"""Parity tests for issue #2310 (Group C of scraper_kit migration epic #2277).

Migrates legacy scraper imports of these files to their `scraper_kit` equivalents
(behavior of the enclosing task/service functions is unchanged — see vault audit
`Scraper_Kit_Legacy_Dependency_Audit_0703`):

  - app/services/house_imv_backfill.py    → BrowserFetcher now from
    `scraper_kit.browser_fetcher` (was `app.services.scrapers.browser_fetcher`).
    avito_imv itself was still legacy at the time (valuation dependency, issue
    #2308 — separate); migrated to `scraper_kit.providers.avito.imv` in #2334,
    then the legacy module was deleted entirely in #2277's final step.
  - app/tasks/avito_detail_backfill.py    → AvitoScraper/BrowserFetcher/avito
    exceptions/fetch_detail/research_in_session/save_detail_enrichment now from
    `scraper_kit.*`. `build_warmed_session`/`_AVITO_WARM_SEARCH_URL` stay legacy —
    at the time this file was written, kit's `build_warmed_session()` built its
    curl_cffi session via `_build_detail_session()` with NO config parameter at all
    (unlike `fetch_detail`, which does), so there was no caller-side way to inject
    `settings.scraper_proxy_url`. Fixed in issue #2330 (Group F2) — kit's
    `build_warmed_session()` now accepts `config`, but THIS caller still imports it
    from legacy, so migrating this specific call site remains a separate follow-up
    before the sticky MGTS-proxy egress on the warm-batch path
    (avito_detail_backfill_use_curl=True, the prod default) can move to kit.
  - app/tasks/cian_history_backfill.py    → fetch_detail/save_detail_enrichment
    (listings block), fetch_newbuilding/save_newbuilding_enrichment (houses block),
    and estimate_via_cian_valuation (valuations block) all now from `scraper_kit.*`.
    Both the houses and valuations call sites pass `config=RealScraperConfig()`
    (mandatory kwarg on the kit functions) — see
    `test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoint` and
    `test_cian_history_backfill_valuations_estimate_gets_config` below (#2397 Part D2;
    houses block was previously blocked on issue #2322, now fixed).
  - app/tasks/ekb_geoportal_ingest.py     → EkbGeoportalClient/TopoBuilding now
    from `scraper_kit.providers.ekb_geoportal.client` (already proven byte-
    identical by tests/scrapers/test_ekb_geoportal_client_kit_parity.py, #2307).

This file proves the DELTA introduced by the #2310 migration specifically —
general avito/cian-provider parsing golden-parity is already covered by
`tests/test_scraper_kit_avito_golden_parity.py` and (while it existed)
`tests/test_scraper_kit_cian_golden_parity.py`. Uses `tests/support/parity.assert_parity`
per the epic's parity-gate convention (issue #2304).

`test_cian_fetch_detail_via_browser_fetcher_parity` (legacy `app.services.scrapers.
cian_detail.fetch_detail` vs kit, browser_fetcher call shape) removed (#2397 Part E2)
— `app.services.scrapers.cian_detail` deleted, 0 runtime importers (cian_history_backfill.py
already calls the kit `fetch_detail` exclusively, proven by
`test_cian_history_backfill_browser_fetcher_uses_settings_endpoint` below).

`test_avito_fetch_detail_via_browser_fetcher_parity` (legacy `app.services.scrapers.
avito_detail.fetch_detail` vs kit, browser_fetcher call shape) removed (#2397 финальный
шаг E) — `app.services.scrapers.avito_detail` deleted, 0 runtime importers. Kit-only
avito_detail_backfill.py coverage of the browser_fetcher branch remains via
`test_pipeline_browser_routing.py::test_fetch_detail_browser_returns_enrichment`.
"""

from __future__ import annotations

import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

# app.core.config.settings requires DATABASE_URL at import time.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")

from scraper_kit.avito_exceptions import AvitoBlockedError as KitAvitoBlockedError
from scraper_kit.providers.avito.detail import fetch_detail as kit_avito_fetch_detail
from scraper_kit.providers.avito.serp import AvitoScraper as KitAvitoScraper

from app.core.config import settings
from app.services.scraper_adapters import RealScraperConfig

# ── avito fetch_detail: backconnect config-gate (avito_detail_backfill.py delta) ──


def _mock_session_403_then_ok(html: str) -> MagicMock:
    """curl_cffi-like session: first .get() -> 403, second -> 200 with html."""
    resp_403 = MagicMock(status_code=403, text="")
    resp_ok = MagicMock(status_code=200, text=html)
    session = MagicMock()
    session.get = AsyncMock(side_effect=[resp_403, resp_ok])
    session.close = AsyncMock()
    return session


def _detail_html() -> str:
    return (
        '<html><body><div data-marker="item-view/item-id">'
        "№ 1 · 1 мая в 10:00 · 1 просмотр</div>"
        '<span itemprop="price" content="1000000">1 000 000 ₽</span>'
        "</body></html>"
    )


async def test_avito_fetch_detail_without_config_raises_on_403_no_reconnect() -> None:
    """kit fetch_detail with config=None never retries on 403 — proves the config-gate
    is real: avito_detail_backfill's own-session call site MUST pass config= or it loses
    the backconnect-reconnect-on-block behavior that legacy always had via settings.*."""
    session = _mock_session_403_then_ok(_detail_html())
    with patch("scraper_kit.providers.avito.detail.asyncio.sleep", new_callable=AsyncMock):
        try:
            await kit_avito_fetch_detail(
                "https://www.avito.ru/x", cffi_session=session, reconnect_on_block=True
            )
        except KitAvitoBlockedError:
            pass
        else:
            raise AssertionError("expected AvitoBlockedError when config= is omitted")
    # Only ONE .get() happened — no reconnect retry attempted (backconnect gate closed).
    assert session.get.call_count == 1


async def test_avito_fetch_detail_with_config_reconnects_on_403_like_legacy() -> None:
    """With config=RealScraperConfig() (our actual avito_detail_backfill call site) and
    a configured proxy, a 403 triggers a fresh-session reconnect retry — matching legacy,
    which always read settings.scraper_proxy_url directly (no injection needed)."""
    session = _mock_session_403_then_ok(_detail_html())
    retry_session = MagicMock()
    retry_session.get = AsyncMock(return_value=MagicMock(status_code=200, text=_detail_html()))
    retry_session.close = AsyncMock()

    with (
        patch.object(settings, "scraper_proxy_url_env", "http://test-proxy.local:8080"),
        patch("scraper_kit.providers.avito.detail.asyncio.sleep", new_callable=AsyncMock),
        patch(
            "scraper_kit.providers.avito.detail._build_detail_session",
            return_value=retry_session,
        ),
    ):
        result = await kit_avito_fetch_detail(
            "https://www.avito.ru/x",
            cffi_session=session,
            reconnect_on_block=True,
            config=RealScraperConfig(),
        )

    assert result is not None
    # First (caller-provided) session saw the 403; retry_session served the 200 that
    # produced the parsed result.
    assert session.get.call_count == 1
    retry_session.get.assert_awaited_once()


# ── AvitoScraper: kit constructor now requires ScraperConfig (positional) ────────


def test_avito_scraper_rotate_ip_reads_injected_config() -> None:
    """kit AvitoScraper(config) is a required positional arg (legacy AvitoScraper()
    took none, reading settings.avito_proxy_rotate_url directly at call time inside
    _rotate_ip). avito_detail_backfill.py now constructs AvitoScraper(RealScraperConfig())
    — this proves the injected config is what _rotate_ip actually reads."""
    scraper = KitAvitoScraper(RealScraperConfig())
    assert scraper._config.avito_proxy_rotate_url == settings.avito_proxy_rotate_url


# ── BrowserFetcher(endpoint=) wiring — house_imv_backfill.py / cian_history_backfill.py ──


async def test_house_imv_backfill_browser_fetcher_uses_settings_endpoint() -> None:
    """house_imv_backfill.py's BrowserFetcher(source="avito") now needs endpoint=
    (kit's mandatory keyword-only param) — proves the call site passes
    settings.browser_http_endpoint, matching legacy's internal settings read."""
    from app.services import house_imv_backfill

    captured: dict[str, Any] = {}

    class _FakeBrowserFetcher:
        def __init__(self, **kwargs: Any) -> None:
            captured.update(kwargs)

        async def __aenter__(self) -> _FakeBrowserFetcher:
            return self

        async def __aexit__(self, *_: object) -> None:
            return None

    with (
        patch.object(house_imv_backfill.settings, "avito_imv_use_browser_fetcher", True),
        patch.object(house_imv_backfill, "BrowserFetcher", _FakeBrowserFetcher),
        patch.object(house_imv_backfill, "_process_one_house", AsyncMock(return_value="ok")),
    ):
        db = MagicMock()
        db.execute.return_value.mappings.return_value.all.return_value = [
            {"id": 1, "address": "ЕКБ, ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}
        ]
        await house_imv_backfill.backfill_house_imv(db, batch_size=1)

    assert captured == {"source": "avito", "endpoint": settings.browser_http_endpoint}


async def test_cian_history_backfill_browser_fetcher_uses_settings_endpoint() -> None:
    """cian_history_backfill.py's BrowserFetcher(source="cian") also needs endpoint=."""
    from app.tasks import cian_history_backfill

    captured: dict[str, Any] = {}

    class _FakeBrowserFetcher:
        def __init__(self, **kwargs: Any) -> None:
            captured.update(kwargs)

        async def __aenter__(self) -> _FakeBrowserFetcher:
            return self

        async def __aexit__(self, *_: object) -> None:
            return None

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

    with patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher):
        await cian_history_backfill.backfill_cian_history(
            db, do_listings=True, do_houses=False, do_valuations=False
        )

    assert captured == {"source": "cian", "endpoint": settings.browser_http_endpoint}


# ── config= threading — houses / valuations blocks (#2397 Part D2) ───────────────


async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoint() -> None:
    """Houses block's fetch_newbuilding() call must pass config=RealScraperConfig() so
    kit's BrowserFetcher gets a real browser_http_endpoint (issue #2322, now fixed) —
    without it fetch_newbuilding() degrades to endpoint=None instead of raising, which
    would silently no-op the nightly houses_price_dynamics backfill in prod."""
    from app.tasks import cian_history_backfill

    captured: dict[str, Any] = {}

    async def _fake_fetch_newbuilding(zhk_url: str, *, config: Any = None) -> None:
        captured["zhk_url"] = zhk_url
        captured["endpoint"] = config.browser_http_endpoint if config is not None else None

    db = MagicMock()
    db.execute.return_value.mappings.return_value.all.return_value = [
        {"id": 1, "cian_zhk_url": "https://zhk-test-ekb-i.cian.ru/"}
    ]

    with (
        patch(
            "scraper_kit.providers.cian.newbuilding.fetch_newbuilding",
            _fake_fetch_newbuilding,
        ),
        patch("asyncio.sleep", new_callable=AsyncMock),
    ):
        await cian_history_backfill.backfill_cian_history(
            db, do_listings=False, do_houses=True, do_valuations=False
        )

    assert captured["zhk_url"] == "https://zhk-test-ekb-i.cian.ru/"
    assert captured["endpoint"] == settings.browser_http_endpoint


async def test_cian_history_backfill_valuations_estimate_gets_config() -> None:
    """Valuations block's estimate_via_cian_valuation() call must pass a non-None
    config=RealScraperConfig() — kit's function requires config as a mandatory
    keyword-only arg (load_session(db, config=config) and the price sanity gate both
    read it); omitting it is a TypeError at call time, not a silent degrade."""
    from app.tasks import cian_history_backfill

    captured: dict[str, Any] = {}

    async def _fake_estimate(db_arg: Any, *, config: Any = None, **kwargs: Any) -> None:
        captured["config"] = config
        captured["kwargs"] = kwargs

    db = MagicMock()
    db.execute.return_value.mappings.return_value.all.return_value = [
        {
            "id": 1,
            "address": "ЕКБ, ул. Тестовая, 1",
            "area_m2": 45.0,
            "rooms": 2,
            "floor": 3,
            "total_floors": 9,
        }
    ]

    with (
        patch.object(cian_history_backfill, "estimate_via_cian_valuation", _fake_estimate),
        patch("asyncio.sleep", new_callable=AsyncMock),
    ):
        await cian_history_backfill.backfill_cian_history(
            db, do_listings=False, do_houses=False, do_valuations=True
        )

    assert captured["config"] is not None
    assert isinstance(captured["config"], RealScraperConfig)


# ── ekb_geoportal_ingest.py: task module resolves the kit client import ──────────


def test_ekb_geoportal_ingest_imports_kit_client() -> None:
    """app/tasks/ekb_geoportal_ingest.py now imports EkbGeoportalClient/TopoBuilding
    from scraper_kit.providers.ekb_geoportal.client (issue #2310) — proves the task
    module resolves to the SAME classes as the kit package (not a stale/duplicate
    import), and that construction still works with no arguments."""
    from scraper_kit.providers.ekb_geoportal.client import (
        EkbGeoportalClient as KitClient,
    )

    from app.tasks.ekb_geoportal_ingest import EkbGeoportalClient as TaskClient

    assert TaskClient is KitClient
    assert isinstance(TaskClient(), KitClient)
