"""Stage 1 #915 — route avito pipeline SERP+detail+houses through BrowserFetcher.

Тесты без сети и без БД. BrowserFetcher мокируется объектом с async fetch().

Покрытие:
  1. fetch_detail(browser_fetcher=mock) → DetailEnrichment (browser branch taken)
  2. fetch_detail(browser_fetcher=mock) → AvitoBlockedError при firewall HTML
  3. fetch_house_catalog(browser_fetcher=mock) → browser branch taken
  4. Curl mode (default) — browser_fetcher=None, curl path используется

Legacy `run_avito_pipeline` browser/curl-mode integration tests (было ранее секцией 4)
удалены вместе с `app.services.scrape_pipeline` (#2397 Part E1) — browser-routing
покрытие на уровне `fetch_detail`/`fetch_house_catalog`/`AvitoScraper._fetch_serp_html`
ниже остаётся нетронутым.

`fetch_house_catalog` ретаргетирован на `scraper_kit.providers.avito.houses` (#2397
Part E2) — legacy `app.services.scrapers.avito_houses` удалён (0 runtime-импортёров,
kit — единственный живой путь).

`fetch_detail`/`AvitoScraper`/`AvitoBlockedError` ретаргетированы на
`scraper_kit.providers.avito.detail`/`serp`/`avito_exceptions` (#2397 финальный шаг
E) — legacy `app.services.scrapers.avito*` удалён (0 runtime-импортёров).
"""

from __future__ import annotations

import json
import os
import urllib.parse
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")

from scraper_kit.avito_exceptions import AvitoBlockedError
from scraper_kit.providers.avito.detail import fetch_detail
from scraper_kit.providers.avito.houses import fetch_house_catalog

FIXTURES = Path(__file__).parent / "fixtures"

# Минимальный HTML для fetch_detail — достаточен для parse_detail_html
DETAIL_HTML = """
<html><body>
<div data-marker="item-view/item-id">№ 99887766 · 18 мая в 10:00 · 100 просмотров</div>
<span itemprop="price" content="5000000">5 000 000 ₽</span>
<div data-marker="item-map-wrapper"
  data-map-lat="56.8" data-map-lon="60.6" data-location-id="1"></div>
<div data-marker="item-view/item-params">
  <ul>
    <li>Количество комнат: 2</li>
    <li>Общая площадь: 50 м²</li>
    <li>Этаж: 5 из 10</li>
  </ul>
</div>
</body></html>
"""

# Firewall HTML — содержит маркер который _is_firewall_page ловит
FIREWALL_HTML = """
<html><head><title>Доступ ограничен</title></head>
<body><div class="firewall-container">Доступ ограничен</div></body></html>
"""

_MINIMAL_STATE = {
    "data": {
        "data": {
            "page": {
                "placeholders": [
                    {
                        "type": "housePage",
                        "props": {
                            "developmentData": {
                                "avitoId": 12345,
                                "id": "abc123",
                                "title": "ЖК Тест",
                                "address": "ул. Тестовая, 1",
                                "fullAddress": "Екатеринбург, ул. Тестовая, 1",
                                "coords": {"lat": 56.8, "lng": 60.6},
                                "aboutDevelopment": {"expandParams": {"items": []}},
                                "developer": {},
                                "mapPreview": {},
                            }
                        },
                    }
                ]
            }
        }
    }
}

_encoded_state = urllib.parse.quote(json.dumps(_MINIMAL_STATE, ensure_ascii=False))
HOUSES_HTML = f"""
<html><body>
<script>window.__preloadedState__ = "{_encoded_state}";</script>
</body></html>
"""


# ── Helpers ──────────────────────────────────────────────────────────────────


def make_mock_fetcher(html: str) -> MagicMock:
    """Создаёт mock BrowserFetcher с async fetch(), возвращающим заданный HTML."""
    mock = MagicMock()
    mock.fetch = AsyncMock(return_value=html)
    return mock


# ── 1. fetch_detail browser branch → DetailEnrichment ────────────────────────


@pytest.mark.asyncio
async def test_fetch_detail_browser_returns_enrichment() -> None:
    """browser_fetcher.fetch() вызывается, curl AsyncSession не трогается."""
    mock_fetcher = make_mock_fetcher(DETAIL_HTML)
    item_url = "/ekaterinburg/kvartiry/test_99887766-1234"

    result = await fetch_detail(item_url, browser_fetcher=mock_fetcher)

    # browser path взят
    mock_fetcher.fetch.assert_awaited_once()
    called_url = mock_fetcher.fetch.call_args[0][0]
    assert called_url.startswith("https://www.avito.ru"), f"unexpected url: {called_url}"
    assert called_url.endswith(item_url)

    assert result.item_id == "99887766"
    assert result.price_rub == 5_000_000
    assert result.rooms == 2


@pytest.mark.asyncio
async def test_fetch_detail_browser_full_url_passthrough() -> None:
    """Если item_url уже абсолютный — передаётся без urljoin."""
    mock_fetcher = make_mock_fetcher(DETAIL_HTML)
    full_url = "https://www.avito.ru/ekaterinburg/kvartiry/test_99887766-1234"

    result = await fetch_detail(full_url, browser_fetcher=mock_fetcher)

    called_url = mock_fetcher.fetch.call_args[0][0]
    assert called_url == full_url
    assert result.item_id == "99887766"


@pytest.mark.asyncio
async def test_fetch_detail_browser_no_curl_session_used() -> None:
    """cffi_session не используется когда browser_fetcher передан."""
    mock_fetcher = make_mock_fetcher(DETAIL_HTML)
    mock_session = MagicMock()
    mock_session.get = AsyncMock()  # НЕ должен быть вызван

    result = await fetch_detail(
        "/ekaterinburg/kvartiry/test_99887766-1234",
        cffi_session=mock_session,
        browser_fetcher=mock_fetcher,
    )

    mock_session.get.assert_not_awaited()
    assert result.item_id == "99887766"


# ── 2. fetch_detail browser firewall → AvitoBlockedError ─────────────────────


@pytest.mark.asyncio
async def test_fetch_detail_browser_firewall_raises() -> None:
    """Firewall HTML через browser path → AvitoBlockedError."""
    mock_fetcher = make_mock_fetcher(FIREWALL_HTML)

    with pytest.raises(AvitoBlockedError, match="browser-mode"):
        await fetch_detail(
            "/ekaterinburg/kvartiry/blocked_item-9999",
            browser_fetcher=mock_fetcher,
        )


# ── 3. fetch_house_catalog browser branch ────────────────────────────────────


@pytest.mark.asyncio
async def test_fetch_house_catalog_browser_branch_taken() -> None:
    """browser_fetcher.fetch() вызывается, curl path не трогается."""
    mock_fetcher = make_mock_fetcher(HOUSES_HTML)
    house_path = "/catalog/houses/ekaterinburg/ul_test/3171365"

    result = await fetch_house_catalog(house_path, browser_fetcher=mock_fetcher)

    mock_fetcher.fetch.assert_awaited_once()
    called_url = mock_fetcher.fetch.call_args[0][0]
    assert "avito.ru" in called_url
    assert house_path in called_url

    assert result.house.ext_id == 12345
    assert result.house.title == "ЖК Тест"


@pytest.mark.asyncio
async def test_fetch_house_catalog_browser_no_curl_used() -> None:
    """cffi_session.get не вызывается в browser mode."""
    mock_fetcher = make_mock_fetcher(HOUSES_HTML)
    mock_session = MagicMock()
    mock_session.get = AsyncMock()

    await fetch_house_catalog(
        "/catalog/houses/ekaterinburg/ul_test/3171365",
        cffi_session=mock_session,
        browser_fetcher=mock_fetcher,
    )

    mock_session.get.assert_not_awaited()


@pytest.mark.asyncio
async def test_fetch_house_catalog_browser_firewall_raises() -> None:
    """Firewall HTML через browser path → AvitoBlockedError (kit)."""
    mock_fetcher = make_mock_fetcher(FIREWALL_HTML)

    with pytest.raises(AvitoBlockedError, match="browser-mode"):
        await fetch_house_catalog(
            "/catalog/houses/ekaterinburg/ul_test/9999",
            browser_fetcher=mock_fetcher,
        )


# ── 4. Curl mode (default) — browser_fetcher stays None ──────────────────────


@pytest.mark.asyncio
async def test_fetch_detail_curl_mode_no_browser_fetcher() -> None:
    """Если browser_fetcher=None, curl path используется (собственная сессия)."""
    from unittest.mock import patch as _patch

    # Мокаем создание собственной сессии чтобы не нужен реальный curl_cffi
    mock_resp = MagicMock()
    mock_resp.status_code = 200
    mock_resp.text = DETAIL_HTML

    mock_session = AsyncMock()
    mock_session.get = AsyncMock(return_value=mock_resp)
    mock_session.close = AsyncMock()

    # Kit _build_detail_session delegates to scraper_kit.providers._base.build_document_session
    # (shared helper), which constructs AsyncSession there — not in the detail module itself.
    with _patch(
        "scraper_kit.providers._base.AsyncSession",
        return_value=mock_session,
    ):
        result = await fetch_detail("/ekaterinburg/kvartiry/test_99887766-1234")

    # curl path взят — session.get был вызван
    mock_session.get.assert_awaited_once()
    assert result.item_id == "99887766"


# ── 5. SERP routing: real _fetch_serp_html routes through browser ────────────


@pytest.mark.asyncio
async def test_fetch_serp_html_routes_through_browser() -> None:
    """With _browser set and _cffi=None, the REAL _fetch_serp_html returns the
    browser HTML and never dereferences _cffi (no AssertionError). Proves #915
    SERP routing on top of #901's browser branch."""
    from unittest.mock import AsyncMock

    from scraper_kit.providers.avito.serp import AvitoScraper

    from app.services.scraper_adapters import RealScraperConfig

    scraper = AvitoScraper(RealScraperConfig())
    assert scraper._cffi is None
    scraper._browser = AsyncMock()
    scraper._browser.fetch = AsyncMock(return_value="<html><body>real serp listing</body></html>")

    html = await scraper._fetch_serp_html(
        "https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
    )

    assert html == "<html><body>real serp listing</body></html>"
    scraper._browser.fetch.assert_awaited_once()


# ── 6. SERP browser sidecar timeout → transient retry, not bare exception ────
# #2472: BrowserFetcher.fetch (browser_fetcher.py) retries once internally on
# httpx.HTTPError/TransportError, then re-raises. Before this fix, non-
# HTTPStatusError exceptions (httpx.TimeoutException/TransportError — e.g.
# ReadTimeout/ConnectError/RemoteProtocolError) propagated unhandled through
# _fetch_serp_html_browser straight into pipeline.run_avito_full_load's generic
# `except Exception` → mark_failed(str(exc)) (str(ReadTimeout())=="" → empty
# error, and mark_failed does NOT persist done_buckets, unlike mark_banned).


@pytest.mark.asyncio
async def test_fetch_serp_html_browser_timeout_routes_transient_not_bare_exception() -> None:
    """httpx.ReadTimeout from browser.fetch() must NOT propagate as an unhandled
    exception. It's classified transient (status=0, no soft-ban keyword match)
    and, once the bounded retry budget is exhausted, surfaces as
    AvitoRateLimitedError — the same graceful banned-path a sidecar
    HTTPStatusError takes — never as a bare/generic exception."""
    from unittest.mock import patch

    import httpx
    from scraper_kit.avito_exceptions import AvitoRateLimitedError
    from scraper_kit.providers.avito import serp as serp_module
    from scraper_kit.providers.avito.serp import AvitoScraper

    from app.services.scraper_adapters import RealScraperConfig

    scraper = AvitoScraper(RealScraperConfig())
    assert scraper._cffi is None
    scraper._browser = AsyncMock()
    scraper._browser.fetch = AsyncMock(side_effect=httpx.ReadTimeout(""))

    with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
        with pytest.raises(AvitoRateLimitedError):
            await scraper._fetch_serp_html(
                "https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
            )

    # 1 initial attempt + 2 transient retries (_AVITO_SIDECAR_TRANSIENT_RETRIES) = 3,
    # no IP rotation involved (RealScraperConfig has no rotate_url configured in tests).
    assert scraper._browser.fetch.await_count == 3
