"""Offline unit tests for run_yandex_city_sweep (#561, shipped DORMANT).

Pure & fast: NO live network, NO DB. We monkeypatch:
  - YandexRealtyScraper.__init__ / __aenter__ / __aexit__ — avoid get_scraper_delay
    DB read + real httpx.AsyncClient creation.
  - YandexRealtyScraper.fetch_around — return a fixed list of fake ScrapedLot.
  - scrape_pipeline.save_listings — counter/no-op (no real listings table).
  - scrape_runs.* (is_cancelled / update_heartbeat / mark_*) — in-memory fakes.

These tests assert orchestration semantics only; live Yandex behavior is intentionally
unverified (sweep is dormant until egress-IP rotation #623).
"""
import os

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

from typing import Any

import pytest

from app.services import scrape_pipeline
from app.services.scrapers.base import ScrapedLot
from app.services.scrapers.yandex_realty import YandexRealtyScraper

# 3 anchors keep tests fast and let us probe "anchor k cancels/fails, others proceed".
TEST_ANCHORS: list[tuple[float, float, str]] = [
    (56.8400, 60.6050, "A1"),
    (56.7950, 60.5300, "A2"),
    (56.8970, 60.6100, "A3"),
]


def _fake_lot(offer_id: str) -> ScrapedLot:
    """Minimal valid ScrapedLot (source/source_url required, price_rub > 0)."""
    return ScrapedLot(
        source="yandex",
        source_url=f"https://realty.yandex.ru/offer/{offer_id}/",
        source_id=offer_id,
        address="Екатеринбург, улица Тестовая, 1",
        price_rub=5_000_000,
        area_m2=50.0,
        rooms=2,
    )


class _FakeRuns:
    """In-memory stand-in for app.services.scrape_runs (no DB)."""

    def __init__(self, *, cancel_at_anchor: int | None = None) -> None:
        # cancel_at_anchor: 1-based anchor index at which is_cancelled flips True.
        self.cancel_at_anchor = cancel_at_anchor
        self.is_cancelled_calls = 0
        self.heartbeats: list[dict[str, int]] = []
        self.done: dict[str, int] | None = None
        self.failed: tuple[str, dict[str, int]] | None = None
        self.banned: tuple[str, dict[str, int]] | None = None

    def is_cancelled(self, _db: Any, _run_id: int) -> bool:
        self.is_cancelled_calls += 1
        if self.cancel_at_anchor is None:
            return False
        return self.is_cancelled_calls >= self.cancel_at_anchor

    def update_heartbeat(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None:
        self.heartbeats.append(dict(counters))

    def mark_done(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None:
        self.done = dict(counters)

    def mark_failed(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None:
        self.failed = (error, dict(counters))

    def mark_banned(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None:
        self.banned = (error, dict(counters))


@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
    """Kill inter-anchor asyncio.sleep so tests are instant."""
    async def _instant(_secs: float) -> None:
        return None

    monkeypatch.setattr(scrape_pipeline.asyncio, "sleep", _instant)


@pytest.fixture(autouse=True)
def _stub_scraper_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None:
    """Neutralize __init__/__aenter__/__aexit__ — no get_scraper_delay DB, no httpx."""

    def _init(self: YandexRealtyScraper, city: str = "ekaterinburg") -> None:
        self.city = city
        self.request_delay_sec = 0.0
        self._client = None

    async def _aenter(self: YandexRealtyScraper) -> YandexRealtyScraper:
        return self

    async def _aexit(self: YandexRealtyScraper, *_args: Any) -> None:
        return None

    monkeypatch.setattr(YandexRealtyScraper, "__init__", _init)
    monkeypatch.setattr(YandexRealtyScraper, "__aenter__", _aenter)
    monkeypatch.setattr(YandexRealtyScraper, "__aexit__", _aexit)


def _install_runs(monkeypatch: pytest.MonkeyPatch, fake: _FakeRuns) -> None:
    monkeypatch.setattr(scrape_pipeline.scrape_runs, "is_cancelled", fake.is_cancelled)
    monkeypatch.setattr(scrape_pipeline.scrape_runs, "update_heartbeat", fake.update_heartbeat)
    monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_done", fake.mark_done)
    monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_failed", fake.mark_failed)
    monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_banned", fake.mark_banned)


# ── Happy path: all anchors iterated, counters aggregated ───────────────────


async def test_sweep_iterates_all_anchors_and_aggregates(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    fetch_calls: list[tuple[float, float, int]] = []
    save_calls: list[int] = []

    async def fake_fetch(
        self: YandexRealtyScraper,
        lat: float,
        lon: float,
        radius_m: int = 1000,
        page: int = 0,
    ) -> list[ScrapedLot]:
        fetch_calls.append((lat, lon, page))
        # 2 lots per page; let pagination run the full pages_per_anchor.
        return [_fake_lot(f"{lat}-{page}-{i}") for i in range(2)]

    def fake_save(
        _db: Any, lots: list[ScrapedLot], *, run_id: int | None = None
    ) -> tuple[int, int]:
        save_calls.append(len(lots))
        # Pretend everything is a fresh insert.
        return len(lots), 0

    monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
    monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save)
    fake = _FakeRuns()
    _install_runs(monkeypatch, fake)

    counters = await scrape_pipeline.run_yandex_city_sweep(
        db=object(),
        run_id=1,
        anchors=TEST_ANCHORS,
        pages_per_anchor=2,
        request_delay_sec=0.0,
    )

    # fetch_around called pages_per_anchor times per anchor (no empty-page short-circuit).
    assert len(fetch_calls) == len(TEST_ANCHORS) * 2
    # save_listings called once per anchor (anchor-level batch).
    assert len(save_calls) == len(TEST_ANCHORS)
    # Counters: 3 anchors × 2 pages × 2 lots = 12.
    assert counters.anchors_total == len(TEST_ANCHORS)
    assert counters.anchors_done == len(TEST_ANCHORS)
    assert counters.pages_fetched == len(TEST_ANCHORS) * 2
    assert counters.lots_fetched == 12
    assert counters.lots_inserted == 12
    assert counters.lots_updated == 0
    assert counters.errors_count == 0
    # mark_done called with final counters; no failure/ban.
    assert fake.done is not None
    assert fake.done["lots_fetched"] == 12
    assert fake.failed is None
    assert fake.banned is None


async def test_save_listings_receives_run_id(monkeypatch: pytest.MonkeyPatch) -> None:
    """run_id must be threaded into save_listings for snapshot provenance."""
    seen_run_ids: list[int | None] = []

    async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
        return [_fake_lot(f"{lat}-{page}")]

    def fake_save(_db, lots, *, run_id=None):
        seen_run_ids.append(run_id)
        return len(lots), 0

    monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
    monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save)
    _install_runs(monkeypatch, _FakeRuns())

    await scrape_pipeline.run_yandex_city_sweep(
        db=object(), run_id=777, anchors=TEST_ANCHORS, pages_per_anchor=1,
    )
    assert seen_run_ids == [777, 777, 777]


# ── Empty page short-circuits pagination ────────────────────────────────────


async def test_empty_page_stops_pagination(monkeypatch: pytest.MonkeyPatch) -> None:
    """An empty SERP page ends pagination for that anchor (no further pages)."""
    pages_seen: list[int] = []

    async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
        pages_seen.append(page)
        if page == 0:
            return [_fake_lot(f"{lat}-0")]
        return []  # page 1 empty → stop

    monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
    monkeypatch.setattr(
        scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
    )
    fake = _FakeRuns()
    _install_runs(monkeypatch, fake)

    counters = await scrape_pipeline.run_yandex_city_sweep(
        db=object(), run_id=2, anchors=TEST_ANCHORS, pages_per_anchor=5,
    )
    # Per anchor: page 0 (1 lot) then page 1 (empty, break) → 2 pages each.
    assert pages_seen == [0, 1, 0, 1, 0, 1]
    assert counters.pages_fetched == 6  # 2 per anchor × 3
    assert counters.lots_fetched == 3  # 1 per anchor
    assert counters.anchors_done == 3


# ── Mid-sweep cancel stops further anchors ──────────────────────────────────


async def test_cancel_midway_stops_remaining_anchors(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    fetch_calls: list[str] = []

    async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
        fetch_calls.append(f"{lat}")
        return [_fake_lot(f"{lat}-{page}")]

    monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
    monkeypatch.setattr(
        scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
    )
    # is_cancelled flips True on the 2nd check (before anchor #2) → only anchor #1 runs.
    fake = _FakeRuns(cancel_at_anchor=2)
    _install_runs(monkeypatch, fake)

    counters = await scrape_pipeline.run_yandex_city_sweep(
        db=object(), run_id=3, anchors=TEST_ANCHORS, pages_per_anchor=1,
    )
    # Only the first anchor fetched before cancel.
    assert len(fetch_calls) == 1
    assert counters.anchors_done == 1
    # Cancel path returns early: no mark_done, no ban/fail.
    assert fake.done is None
    assert fake.failed is None
    assert fake.banned is None


# ── Per-anchor exception is isolated (single failure → continue) ────────────


async def test_single_anchor_failure_is_isolated(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Anchor #2 raises → anchors #1 and #3 still processed; sweep completes."""
    processed: list[str] = []

    async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
        if name_of(lat) == "A2":
            raise RuntimeError("boom on anchor 2")
        processed.append(name_of(lat))
        return [_fake_lot(f"{lat}-{page}")]

    def name_of(lat: float) -> str:
        for a_lat, _lon, nm in TEST_ANCHORS:
            if a_lat == lat:
                return nm
        return "?"

    monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
    monkeypatch.setattr(
        scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
    )
    fake = _FakeRuns()
    _install_runs(monkeypatch, fake)

    counters = await scrape_pipeline.run_yandex_city_sweep(
        db=object(), run_id=4, anchors=TEST_ANCHORS, pages_per_anchor=1,
    )
    # A1 and A3 processed; A2 raised and was skipped.
    assert processed == ["A1", "A3"]
    assert counters.errors_count == 1
    assert counters.anchors_done == 3  # loop advanced past all 3 anchors
    assert counters.lots_fetched == 2  # only A1 + A3 contributed lots
    # Not an abort — sweep marked done despite the isolated failure.
    assert fake.done is not None
    assert fake.banned is None


# ── Defensive abort: N consecutive failures → mark_banned, stop early ───────


async def test_consecutive_failures_abort_sweep(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """All anchors raise → after MAX_CONSECUTIVE_FAILURES the sweep aborts + bans."""
    attempts: list[str] = []

    async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
        attempts.append(f"{lat}")
        raise RuntimeError("yandex blocked")

    monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
    monkeypatch.setattr(
        scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
    )
    fake = _FakeRuns()
    _install_runs(monkeypatch, fake)

    # 5 anchors so the 3-consecutive-failure threshold trips before the list ends.
    anchors = [
        *TEST_ANCHORS,
        (56.7700, 60.5500, "A4"),
        (56.8650, 60.6200, "A5"),
    ]
    counters = await scrape_pipeline.run_yandex_city_sweep(
        db=object(), run_id=5, anchors=anchors, pages_per_anchor=1,
    )
    # Abort exactly at the threshold (3 consecutive failures), not all 5 anchors.
    assert len(attempts) == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES
    assert counters.errors_count == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES
    assert counters.anchors_done == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES
    assert fake.banned is not None
    assert "consecutive" in fake.banned[0]
    assert fake.done is None  # aborted, never marked done


async def test_non_consecutive_failures_do_not_abort(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A success between two failures resets the consecutive counter → no abort."""
    def name_of(lat: float, anchors: list[tuple[float, float, str]]) -> str:
        for a_lat, _lon, nm in anchors:
            if a_lat == lat:
                return nm
        return "?"

    anchors = [
        *TEST_ANCHORS,
        (56.7700, 60.5500, "A4"),
        (56.8650, 60.6200, "A5"),
    ]
    # Fail A1, succeed A2, fail A3, succeed A4, fail A5 → never 3-in-a-row.
    failing = {"A1", "A3", "A5"}

    async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
        if name_of(lat, anchors) in failing:
            raise RuntimeError("transient")
        return [_fake_lot(f"{lat}-{page}")]

    monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
    monkeypatch.setattr(
        scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
    )
    fake = _FakeRuns()
    _install_runs(monkeypatch, fake)

    counters = await scrape_pipeline.run_yandex_city_sweep(
        db=object(), run_id=6, anchors=anchors, pages_per_anchor=1,
    )
    assert counters.errors_count == 3  # A1, A3, A5
    assert counters.anchors_done == 5  # walked the whole list, no early abort
    assert counters.lots_fetched == 2  # A2 + A4 contributed
    assert fake.banned is None
    assert fake.done is not None
