"""test_server.py — юниты per-provider concurrency для tradein-browser (#1793).

Проверяет ядро per-provider модели:
  1. _provider_for_url — корректный маппинг host → provider (avito/cian/yandex/generic);
  2. _provider_proxy — BROWSER_PROXY_* primary с фолбэком на legacy *_PROXY_URL;
  3. _resolve_provider — явный body["provider"] перебивает host, иначе host;
  4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи),
     два к ОДНОМУ — сериализованы (один лок). Через _do_fetch с asyncio.Event и
     замером перекрытия по времени.

camoufox НЕ запускается: _ensure_browser / _do_fetch мокаются. Хендлер вызывается
напрямую (make_mocked_request не поднимает сокет).

Запуск (из tradein-mvp/browser/)::

    python -m pytest test_server.py -q
"""

from __future__ import annotations

import asyncio
import importlib.util
import json
from pathlib import Path
from typing import Any

import pytest
from aiohttp.test_utils import make_mocked_request

# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)


def _json_body(response: Any) -> dict[str, Any]:
    return json.loads(response.body.decode())


@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
    """Чистое per-provider состояние + инициализированный _locks_guard на каждый тест."""
    monkeypatch.setattr(server, "_browsers", {})
    monkeypatch.setattr(server, "_browser_cms", {})
    monkeypatch.setattr(server, "_page_counters", {})
    monkeypatch.setattr(server, "_locks", {})
    monkeypatch.setattr(server, "_retry_tasks", {})
    monkeypatch.setattr(server, "_last_goto_at", {})
    monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())


# ── _provider_for_url ────────────────────────────────────────────────────────────


def test_provider_for_url_avito() -> None:
    assert server._provider_for_url("https://www.avito.ru/moskva/kvartiry") == "avito"
    assert server._provider_for_url("https://m.avito.ru/item/123") == "avito"


def test_provider_for_url_cian() -> None:
    assert server._provider_for_url("https://www.cian.ru/sale/flat/1/") == "cian"
    assert server._provider_for_url("https://spb.cian.ru/x") == "cian"


def test_provider_for_url_yandex() -> None:
    assert server._provider_for_url("https://realty.yandex.ru/offer/1/") == "yandex"


def test_provider_for_url_generic_fallback() -> None:
    assert server._provider_for_url("https://example.com/x") == "generic"
    assert server._provider_for_url("https://domclick.ru/x") == "generic"
    # Битый / пустой URL — без хоста → generic, не падаем.
    assert server._provider_for_url("not-a-url") == "generic"
    assert server._provider_for_url("") == "generic"


# ── _provider_proxy ──────────────────────────────────────────────────────────────


def test_provider_proxy_uses_browser_proxy_primary(monkeypatch: pytest.MonkeyPatch) -> None:
    """BROWSER_PROXY_* — primary источник для каждого поставщика."""
    monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://u:p@avito-proxy:8080")
    monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@cian-proxy:8080")
    monkeypatch.setenv("BROWSER_PROXY_YANDEX", "http://u:p@yandex-proxy:8080")
    assert server._provider_proxy("avito") == "http://u:p@avito-proxy:8080"
    assert server._provider_proxy("cian") == "http://u:p@cian-proxy:8080"
    assert server._provider_proxy("yandex") == "http://u:p@yandex-proxy:8080"


def test_provider_proxy_falls_back_to_legacy(monkeypatch: pytest.MonkeyPatch) -> None:
    """Без BROWSER_PROXY_* берётся legacy *_PROXY_URL фолбэк."""
    monkeypatch.delenv("BROWSER_PROXY_AVITO", raising=False)
    monkeypatch.delenv("BROWSER_PROXY_CIAN", raising=False)
    monkeypatch.delenv("BROWSER_PROXY_YANDEX", raising=False)
    monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@legacy-avito:8080")
    monkeypatch.setenv("CIAN_PROXY_URL", "http://u:p@legacy-cian:8080")
    monkeypatch.setenv("YANDEX_PROXY_URL", "http://u:p@legacy-yandex:8080")
    assert server._provider_proxy("avito") == "http://u:p@legacy-avito:8080"
    assert server._provider_proxy("cian") == "http://u:p@legacy-cian:8080"
    assert server._provider_proxy("yandex") == "http://u:p@legacy-yandex:8080"


def test_provider_proxy_generic(monkeypatch: pytest.MonkeyPatch) -> None:
    """generic: SCRAPER_PROXY_URL primary, AVITO_PROXY_URL фолбэк."""
    monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
    monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@avito:8080")
    assert server._provider_proxy("generic") == "http://u:p@avito:8080"
    monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@scraper:8080")
    assert server._provider_proxy("generic") == "http://u:p@scraper:8080"


def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
    """Ни одной env → None (direct, как одиночный браузер без прокси)."""
    for var in (
        "BROWSER_PROXY_CIAN",
        "CIAN_PROXY_URL",
    ):
        monkeypatch.delenv(var, raising=False)
    assert server._provider_proxy("cian") is None


def test_provider_proxy_browser_proxy_wins_over_legacy(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """BROWSER_PROXY_* перебивает legacy *_PROXY_URL (http vs возможный socks5)."""
    monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@http-cian:8080")
    monkeypatch.setenv("CIAN_PROXY_URL", "socks5://u:p@socks-cian:1080")
    assert server._provider_proxy("cian") == "http://u:p@http-cian:8080"


# ── _resolve_provider ────────────────────────────────────────────────────────────


def test_resolve_provider_by_host() -> None:
    assert server._resolve_provider({}, "https://www.avito.ru/x") == "avito"
    assert server._resolve_provider({}, "https://www.cian.ru/x") == "cian"


def test_resolve_provider_explicit_overrides_host() -> None:
    """Явный валидный body["provider"] перебивает host."""
    assert server._resolve_provider({"provider": "yandex"}, "https://www.avito.ru/x") == "yandex"


def test_resolve_provider_invalid_explicit_falls_back_to_host() -> None:
    """Невалидный body["provider"] игнорируется → берём по host."""
    assert server._resolve_provider({"provider": "bogus"}, "https://www.cian.ru/x") == "cian"
    assert server._resolve_provider({"provider": 123}, "https://www.cian.ru/x") == "cian"


def test_resolve_provider_uses_source_field() -> None:
    """browser_fetcher шлёт {"url","source"} — explicit берётся и из "source"."""
    assert server._resolve_provider({"source": "avito"}, "https://example.com/x") == "avito"
    assert server._resolve_provider({"source": "yandex"}, "https://www.avito.ru/x") == "yandex"


def test_resolve_provider_domclick_source_to_generic() -> None:
    """domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.

    'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: в prod
    нет выделенного BROWSER_PROXY_DOMCLICK.
    """
    assert (
        server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"
    )


# ── _lock_for: один лок на поставщика ────────────────────────────────────────────


def test_lock_for_is_stable_per_provider() -> None:
    """_lock_for возвращает один и тот же объект-лок для поставщика, разные — для разных."""

    async def _run() -> tuple[Any, Any, Any]:
        a1 = await server._lock_for("avito")
        a2 = await server._lock_for("avito")
        c1 = await server._lock_for("cian")
        return a1, a2, c1

    a1, a2, c1 = asyncio.run(_run())
    assert a1 is a2, "лок одного поставщика стабилен"
    assert a1 is not c1, "локи разных поставщиков различны"


# ── concurrency: параллель между поставщиками, сериализация внутри ───────────────


def _make_fetch_request(url: str) -> Any:
    request = make_mocked_request("POST", "/fetch")
    request.json = lambda: _coro({"url": url})  # type: ignore[method-assign]
    return request


class _OverlapProbe:
    """Мок _do_fetch: меряет максимальное число одновременно активных fetch per-provider."""

    def __init__(self, hold: float = 0.05) -> None:
        self.hold = hold
        self.active: dict[str, int] = {}
        self.peak: dict[str, int] = {}
        self.global_active = 0
        self.global_peak = 0

    async def __call__(
        self,
        provider: str,
        url: str,
        *,
        origin: str | None = None,
        cookies: dict | None = None,
    ) -> str:
        self.active[provider] = self.active.get(provider, 0) + 1
        self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])
        self.global_active += 1
        self.global_peak = max(self.global_peak, self.global_active)
        try:
            await asyncio.sleep(self.hold)
        finally:
            self.active[provider] -= 1
            self.global_active -= 1
        return f"<html>{provider}</html>"


def test_same_provider_fetches_are_serialized(monkeypatch: pytest.MonkeyPatch) -> None:
    """Два /fetch к ОДНОМУ поставщику сериализованы (один лок) — peak==1."""
    probe = _OverlapProbe()

    async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
        server._browsers[provider] = object()
        return True

    monkeypatch.setattr(server, "_ensure_browser", _ensure)
    monkeypatch.setattr(server, "_do_fetch", probe)

    async def _run() -> None:
        await asyncio.gather(
            server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
            server.fetch_handler(_make_fetch_request("https://www.avito.ru/b")),
            server.fetch_handler(_make_fetch_request("https://www.avito.ru/c")),
        )

    asyncio.run(_run())
    assert probe.peak["avito"] == 1, "внутри поставщика строго ≤1 одновременный fetch"


def test_different_providers_fetch_concurrently(monkeypatch: pytest.MonkeyPatch) -> None:
    """Два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи) — global peak>1."""
    probe = _OverlapProbe()

    async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
        server._browsers[provider] = object()
        return True

    monkeypatch.setattr(server, "_ensure_browser", _ensure)
    monkeypatch.setattr(server, "_do_fetch", probe)

    async def _run() -> list[Any]:
        return await asyncio.gather(
            server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
            server.fetch_handler(_make_fetch_request("https://www.cian.ru/b")),
            server.fetch_handler(_make_fetch_request("https://realty.yandex.ru/c")),
        )

    responses = asyncio.run(_run())
    assert all(r.status == 200 for r in responses)
    # >=2 (не ==3): на нагруженном CI планировщик может не вывести все три корутины
    # в overlap одновременно; перекрытие хотя бы двух уже доказывает параллельность
    # между поставщиками. Same-provider тест (peak==1) остаётся строгим.
    assert probe.global_peak >= 2, "разные поставщики должны фетчить конкурентно"
    # У каждого поставщика по одному запросу — внутренний peak == 1.
    assert probe.peak == {"avito": 1, "cian": 1, "yandex": 1}


def test_mixed_load_serializes_within_parallelizes_across(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Смешанная нагрузка: avito×2 сериализованы, но параллельны с cian×1."""
    probe = _OverlapProbe()

    async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
        server._browsers[provider] = object()
        return True

    monkeypatch.setattr(server, "_ensure_browser", _ensure)
    monkeypatch.setattr(server, "_do_fetch", probe)

    async def _run() -> None:
        await asyncio.gather(
            server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
            server.fetch_handler(_make_fetch_request("https://www.avito.ru/b")),
            server.fetch_handler(_make_fetch_request("https://www.cian.ru/c")),
        )

    asyncio.run(_run())
    assert probe.peak["avito"] == 1, "avito сериализован сам с собой"
    assert probe.peak["cian"] == 1
    assert probe.global_peak >= 2, "avito и cian должны перекрываться во времени"


# ── _parse_block_types: env-парсинг ───────────────────────────────────────────────


def test_parse_block_types_default() -> None:
    """None (env не задана) → дефолт font,media."""
    assert server._parse_block_types(None) == frozenset({"font", "media"})


def test_parse_block_types_empty_disables() -> None:
    """Пустая строка → пустой набор (ничего не блокируем)."""
    assert server._parse_block_types("") == frozenset()
    assert server._parse_block_types("   ") == frozenset()
    assert server._parse_block_types(" , , ") == frozenset()


def test_parse_block_types_custom_csv() -> None:
    """CSV парсится с trim/lower, дубликаты схлопываются."""
    assert server._parse_block_types("font, Media , font") == frozenset({"font", "media"})
    assert server._parse_block_types("FONT,IMAGE") == frozenset({"font", "image"})


# ── route-handler: блокировка font/media, пропуск document/script/css/xhr ──────────


class _FakeRequest:
    def __init__(self, resource_type: str) -> None:
        self.resource_type = resource_type


class _FakeRoute:
    """Мок Playwright Route: фиксирует, был ли abort или continue_."""

    def __init__(self, resource_type: str) -> None:
        self.request = _FakeRequest(resource_type)
        self.aborted = False
        self.continued = False

    async def abort(self) -> None:
        self.aborted = True

    async def continue_(self) -> None:
        self.continued = True


class _CapturePage:
    """Мок page: ловит handler, переданный в page.route, чтобы дёрнуть его напрямую."""

    def __init__(self) -> None:
        self.handler: Any = None
        self.route_pattern: Any = None

    async def route(self, pattern: str, handler: Any) -> None:
        self.route_pattern = pattern
        self.handler = handler


def test_resource_block_aborts_font_and_media(monkeypatch: pytest.MonkeyPatch) -> None:
    """Дефолтный набор font/media → abort; document/script/stylesheet/xhr/fetch → continue."""
    monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset({"font", "media"}))
    page = _CapturePage()

    asyncio.run(server._apply_resource_block(page))
    assert page.handler is not None, "route-handler должен быть навешен"
    assert page.route_pattern == "**/*"

    async def _check(resource_type: str) -> _FakeRoute:
        route = _FakeRoute(resource_type)
        await page.handler(route)
        return route

    # Блокируемые типы → abort
    for rt in ("font", "media"):
        route = asyncio.run(_check(rt))
        assert route.aborted is True and route.continued is False, f"{rt} должен abort'иться"

    # Критичные для extraction/fingerprint типы → continue
    for rt in ("document", "script", "stylesheet", "xhr", "fetch", "image"):
        route = asyncio.run(_check(rt))
        assert route.continued is True and route.aborted is False, f"{rt} должен проходить"


def test_resource_block_noop_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
    """Пустой _BLOCKED_TYPES → page.route не навешивается (no-op)."""
    monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset())
    page = _CapturePage()
    asyncio.run(server._apply_resource_block(page))
    assert page.handler is None, "при пустом наборе route навешивать не должны"


def test_resource_block_handler_degrades_to_continue(monkeypatch: pytest.MonkeyPatch) -> None:
    """Сбой abort() деградирует в continue_() — битый ресурс не рвёт навигацию."""
    monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset({"font"}))
    page = _CapturePage()
    asyncio.run(server._apply_resource_block(page))

    class _FlakyRoute(_FakeRoute):
        async def abort(self) -> None:
            raise RuntimeError("route already handled")

    route = _FlakyRoute("font")
    asyncio.run(page.handler(route))
    assert route.continued is True, "при сбое abort должны fallback'нуть в continue_()"


# ── _pace_provider: межстраничный пейсинг одного провайдера ────────────────────────


def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
    """Первый goto провайдера — без ожидания, но timestamp пишется."""
    monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
    slept: list[float] = []

    async def _fake_sleep(d: float) -> None:
        slept.append(d)

    monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
    asyncio.run(server._pace_provider("avito"))
    assert slept == [], "первый вызов не должен спать"
    assert "avito" in server._last_goto_at


def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) -> None:
    """Второй goto в пределах интервала → sleep на остаток (interval - elapsed)."""
    monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
    slept: list[float] = []

    async def _fake_sleep(d: float) -> None:
        slept.append(d)

    # _pace_provider читает time() дважды за вызов (now + запись last).
    # 1-й вызов: now=100.0, last←100.0. 2-й: now=100.5 (elapsed=0.5→remaining=1.5),
    # после sleep last←102.0.
    clock = iter([100.0, 100.0, 100.5, 102.0])

    class _FakeLoop:
        def time(self) -> float:
            return next(clock)

    monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
    monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())

    async def _run() -> None:
        await server._pace_provider("cian")  # первый — last=100.0
        await server._pace_provider("cian")  # второй — elapsed=0.5, remaining=1.5

    asyncio.run(_run())
    assert len(slept) == 1, "второй вызов должен один раз sleep'нуть"
    assert slept[0] == pytest.approx(1.5), "ждём остаток интервала (2.0 - 0.5)"


def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.MonkeyPatch) -> None:
    """Если с прошлого goto прошло >= интервала → не спим."""
    monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
    slept: list[float] = []

    async def _fake_sleep(d: float) -> None:
        slept.append(d)

    # 2 чтения time() на вызов: [now1, last1, now2, last2]; elapsed=5.0 > 2.0 → no sleep.
    clock = iter([100.0, 100.0, 105.0, 105.0])

    class _FakeLoop:
        def time(self) -> float:
            return next(clock)

    monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
    monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())

    async def _run() -> None:
        await server._pace_provider("yandex")
        await server._pace_provider("yandex")  # elapsed=5.0 > 2.0 → no sleep

    asyncio.run(_run())
    assert slept == [], "интервал уже прошёл — спать не нужно"


def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None:
    """BROWSER_MIN_PAGE_INTERVAL_S <= 0 → пейсинг выключен, timestamp не пишется."""
    monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
    slept: list[float] = []

    async def _fake_sleep(d: float) -> None:
        slept.append(d)

    monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)

    async def _run() -> None:
        await server._pace_provider("avito")
        await server._pace_provider("avito")

    asyncio.run(_run())
    assert slept == []
    assert "avito" not in server._last_goto_at


def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) -> None:
    """Timestamp независим по провайдерам — пейсинг avito не влияет на cian."""
    monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
    slept: list[float] = []

    async def _fake_sleep(d: float) -> None:
        slept.append(d)

    # 2 чтения time() на вызов: avito [100.0, 100.0]; cian первый [100.1, 100.1]
    # (нет last для cian → no sleep).
    clock = iter([100.0, 100.0, 100.1, 100.1])

    class _FakeLoop:
        def time(self) -> float:
            return next(clock)

    monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
    monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())

    async def _run() -> None:
        await server._pace_provider("avito")
        await server._pace_provider("cian")

    asyncio.run(_run())
    assert slept == [], "разные провайдеры не пейсят друг друга"


async def _coro(value: Any) -> Any:
    return value
