"""server.py — tradein-browser service main process.

Этот модуль запускается как точка входа контейнера ``tradein-browser``.
Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и
экспонирует простой HTTP API на базе aiohttp:

    GET  /health  → {"status": "ok"}
    POST /fetch   → {"url": "..."} → {"html": "..."}
    POST /login   → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]}

Такой подход выбран потому, что ``camoufox.server.launch_server`` (Playwright
WS-сервер) несовместим с современными версиями playwright (1.45–1.60):
``browserServerImpl.js`` отсутствует в дистрибутиве → контейнер краш-лупится
с MODULE_NOT_FOUND / RuntimeError при запуске через ``launch_server``.
Локальный запуск ``AsyncCamoufox`` работает стабильно — проверено.

Переменные окружения:
    BROWSER_PORT            — TCP-порт HTTP-сервера (default: 3000)
    BROWSER_RECYCLE_PAGES   — страниц в одном сеансе браузера до перезапуска (default: 15)
    BROWSER_NAV_TIMEOUT_MS  — таймаут page.goto в мс (default: 60000)
    BROWSER_WAIT_MS         — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000)
    AVITO_PROXY_URL         — прокси ``http://user:pass@host:port`` (mobileproxy, #623);
                              SCRAPER_PROXY_URL — generic-fallback. Без него — soft-block.

Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS):
    pre_click_selectors — список селекторов для последовательного клика до формы;
    каждый клик non-fatal (пропускается при отсутствии элемента). Двухшаговый
    submit: после первого сабмита Cian может показать «Введите пароль» повторно.

Эндпоинт /fetch доступен из Docker-сети как ``http://tradein-browser:3000/fetch``.
"""

import asyncio
import base64
import logging
import os
from urllib.parse import urlparse

from aiohttp import web

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)

# ── конфигурация из env ────────────────────────────────────────────────────────

BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
BROWSER_RECYCLE_PAGES: int = int(os.environ.get("BROWSER_RECYCLE_PAGES", "15"))
BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "60000"))
# 6000 (не 2500): avito гидрирует listings client-side ПОСЛЕ domcontentloaded;
# на 2.5с в HTML генерик-шелл без объявлений (0 listings), на 5-6с — полная
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
# Прокси: основной источник — AVITO_PROXY_URL (mobileproxy, #623, лежит в
# backend/.env.runtime, тот же что у curl_cffi-avito). SCRAPER_PROXY_URL —
# опциональный generic-fallback на будущее (cian/yandex). Без прокси camoufox
# фетчит с датацентр-IP → avito отдаёт soft-block заглушку.
SCRAPER_PROXY_URL: str | None = os.environ.get("AVITO_PROXY_URL") or os.environ.get(
    "SCRAPER_PROXY_URL"
)


def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
    """Парсит proxy URL → camoufox proxy dict.

    Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``.
    Возвращает None если proxy_url пуст.

    Returns:
        ``{"server": "http://host:port", "username": "...", "password": "..."}``
        или None.
    """
    if not proxy_url:
        return None

    parsed = urlparse(proxy_url)
    port_part = f":{parsed.port}" if parsed.port else ""
    server = f"{parsed.scheme}://{parsed.hostname}{port_part}"

    result: dict[str, str] = {"server": server}
    if parsed.username:
        result["username"] = parsed.username
    if parsed.password:
        result["password"] = parsed.password

    return result


# ── состояние браузера ─────────────────────────────────────────────────────────

_browser: object | None = None          # текущий экземпляр Browser
_browser_cm: object | None = None       # AsyncCamoufox context manager (для __aexit__)
_page_counter: int = 0                  # страниц с момента последнего (пере)запуска
_fetch_lock: asyncio.Lock | None = None  # сериализация fetch-запросов

# Resilience (#crash-storm): если прокси (mproxy.site) лежит, camoufox делает
# eager public_ip(proxy) для geoip-fingerprint и бросает InvalidProxy. Раньше это
# валило app.startup → exit(1) → docker restart → ∞ (RestartCount 3000+). Теперь
# фейл launch'а НЕ фатален: контейнер остаётся Up, а фоновая задача retry'ит
# launch с backoff пока прокси не вернётся. proxy+geoip СОХРАНЕНЫ (анти-бан).
_BROWSER_RETRY_MIN_S: float = 30.0      # стартовый интервал retry launch'а
_BROWSER_RETRY_MAX_S: float = 60.0      # потолок backoff'а
_browser_retry_task: asyncio.Task[None] | None = None  # handle фоновой retry-задачи


async def _launch_browser() -> None:
    """Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных."""
    global _browser, _browser_cm, _page_counter

    from camoufox.async_api import AsyncCamoufox

    proxy = _parse_proxy(SCRAPER_PROXY_URL)
    kwargs: dict[str, object] = {
        "headless": True,
        "os": "windows",
        "locale": "ru-RU",
        "geoip": True,
        "humanize": True,
    }
    if proxy is not None:
        kwargs["proxy"] = proxy

    logger.info(
        "tradein-browser: запуск AsyncCamoufox (proxy=%s, recycle_pages=%d)",
        proxy is not None,
        BROWSER_RECYCLE_PAGES,
    )
    cm = AsyncCamoufox(**kwargs)  # type: ignore[arg-type]
    browser = await cm.__aenter__()
    _browser_cm = cm
    _browser = browser
    _page_counter = 0
    logger.info("tradein-browser: браузер запущен")


async def _close_browser() -> None:
    """Закрывает текущий экземпляр браузера."""
    global _browser, _browser_cm, _page_counter

    cm = _browser_cm
    if cm is not None:
        try:
            await cm.__aexit__(None, None, None)  # type: ignore[attr-defined]
            logger.info("tradein-browser: браузер закрыт")
        except Exception as exc:
            logger.warning("tradein-browser: ошибка при закрытии браузера: %s", exc)
        finally:
            _browser = None
            _browser_cm = None
            _page_counter = 0


async def _relaunch_browser() -> None:
    """Закрывает текущий браузер и запускает новый (recycle / crash-recovery)."""
    logger.info("tradein-browser: перезапуск браузера")
    await _close_browser()
    await _launch_browser()


async def _try_launch_browser() -> bool:
    """Одна попытка launch'а с санитизированным логом фейла.

    НЕ пробрасывает исключение наружу — возвращает True/False. Критично для
    resilience: фейл launch'а (прокси лёг → InvalidProxy) не должен валить
    startup или фоновую задачу. Креды прокси НЕ логируются: camoufox светит
    ``user:pass@host`` в тексте своего исключения, поэтому мы логируем только
    тип + санитизированное сообщение, без ``str(exc)``.

    При частичном фейле (CM создан, но __aenter__ не довёл) чистит состояние
    через _close_browser, чтобы следующий retry стартовал с чистого листа.
    """
    try:
        await _launch_browser()
        return True
    except Exception as exc:
        logger.warning(
            "tradein-browser: browser launch failed: proxy unreachable "
            "(exc_type=%s) — контейнер остаётся Up, retry в фоне",
            type(exc).__name__,
        )
        # Подчистить возможный частичный CM перед следующей попыткой.
        await _close_browser()
        return False


async def _browser_retry_loop() -> None:
    """Фоновый retry launch'а браузера с backoff, пока прокси не вернётся.

    Идемпотентна: если браузер уже поднят (_browser не None) — сразу выходит.
    Иначе ретраит _try_launch_browser с возрастающим интервалом
    (_BROWSER_RETRY_MIN_S → _BROWSER_RETRY_MAX_S). Завершается после первого
    успешного launch'а. Отменяется в _on_cleanup (CancelledError пробрасывается).
    """
    if _browser is not None:
        return

    delay = _BROWSER_RETRY_MIN_S
    while _browser is None:
        await asyncio.sleep(delay)
        # Лок сериализует launch с in-flight /fetch lazy-launch и recycle —
        # иначе два __aenter__ на один глобал _browser затрут друг друга.
        assert _fetch_lock is not None, "_fetch_lock not initialised"
        async with _fetch_lock:
            if _browser is not None:
                return  # handler уже поднял браузер — задача отработала
            logger.info("tradein-browser: фоновый retry launch'а браузера")
            launched = await _try_launch_browser()
        if launched:
            logger.info("tradein-browser: браузер поднят после retry, задача завершена")
            return
        delay = min(delay * 1.5, _BROWSER_RETRY_MAX_S)


def _start_retry_task(app: web.Application) -> None:
    """Запускает (если ещё нет) фоновую retry-задачу и кладёт handle в app."""
    global _browser_retry_task

    existing = _browser_retry_task
    if existing is not None and not existing.done():
        return  # задача уже бежит — не плодим дубли

    task = asyncio.create_task(_browser_retry_loop())
    _browser_retry_task = task
    app["browser_retry_task"] = task


# ── aiohttp lifecycle hooks ────────────────────────────────────────────────────


async def _on_startup(app: web.Application) -> None:
    global _fetch_lock
    _fetch_lock = asyncio.Lock()

    # Launch НЕ фатален: если прокси лежит, camoufox бросит InvalidProxy. Раньше
    # это валило startup → exit(1) → restart-loop. Теперь ловим, остаёмся Up и
    # ретраим в фоне. proxy+geoip сохранены — никакого direct-fetch fallback.
    if not await _try_launch_browser():
        logger.warning(
            "tradein-browser: старт без браузера (launch failed) — запуск фонового retry"
        )
        _start_retry_task(app)


async def _on_cleanup(app: web.Application) -> None:
    global _browser_retry_task

    task = _browser_retry_task
    if task is not None and not task.done():
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass
        except Exception as exc:
            logger.warning(
                "tradein-browser: ошибка при отмене retry-задачи: %s", type(exc).__name__
            )
    _browser_retry_task = None
    await _close_browser()


# ── handlers ───────────────────────────────────────────────────────────────────


async def health_handler(request: web.Request) -> web.Response:
    # Всегда 200 — иначе compose healthcheck (если появится) убьёт контейнер,
    # хотя процесс жив и ретраит браузер. browser_ready — для observability:
    # видно лежит ли прокси, не теряя контейнер. Текущий prod compose НЕ имеет
    # healthcheck на browser (depends_on: condition: service_started), так что
    # 200-без-браузера никого не рестартит.
    return web.json_response({"status": "ok", "browser_ready": _browser is not None})


async def fetch_handler(request: web.Request) -> web.Response:
    """POST /fetch {"url": "..."} → {"html": "..."}

    Выполняет навигацию в браузере и возвращает полный HTML страницы.
    Запросы сериализованы через lock (один за раз).
    """
    global _page_counter

    try:
        body = await request.json()
    except Exception:
        return web.json_response({"error": "invalid JSON body"}, status=400)

    url: str | None = body.get("url")
    if not url:
        return web.json_response({"error": "missing 'url' field"}, status=400)

    assert _fetch_lock is not None, "_fetch_lock not initialised"

    async with _fetch_lock:
        # Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка
        # под локом — вдруг прокси уже вернулся. НИКОГДА не фетчим без прокси/
        # браузера: если всё ещё None → 503 (caller словит как HTTPStatusError
        # и обработает как failed fetch, а не как crash).
        if _browser is None:
            await _try_launch_browser()
        if _browser is None:
            logger.warning("tradein-browser: /fetch 503 — браузер недоступен (proxy may be down)")
            return web.json_response(
                {"error": "browser unavailable (proxy may be down)"}, status=503
            )

        try:
            html = await _do_fetch(url)
        except Exception as exc:
            logger.error(
                "tradein-browser: fetch error url=%r: %s: %s", url, type(exc).__name__, exc
            )
            return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)

    return web.json_response({"html": html})


async def _do_fetch(url: str) -> str:
    """Одна попытка навигации; при краше браузера — перезапускает и повторяет один раз."""
    global _page_counter

    try:
        return await _fetch_once(url)
    except Exception as exc:
        if _is_browser_crash(exc):
            logger.warning(
                "tradein-browser: краш браузера (%s), перезапуск + retry: %s",
                type(exc).__name__,
                url,
            )
            await _relaunch_browser()
            return await _fetch_once(url)
        raise


async def _fetch_once(url: str) -> str:
    """Открывает страницу, переходит по URL, ждёт JS, возвращает HTML."""
    global _page_counter

    browser = _browser
    assert browser is not None, "browser not launched"

    page = await browser.new_page()  # type: ignore[attr-defined]
    try:
        await page.goto(url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded")  # type: ignore[attr-defined]
        if BROWSER_WAIT_MS > 0:
            await page.wait_for_timeout(BROWSER_WAIT_MS)  # type: ignore[attr-defined]
        html: str = await page.content()  # type: ignore[attr-defined]
    finally:
        await page.close()  # type: ignore[attr-defined]

    _page_counter += 1
    logger.debug("tradein-browser: fetch OK url=%r pages_since_launch=%d", url, _page_counter)

    if _page_counter >= BROWSER_RECYCLE_PAGES:
        logger.info(
            "tradein-browser: recycle threshold (%d) достигнут, перезапуск браузера",
            BROWSER_RECYCLE_PAGES,
        )
        await _relaunch_browser()

    return html


def _is_browser_crash(exc: BaseException) -> bool:
    """Проверяет является ли исключение признаком краша / разрыва браузера."""
    cls_name = type(exc).__name__
    if cls_name in ("TargetClosedError", "BrowserClosedError", "ConnectionClosedError"):
        return True
    msg = str(exc).lower()
    return (
        "browser has been closed" in msg
        or "target closed" in msg
        or "connection closed" in msg
        or "browser disconnected" in msg
    )


# ── login handler ──────────────────────────────────────────────────────────────


class LoginError(Exception):
    """Исключение с диагностическим payload для /login эндпоинта."""

    def __init__(self, payload: dict) -> None:
        super().__init__(str(payload))
        self.payload = payload


async def login_handler(request: web.Request) -> web.Response:
    """POST /login {...} → {"cookies": [...]}

    Выполняет вход на указанную страницу (email + пароль), ждёт появления
    success_cookie, возвращает полный список cookies браузерного контекста.
    Запросы сериализованы через lock (один за раз — один браузер).
    """
    try:
        body = await request.json()
    except Exception:
        return web.json_response({"error": "invalid JSON body"}, status=400)

    missing = [f for f in ("url", "email", "password", "email_selector",
                           "password_selector", "submit_selector") if not body.get(f)]
    if missing:
        return web.json_response({"error": f"missing required fields: {missing}"}, status=400)

    assert _fetch_lock is not None, "_fetch_lock not initialised"

    async with _fetch_lock:
        # Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
        # Lazy-попытка под локом, иначе 503 — без прокси не логинимся.
        if _browser is None:
            await _try_launch_browser()
        if _browser is None:
            logger.warning("tradein-browser: /login 503 — браузер недоступен (proxy may be down)")
            return web.json_response(
                {"error": "browser unavailable (proxy may be down)"}, status=503
            )

        try:
            cookies = await _do_login(body)
        except LoginError as exc:
            return web.json_response(exc.payload, status=502)
        except Exception as exc:
            logger.error(
                "tradein-browser: login error url=%r: %s: %s",
                body.get("url"),
                type(exc).__name__,
                exc,
            )
            return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=502)

    return web.json_response({"cookies": cookies})


async def _do_login(params: dict) -> list[dict]:
    """Одна попытка логина; при краше браузера — перезапускает и повторяет."""
    try:
        return await _login_once(params)
    except LoginError:
        raise  # диагностический payload идёт напрямую в хендлер
    except Exception as exc:
        if _is_browser_crash(exc):
            logger.warning(
                "tradein-browser: краш браузера (%s) при логине, перезапуск + retry",
                type(exc).__name__,
            )
            await _relaunch_browser()
            return await _login_once(params)
        raise


async def _wait_cookie(page: object, name: str, tries: int, interval_ms: int) -> bool:
    """Ожидает появления cookie с заданным именем в контексте страницы.

    Returns:
        True если cookie появился в течение tries × interval_ms мс, иначе False.
    """
    for _ in range(tries):
        cookies = await page.context.cookies()  # type: ignore[attr-defined]
        if any(c["name"] == name for c in cookies):
            return True
        await page.wait_for_timeout(interval_ms)  # type: ignore[attr-defined]
    return False


async def _login_once(params: dict) -> list[dict]:
    """Открывает страницу, заполняет форму, нажимает submit, ждёт success_cookie.

    Поддерживает список pre_click_selectors (клик по очереди, каждый non-fatal)
    и 2-шаговый submit Cian: после первого сабмита появляется экран
    «Введите пароль» — дозаполняем password и кликаем повторно (макс. 2 попытки).
    """
    global _page_counter

    browser = _browser
    assert browser is not None, "browser not launched"

    url: str = params["url"]
    email: str = params["email"]
    password: str = params["password"]
    email_selector: str = params["email_selector"]
    password_selector: str = params["password_selector"]
    submit_selector: str = params["submit_selector"]
    pre_click_selectors: list[str] = params.get("pre_click_selectors") or []
    success_cookie: str = params.get("success_cookie", "")
    wait_ms: int = params.get("wait_ms") or BROWSER_WAIT_MS

    page = await browser.new_page()  # type: ignore[attr-defined]
    try:
        await page.goto(  # type: ignore[attr-defined]
            url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
        )
        await page.wait_for_timeout(wait_ms)  # type: ignore[attr-defined]

        # Pre-click sequence: каждый клик non-fatal (AnotherAccountBtn на fresh
        # headless-сессии отсутствует — молча пропускается).
        for sel in pre_click_selectors:
            try:
                await page.click(sel, timeout=12000)  # type: ignore[attr-defined]
                await page.wait_for_timeout(1200)  # type: ignore[attr-defined]
            except Exception:
                logger.info("login: pre-click skipped: %s", sel)

        await page.fill(email_selector, email, timeout=15000)  # type: ignore[attr-defined]
        await page.fill(password_selector, password, timeout=15000)  # type: ignore[attr-defined]

        # 2-шаговый submit: Cian после первого сабмита показывает экран
        # «Введите пароль» с тем же password_selector — дозаполняем и повторяем.
        cookie_found = False
        for attempt in range(2):
            await page.click(submit_selector, timeout=15000)  # type: ignore[attr-defined]
            if success_cookie:
                cookie_found = await _wait_cookie(
                    page, success_cookie, tries=20, interval_ms=500
                )
            if cookie_found:
                break
            # 2-step: если поле пароля ещё видимо — дозаполнить и повторить
            still_visible = False
            try:
                still_visible = await page.is_visible(password_selector)  # type: ignore[attr-defined]
            except Exception:
                pass
            if attempt == 0 and still_visible:
                await page.fill(password_selector, password, timeout=15000)  # type: ignore[attr-defined]
                await page.wait_for_timeout(800)  # type: ignore[attr-defined]
                continue
            break

        logger.info(
            "tradein-browser: login success_cookie=%r present=%s",
            success_cookie,
            cookie_found,
        )

        cookies: list[dict] = await page.context.cookies()  # type: ignore[attr-defined]
        logger.info(
            "tradein-browser: login OK url=%r email_selector=%r cookie_count=%d",
            url,
            email_selector,
            len(cookies),
        )
    except Exception as exc:
        screenshot_b64 = ""
        try:
            screenshot_bytes: bytes = await page.screenshot()  # type: ignore[attr-defined]
            screenshot_b64 = base64.b64encode(screenshot_bytes).decode()
        except Exception:
            pass
        page_url = ""
        try:
            page_url = page.url  # type: ignore[attr-defined]
        except Exception:
            pass
        await page.close()  # type: ignore[attr-defined]
        raise LoginError({
            "error": f"{type(exc).__name__}: {exc}",
            "page_url": page_url,
            "screenshot_b64": screenshot_b64,
        }) from exc

    await page.close()  # type: ignore[attr-defined]
    _page_counter += 1
    if _page_counter >= BROWSER_RECYCLE_PAGES:
        logger.info(
            "tradein-browser: recycle threshold (%d) достигнут после login, перезапуск браузера",
            BROWSER_RECYCLE_PAGES,
        )
        await _relaunch_browser()

    return cookies

# ── entrypoint ─────────────────────────────────────────────────────────────────


def build_app() -> web.Application:
    app = web.Application()
    app.on_startup.append(_on_startup)
    app.on_cleanup.append(_on_cleanup)
    app.router.add_get("/health", health_handler)
    app.router.add_post("/fetch", fetch_handler)
    app.router.add_post("/login", login_handler)
    return app


if __name__ == "__main__":
    logger.info("tradein-browser: старт HTTP-сервера на порту %d", BROWSER_PORT)
    web.run_app(build_app(), host="0.0.0.0", port=BROWSER_PORT)
