"""browser_fetcher.py — HTTP-клиент к tradein-browser сервису (#884/#905).

Тонкий клиент над tradein-browser контейнером, который запускает AsyncCamoufox
локально и экспонирует HTTP API (POST /fetch).

Публичный интерфейс не изменился:

    async with BrowserFetcher() as fetcher:
        html = await fetcher.fetch("https://example.com")

Внутреннее устройство: httpx.AsyncClient + POST к settings.browser_http_endpoint.
Recycle, crash-recovery и управление браузером живут на стороне сервера (server.py).
При HTTPError / ConnectError делает одну повторную попытку после короткой паузы,
затем пробрасывает исключение.

Архитектура выбрана потому, что playwright WS-сервер (launch_server) несовместим
с playwright >=1.45: ``browserServerImpl.js`` отсутствует → MODULE_NOT_FOUND.
Локальный AsyncCamoufox + HTTP — работающая альтернатива.
"""

from __future__ import annotations

import asyncio
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from typing import TYPE_CHECKING

import httpx

if TYPE_CHECKING:
    from scraper_kit.contracts import ProxyProvider

logger = logging.getLogger(__name__)

_RETRY_SLEEP_S: float = 1.0
_HTTP_TIMEOUT_S: float = 120.0  # навигация медленная → щедрый таймаут


class BrowserFetcher:
    """Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.

    Использование::

        async with BrowserFetcher() as fetcher:
            html = await fetcher.fetch("https://example.com")
    """

    def __init__(
        self,
        source: str = "avito",
        fetch_timeout_s: float = _HTTP_TIMEOUT_S,
        *,
        endpoint: str,
        proxy_provider: ProxyProvider | None = None,
        use_pool: bool = False,
    ) -> None:
        # source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер
        # роутит /fetch по нему на отдельный браузер+прокси, когда включён
        # FEATURE_BROWSER_POOL_ENABLED (Phase 1). При выключенном флаге source
        # игнорируется — поведение не меняется.
        # fetch_timeout_s — таймаут httpx-клиента для POST /fetch. Yandex-путь передаёт
        # 30s чтобы один проблемный combo занимал ≤30s×retries вместо 120s×retries.
        # endpoint — HTTP-эндпоинт tradein-browser сервиса. Инжектируется вызывающей
        # стороной (продуктовый код передаёт settings.browser_http_endpoint /
        # ScraperConfig.browser_http_endpoint) — kit НЕ импортирует app.core.config.
        #
        # #2164 P4 (ship-dark за use_pool=config.use_proxy_pool_browser):
        # proxy_provider + use_pool → на каждый /fetch scraper-сторона делает
        # acquire(source), кладёт lease.url в тело запроса ({"proxy": ...}), а
        # tradein-browser релончит camoufox с этим прокси (только при реальной смене).
        # После fetch — mark_health + release (finally, lease не течёт). use_pool=False
        # (дефолт) ИЛИ пустой пул → proxy в теле не шлём, браузер юзает свой env-прокси
        # (BROWSER_PROXY_*), поведение не меняется.
        self._source = source
        self._fetch_timeout_s = fetch_timeout_s
        self._client: httpx.AsyncClient | None = None
        self._endpoint: str | None = endpoint
        self._proxy_provider = proxy_provider
        self._use_pool = use_pool

    # ── lifecycle ──────────────────────────────────────────────────────────────

    async def __aenter__(self) -> BrowserFetcher:
        assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
        self._client = httpx.AsyncClient(timeout=self._fetch_timeout_s)
        logger.info("BrowserFetcher: клиент создан, endpoint=%s", self._endpoint)
        return self

    async def __aexit__(self, *_: object) -> None:
        if self._client is not None:
            await self._client.aclose()
            self._client = None
        logger.debug("BrowserFetcher: клиент закрыт")

    # ── public API ─────────────────────────────────────────────────────────────

    async def fetch(
        self,
        url: str,
        *,
        origin: str | None = None,
        cookies: dict[str, str] | None = None,
    ) -> str:
        """Запрашивает HTML страницы через tradein-browser HTTP-сервис.

        origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (органическая
        навигация: реальные cookies/Referer вместо холодного goto), см. /fetch
        ``origin`` в server.py. None (дефолт) → поведение не меняется (ровно один
        goto(url), как раньше) — таков путь всех providers кроме domclick detail.

        cookies — dict cookie_name→value для инъекции в browser-контекст ПЕРЕД
        навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт
        сессии, empирически подтверждено вживую 2026-07-04, см. app.services.
        domclick_session). None (дефолт) → поведение не меняется, инъекции нет —
        таков путь всех providers кроме domclick detail-debug.

        При HTTPError или ConnectError делает одну повторную попытку после
        короткой паузы. Остальные исключения всплывают к вызывающему коду.

        Returns:
            Полный HTML-контент страницы.
        """
        assert self._client is not None, "BrowserFetcher: используй как async context manager"
        assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"

        try:
            return await self._post_fetch(url, origin, cookies)
        except (httpx.HTTPError, httpx.TransportError) as exc:
            logger.warning(
                "BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
                type(exc).__name__,
                _RETRY_SLEEP_S,
                url,
            )
            await asyncio.sleep(_RETRY_SLEEP_S)
            return await self._post_fetch(url, origin, cookies)

    async def fetch_json(
        self,
        url: str,
        *,
        method: str = "GET",
        headers: dict[str, str] | None = None,
        body: str | None = None,
        origin: str | None = None,
    ) -> dict:
        """In-page fetch() через sidecar /fetch-json. Возвращает {"status": int, "body": str}.

        body — уже сериализованная строка (caller делает json.dumps для POST).
        origin — same-origin страница, на которую перейдёт камуфокс перед fetch.

        При HTTPError / TransportError делает одну повторную попытку после короткой
        паузы. Остальные исключения всплывают к вызывающему коду.
        """
        assert self._client is not None, "BrowserFetcher: используй как async context manager"
        assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"

        try:
            return await self._post_fetch_json(url, method, headers, body, origin)
        except (httpx.HTTPError, httpx.TransportError) as exc:
            logger.warning(
                "BrowserFetcher: ошибка fetch-json запроса (%s), retry через %.1fs: %s",
                type(exc).__name__,
                _RETRY_SLEEP_S,
                url,
            )
            await asyncio.sleep(_RETRY_SLEEP_S)
            return await self._post_fetch_json(url, method, headers, body, origin)

    async def login(
        self,
        *,
        url: str,
        email: str,
        password: str,
        email_selector: str,
        password_selector: str,
        submit_selector: str,
        success_cookie: str,
        pre_click_selectors: list[str] | None = None,
        wait_ms: int | None = None,
    ) -> dict[str, str]:
        """Логинится через tradein-browser /login и возвращает cookies как dict name→value.

        Использует увеличенный таймаут (60s) — логин медленнее обычного fetch.
        При HTTPError / TransportError делает одну повторную попытку.

        Returns:
            Плоский словарь {cookie_name: cookie_value}.
        """
        assert self._client is not None, "BrowserFetcher: используй как async context manager"
        assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"

        body: dict[str, object] = {
            "url": url,
            "email": email,
            "password": password,
            "email_selector": email_selector,
            "password_selector": password_selector,
            "submit_selector": submit_selector,
            "success_cookie": success_cookie,
            "pre_click_selectors": pre_click_selectors or [],
        }
        if wait_ms is not None:
            body["wait_ms"] = wait_ms

        try:
            return await self._post_login(body)
        except (httpx.HTTPError, httpx.TransportError) as exc:
            logger.warning(
                "BrowserFetcher: ошибка login-запроса (%s), retry через %.1fs",
                type(exc).__name__,
                _RETRY_SLEEP_S,
            )
            await asyncio.sleep(_RETRY_SLEEP_S)
            return await self._post_login(body)

    # ── internal ───────────────────────────────────────────────────────────────

    @contextmanager
    def _pool_proxy(self) -> Iterator[tuple[str | None, str | None]]:
        """Взять прокси из пула на один /fetch (#2164 P4, ship-dark + fallback).

        use_pool=False (дефолт) ИЛИ proxy_provider=None → yield (None, None): proxy в тело
        /fetch не кладётся, tradein-browser юзает свой env-прокси (BROWSER_PROXY_*),
        поведение не меняется. Флаг on + пул выдал lease → yield (lease.url, lease.kind);
        на выходе mark_health(ok) + release (в finally — lease не течёт). Пул пуст/ошибка
        acquire → fallback (None, None), НЕ падаем (сбор цел). ok=False если внутри блока
        поднялось исключение (бан/сетевая ошибка) → mark_health(False).
        """
        use_pool = self._use_pool and self._proxy_provider is not None
        lease = None
        if use_pool:
            assert self._proxy_provider is not None  # type-narrowing (use_pool гарантирует)
            try:
                lease = self._proxy_provider.acquire(self._source)
            except Exception:
                logger.warning(
                    "BrowserFetcher: proxy_pool acquire(%s) failed — fallback to env proxy",
                    self._source,
                    exc_info=True,
                )
                lease = None

        if lease is None:
            # off / пул пуст / ошибка acquire → без proxy в теле (browser юзает env).
            yield None, None
            return

        assert self._proxy_provider is not None
        ok = True
        try:
            yield lease.url, lease.kind
        except Exception:
            ok = False
            raise
        finally:
            # mark_health + release — best-effort: проблема пула не должна ронять сбор.
            try:
                self._proxy_provider.mark_health(lease, ok)
            except Exception:
                logger.warning(
                    "BrowserFetcher: proxy_pool mark_health failed for %s",
                    self._source,
                    exc_info=True,
                )
            try:
                self._proxy_provider.release(lease)  # ОБЯЗАТЕЛЬНО — lease не течёт
            except Exception:
                logger.warning(
                    "BrowserFetcher: proxy_pool release failed for %s",
                    self._source,
                    exc_info=True,
                )

    async def _post_fetch(
        self,
        url: str,
        origin: str | None = None,
        cookies: dict[str, str] | None = None,
    ) -> str:
        """Один HTTP POST к /fetch эндпоинту сервиса.

        origin/cookies всегда кладём в payload (даже None) — зеркалит
        _post_fetch_json, сервер (body.get("origin")/body.get("cookies"))
        корректно обрабатывает оба случая.
        """
        assert self._client is not None
        assert self._endpoint is not None

        with self._pool_proxy() as (proxy_url, proxy_kind):
            payload: dict = {
                "url": url,
                "source": self._source,
                "origin": origin,
                "cookies": cookies,
            }
            if proxy_url:
                payload["proxy"] = proxy_url
                if proxy_kind:
                    payload["proxy_kind"] = proxy_kind
            resp = await self._client.post(f"{self._endpoint}/fetch", json=payload)
            resp.raise_for_status()
            data: dict[str, str] = resp.json()
            html = data["html"]
        logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
        return html

    async def _post_fetch_json(
        self,
        url: str,
        method: str,
        headers: dict[str, str] | None,
        body: str | None,
        origin: str | None,
    ) -> dict:
        """Один HTTP POST к /fetch-json эндпоинту сервиса."""
        assert self._client is not None
        assert self._endpoint is not None

        with self._pool_proxy() as (proxy_url, proxy_kind):
            payload: dict = {
                "url": url,
                "source": self._source,
                "method": method,
                "headers": headers or {},
                "body": body,
                "origin": origin,
            }
            if proxy_url:
                payload["proxy"] = proxy_url
                if proxy_kind:
                    payload["proxy_kind"] = proxy_kind
            resp = await self._client.post(f"{self._endpoint}/fetch-json", json=payload)
            resp.raise_for_status()
            data: dict = resp.json()
        # Defensive: контракт сервера — {"status": int, "body": str} (зеркалит как
        # _post_fetch читает data["html"]). Если ключи пропали (несовместимый сервер /
        # прокинутый error-payload) — падаем с понятной ошибкой, а не KeyError ниже по
        # стеку у адаптера, который ждёт r["status"]/r["body"].
        if "status" not in data or "body" not in data:
            raise RuntimeError(
                f"BrowserFetcher: /fetch-json вернул некорректный ответ "
                f"(нет 'status'/'body'): keys={sorted(data)} url={url!r}"
            )
        logger.debug(
            "BrowserFetcher: fetch-json OK url=%r status=%s body_len=%d",
            url,
            data.get("status"),
            len(data.get("body") or ""),
        )
        return data

    async def _post_login(self, body: dict[str, object]) -> dict[str, str]:
        """Один HTTP POST к /login эндпоинту сервиса."""
        assert self._client is not None
        assert self._endpoint is not None

        resp = await self._client.post(
            f"{self._endpoint}/login",
            json=body,
            timeout=httpx.Timeout(60.0),
        )
        if resp.status_code == 502:
            data = resp.json()
            has_screenshot = bool(data.get("screenshot_b64"))
            logger.debug(
                "BrowserFetcher: login 502 — has_screenshot=%s page_url=%r",
                has_screenshot,
                data.get("page_url"),
            )
            raise RuntimeError(
                f"browser login failed: {data.get('error')} url={data.get('page_url')}"
            )
        resp.raise_for_status()
        data = resp.json()
        raw: list[dict[str, object]] = data["cookies"]
        result = {str(c["name"]): str(c["value"]) for c in raw}
        logger.info("BrowserFetcher: login OK cookie_count=%d", len(result))
        return result
