"""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. run_avito_pipeline в browser-mode → scraper._browser установлен,
     fetch_detail/fetch_house_catalog вызваны с browser_fetcher
  5. Curl mode (default) — browser_fetcher=None, curl path используется
"""

from __future__ import annotations

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

import pytest

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

from app.services.scrapers.avito_detail import fetch_detail
from app.services.scrapers.avito_exceptions import AvitoBlockedError
from app.services.scrapers.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."""
    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. run_avito_pipeline browser-mode routing ───────────────────────────────


@pytest.mark.asyncio
async def test_run_avito_pipeline_browser_mode_sets_scraper_browser(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """В browser-mode scraper._browser установлен, fetch_detail/fetch_house_catalog
    вызываются с browser_fetcher (не с cffi_session).
    """
    from app.services import scrape_pipeline

    monkeypatch.setattr(scrape_pipeline.settings, "scraper_fetch_mode", "browser", raising=False)

    mock_bf = make_mock_fetcher(DETAIL_HTML)
    captured_scraper: list = []

    # Mock AvitoScraper.fetch_around → возвращает пустой список (нет лотов → нет DB)
    async def mock_fetch_around(
        self: object, lat: float, lon: float, radius_m: int, **kwargs: object
    ) -> list:
        captured_scraper.append(self)
        return []

    mock_db = MagicMock()
    mock_db.execute.return_value.mappings.return_value.all.return_value = []

    with (
        patch("app.services.scrape_pipeline.AvitoScraper.fetch_around", mock_fetch_around),
        patch("app.services.scrape_pipeline.save_listings", return_value=(0, 0)),
    ):
        from app.services.scrape_pipeline import run_avito_pipeline

        await run_avito_pipeline(
            mock_db,
            lat=56.84,
            lon=60.60,
            radius_m=1500,
            enrich_houses=False,
            enrich_detail_top_n=0,
            pages=1,
            shared_browser=mock_bf,
        )

    assert len(captured_scraper) == 1
    scraper_instance = captured_scraper[0]
    assert scraper_instance._browser is mock_bf
    # curl session должен быть None — в browser-mode pipeline не создаёт _cffi сессию
    assert scraper_instance._cffi is None


@pytest.mark.asyncio
async def test_run_avito_pipeline_browser_mode_own_browser_created(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Если shared_browser не передан в browser-mode, BrowserFetcher создаётся
    и __aenter__/__aexit__ вызываются."""
    from app.services import scrape_pipeline

    monkeypatch.setattr(scrape_pipeline.settings, "scraper_fetch_mode", "browser", raising=False)

    mock_bf_instance = make_mock_fetcher(DETAIL_HTML)
    mock_bf_instance.__aenter__ = AsyncMock(return_value=mock_bf_instance)
    mock_bf_instance.__aexit__ = AsyncMock(return_value=None)

    mock_db = MagicMock()
    mock_db.execute.return_value.mappings.return_value.all.return_value = []

    async def mock_fetch_around(self: object, *args: object, **kwargs: object) -> list:
        return []

    with (
        patch("app.services.scrape_pipeline.BrowserFetcher", return_value=mock_bf_instance),
        patch("app.services.scrape_pipeline.AvitoScraper.fetch_around", mock_fetch_around),
        patch("app.services.scrape_pipeline.save_listings", return_value=(0, 0)),
    ):
        await scrape_pipeline.run_avito_pipeline(
            mock_db,
            lat=56.84,
            lon=60.60,
            enrich_houses=False,
            enrich_detail_top_n=0,
            pages=1,
        )

    mock_bf_instance.__aenter__.assert_awaited_once()
    mock_bf_instance.__aexit__.assert_awaited_once()


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


@pytest.mark.asyncio
async def test_run_avito_pipeline_curl_mode_no_browser_fetcher(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """В curl-mode (default) browser_fetcher=None, scraper._cffi установлен."""
    from app.services import scrape_pipeline

    monkeypatch.setattr(scrape_pipeline.settings, "scraper_fetch_mode", "curl_cffi", raising=False)

    captured_scraper: list = []

    async def mock_fetch_around(self: object, *args: object, **kwargs: object) -> list:
        captured_scraper.append(self)
        return []

    mock_db = MagicMock()
    mock_db.execute.return_value.mappings.return_value.all.return_value = []

    with (
        patch("app.services.scrape_pipeline.AvitoScraper.fetch_around", mock_fetch_around),
        patch("app.services.scrape_pipeline.save_listings", return_value=(0, 0)),
    ):
        await scrape_pipeline.run_avito_pipeline(
            mock_db,
            lat=56.84,
            lon=60.60,
            enrich_houses=False,
            enrich_detail_top_n=0,
            pages=1,
        )

    assert len(captured_scraper) == 1
    scraper_instance = captured_scraper[0]
    # curl mode: _cffi должен быть установлен
    assert scraper_instance._cffi is not None
    # browser не установлен в curl mode
    assert getattr(scraper_instance, "_browser", None) is 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()

    with _patch(
        "app.services.scrapers.avito_detail.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"


# ── 6. 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 app.services.scrapers.avito import AvitoScraper

    scraper = AvitoScraper()
    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()
