"""Тесты backfill_ekb_zone_regulations (эпик #1881) — прогрев кэша регламента ВСЕХ терзон ЕКБ.

Сеть/БД не дёргаются: фейковый EKBGeoportalClient + фейковая Session + monkeypatch
``upsert_zone_regulation``/``get_cached_zone_regulation`` (логику апсёрта/чтения не тестируем тут —
она покрыта test_zone_regulation_extract). Проверяем:
  - дедуп по urban_index (fetch только уникальных индексов),
  - идемпотентность (cached skip → не фетчим),
  - per-zone error isolation (одна зона падает → failed++, batch продолжается),
  - limit,
  - представительную точку (внутри полигона).
"""

from __future__ import annotations

from typing import Any

import pytest

from app.services.scrapers.ekb_geoportal_client import EKBFeature, ZoneRegulation
from app.services.site_finder import zone_regulation as zr


def _poly() -> dict[str, Any]:
    return {"type": "Polygon", "coordinates": [[[60, 56], [60.1, 56], [60.1, 56.1], [60, 56]]]}


def _feat(idx: str, geometry: dict[str, Any] | None = None) -> EKBFeature:
    return EKBFeature(
        feature_id=idx,
        geometry=geometry if geometry is not None else _poly(),
        properties={"urban_index": idx},
    )


def _reg(idx: str) -> ZoneRegulation:
    return ZoneRegulation(
        zone_index=idx,
        zone_full_name=f"{idx} зона",
        main_vri=[],
        conditional_vri=[],
        auxiliary_vri=[],
        limit_params=[],
    )


class _FakeClient:
    """Фейковый WFS-клиент: отдаёт заданные фичи, фиксирует резолвы.

    Все тестовые полигоны идентичны → ``zone_regulation_at`` не различает зоны по координатам;
    нам это и не нужно — проверяем счётчики/дедуп/число резолвов, а не маппинг точка→зона.
    """

    def __init__(self, feats: list[EKBFeature]) -> None:
        self._feats = feats
        self.resolved: list[tuple[float, float]] = []

    def features_in_bbox(self, layer: str, bbox: Any) -> list[EKBFeature]:
        return self._feats

    def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation:
        self.resolved.append((lon, lat))
        return _reg(f"Z-{len(self.resolved)}")


class _DB:
    """Фейковая Session: считает commit'ы/rollback'и, ничего не пишет.

    rollback() — owned-session (Celery-таск backfill_zone_regulations.py владеет
    SessionLocal() целиком) fallback на per-zone сбое (#2464 cluster A finding 6).
    """

    def __init__(self) -> None:
        self.commits = 0
        self.rollbacks = 0

    def commit(self) -> None:
        self.commits += 1

    def rollback(self) -> None:
        self.rollbacks += 1


@pytest.fixture
def _no_sleep(monkeypatch: Any) -> None:
    """Не спим в тестах (rate_delay)."""
    monkeypatch.setattr(zr.time, "sleep", lambda *_a, **_kw: None)


def test_dedup_and_counts(monkeypatch: Any, _no_sleep: None) -> None:
    """3 фичи (2 уникальных индекса + 1 дубль) → fetch/upsert только 2 уникальных."""
    feats = [_feat("Ц-1"), _feat("Ц-1"), _feat("Ж-2")]
    client = _FakeClient(feats)
    upserts: list[ZoneRegulation] = []
    monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None)
    monkeypatch.setattr(
        zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {}
    )

    db = _DB()
    result = zr.backfill_ekb_zone_regulations(db, client=client, rate_delay_s=0.0)  # type: ignore[arg-type]

    assert result["enumerated_features"] == 3
    assert result["unique_zones"] == 2  # дубль Ц-1 схлопнут
    assert result["cached_before"] == 0
    assert result["fetched"] == 2  # только уникальные индексы
    assert result["upserted"] == 2
    assert result["failed"] == 0
    assert len(client.resolved) == 2
    assert db.commits == 2  # commit per-upsert


def test_idempotent_cached_skip(monkeypatch: Any, _no_sleep: None) -> None:
    """Если зона уже в кэше — НЕ фетчим (идемпотентность)."""
    feats = [_feat("Ц-1"), _feat("Ж-2")]
    client = _FakeClient(feats)
    # Ц-1 закэширована, Ж-2 нет.
    monkeypatch.setattr(
        zr,
        "get_cached_zone_regulation",
        lambda db, zone_index, **kw: {"zone_index": zone_index} if zone_index == "Ц-1" else None,
    )
    upserts: list[ZoneRegulation] = []
    monkeypatch.setattr(
        zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {}
    )

    result = zr.backfill_ekb_zone_regulations(_DB(), client=client, rate_delay_s=0.0)  # type: ignore[arg-type]

    assert result["cached_before"] == 1  # Ц-1 пропущена
    assert result["fetched"] == 1  # только Ж-2
    assert result["upserted"] == 1
    assert len(client.resolved) == 1  # Ц-1 не дёргала геопортал


def test_per_zone_error_isolation(monkeypatch: Any, _no_sleep: None) -> None:
    """Сбой fetch одной зоны → failed++, batch продолжается для остальных."""
    # Клиент кидает на ПЕРВОЙ резолвленной зоне (по порядку), вторую отдаёт нормально.
    feats = [_feat("Ц-1"), _feat("Ж-2")]

    calls = {"n": 0}

    class _RaisingClient(_FakeClient):
        def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation:
            self.resolved.append((lon, lat))
            calls["n"] += 1
            if calls["n"] == 1:
                raise RuntimeError("geoportal 500 on first zone")
            return _reg("Ж-2")

    client = _RaisingClient(feats)
    monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None)
    upserts: list[ZoneRegulation] = []
    monkeypatch.setattr(
        zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {}
    )

    db = _DB()
    result = zr.backfill_ekb_zone_regulations(db, client=client, rate_delay_s=0.0)  # type: ignore[arg-type]

    assert result["unique_zones"] == 2
    assert result["failed"] == 1  # первая зона упала
    assert result["upserted"] == 1  # вторая прошла → batch не остановился
    assert len(client.resolved) == 2  # дошли до обеих зон
    # #2464 cluster A finding 6: сбойная зона откатывает СВОЮ (owned) сессию —
    # иначе следующая итерация цикла поймала бы "current transaction is aborted".
    assert db.rollbacks == 1
    assert db.commits == 1  # только вторая (успешная) зона закоммитилась


def test_limit_caps_fetches(monkeypatch: Any, _no_sleep: None) -> None:
    """limit ограничивает число live-fetch'ей незакэшированных зон."""
    feats = [_feat("Ц-1"), _feat("Ж-2"), _feat("Ж-3")]
    client = _FakeClient(feats)
    monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None)
    upserts: list[ZoneRegulation] = []
    monkeypatch.setattr(
        zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {}
    )

    result = zr.backfill_ekb_zone_regulations(_DB(), client=client, limit=2, rate_delay_s=0.0)  # type: ignore[arg-type]

    assert result["unique_zones"] == 3
    assert result["fetched"] == 2  # обрезано лимитом
    assert len(client.resolved) == 2


def test_representative_point_inside_polygon() -> None:
    pt = zr._representative_point(_poly())
    assert pt is not None
    lon, lat = pt
    assert 60.0 <= lon <= 60.1
    assert 56.0 <= lat <= 56.1


def test_representative_point_none_on_bad_geom() -> None:
    assert zr._representative_point(None) is None
    assert zr._representative_point({"type": "Polygon", "coordinates": "garbage"}) is None


def test_dedup_prefers_feature_with_geometry() -> None:
    """Из двух фич одного индекса выбираем ту, у которой есть геометрия."""
    feats = [_feat("Ц-1", geometry=None), _feat("Ц-1", geometry=_poly())]
    by_index = zr._dedup_zones_by_index(feats)
    assert set(by_index) == {"Ц-1"}
    assert by_index["Ц-1"].geometry is not None


def test_blank_index_dropped() -> None:
    """Фичи с пустым/отсутствующим urban_index не попадают в дедуп."""
    feats = [_feat("", geometry=_poly()), _feat("Ц-1")]
    by_index = zr._dedup_zones_by_index(feats)
    assert set(by_index) == {"Ц-1"}
