"""Unit-тесты лоадера реестра РНС/РВЭ ГИСОГД-СО (#2367 PR-1).

Pure / mock-based — без реальной сети и БД. Фикстуры — реальные фрагменты ответов
API (list-элемент + карточка), снятые при разведке 2026-07-04. Покрывает:
- _parse_date: ISO-datetime источника → date, пусто/мусор → None.
- parse_cad_nums: split по запятой, strip, валидация формата, отброс мусора, дедуп.
- extract_geojson: строка-GeoJSON, отсутствие (hasGeometry=false), битый JSON, dict.
- _build_record: docNum strip (список с пробелами), fallback полей, RS/RV-код.
- инкрементальность: известный key с тем же date_reg → карточка НЕ тянется (skip).
- _upsert_permit: insert / update / skipped_unchanged (по scalar-заглушке).
- _sync_group: счётчики no_cad/no_geom, Polygon→(SQL ST_Multi) не падает без geom/cad.
- конфликт двух схем по (doc_group, doc_num): поздний date_reg параметризуется в UPSERT.
"""

from __future__ import annotations

from contextlib import contextmanager
from datetime import date
from typing import Any

import httpx

from app.services.scrapers import gisogd66 as g

# ── Реальные фрагменты API (разведка 2026-07-04) ──────────────────────────────

# Элемент списка группы DocRS (/gisogddocgroupdocs/razdel13/DocRS).
_LIST_ITEM = {
    "key": 1000210001947595,
    "gisogdSectionId": "razdel13",
    "name": "Разрешение на строительство № 66-45-08-2026 от 02.06.2026",
    "docNum": "66-45-08-2026",
    "dateDoc": "2026-06-02T00:00:00",
    "numReg": "65740000-13-2026-548",
    "dateReg": "2026-06-19T15:55:11",
}

# Карточка (/gisogddocs/razdel13/{key}).
_CARD = {
    "spatialUnit": "66:45:0200124:343",
    "key": 1000210001947595,
    "name": "Разрешение на строительство № 66-45-08-2026 от 02.06.2026",
    "docNum": "66-45-08-2026",
    "dateDoc": "2026-06-02T00:00:00",
    "numReg": "65740000-13-2026-548",
    "dateReg": "2026-06-19T15:55:11",
    "infosetGeoJson": (
        '{"type":"Polygon","coordinates":[[[61.942031258,56.368423297],'
        "[61.941099943,56.368422956],[61.941111179,56.367992961],"
        "[61.942039129,56.367981137],[61.942031258,56.368423297]]]}"
    ),
    "hasGeometry": True,
    "approvedOrganization": (
        'Орган местного самоуправления "Комитет по архитектуре и '
        'градостроительству Каменск-Уральского городского округа"'
    ),
    "textFiles": [],
}


# ── _parse_date ───────────────────────────────────────────────────────────────


def test_parse_date_iso_datetime() -> None:
    assert g._parse_date("2026-06-19T15:55:11") == date(2026, 6, 19)
    assert g._parse_date("2026-06-02T00:00:00") == date(2026, 6, 2)


def test_parse_date_empty_and_garbage() -> None:
    assert g._parse_date(None) is None
    assert g._parse_date("") is None
    assert g._parse_date("не дата") is None


# ── parse_cad_nums ────────────────────────────────────────────────────────────


def test_parse_cad_nums_single() -> None:
    assert g.parse_cad_nums("66:45:0200124:343") == ["66:45:0200124:343"]


def test_parse_cad_nums_multi_with_whitespace() -> None:
    """Несколько кадастровых номеров через запятую + пробелы вокруг → strip."""
    su = " 66:45:0200124:343 , 66:41:0704002:12 "
    assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]


def test_parse_cad_nums_six_and_seven_digit_block() -> None:
    """Третий блок кадастрового квартала может быть 6 ИЛИ 7 знаков — оба валидны.

    Реальные данные Свердловской обл. — 7 знаков (66:45:0200124:343). Спец. регекс
    \\d{6,7}: 8-значный блок (не встречается) отбраковывается как мусор.
    """
    assert g.parse_cad_nums("66:45:020012:343") == ["66:45:020012:343"]  # 6 знаков
    assert g.parse_cad_nums("66:45:0200124:343") == ["66:45:0200124:343"]  # 7 знаков
    assert g.parse_cad_nums("66:45:02001240:343") == []  # 8 знаков → отброшен


def test_parse_cad_nums_drops_garbage() -> None:
    """Невалидные токены (мусор) отбрасываются, валидные остаются."""
    su = "66:45:0200124:343, мусор, 12345, 66:41:0704002:12"
    assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]


def test_parse_cad_nums_dedup_preserves_order() -> None:
    su = "66:45:0200124:343, 66:45:0200124:343, 66:41:0704002:12"
    assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]


def test_parse_cad_nums_absent() -> None:
    assert g.parse_cad_nums(None) == []
    assert g.parse_cad_nums("") == []
    assert g.parse_cad_nums(", , ") == []


# ── extract_geojson ───────────────────────────────────────────────────────────


def test_extract_geojson_polygon_string() -> None:
    """infosetGeoJson приходит СТРОКОЙ — возвращаем её как есть (валидный JSON)."""
    out = g.extract_geojson(_CARD)
    assert out is not None
    assert out.startswith('{"type":"Polygon"')


def test_extract_geojson_absent() -> None:
    """hasGeometry=false / нет поля → None (geom=NULL)."""
    assert g.extract_geojson({"hasGeometry": False}) is None
    assert g.extract_geojson({"infosetGeoJson": None}) is None
    assert g.extract_geojson({"infosetGeoJson": ""}) is None


def test_extract_geojson_broken_json() -> None:
    """Битая GeoJSON-строка → None (не отправляем мусор в PostGIS)."""
    assert g.extract_geojson({"infosetGeoJson": "{not json"}) is None


def test_extract_geojson_dict_form() -> None:
    """Если источник когда-нибудь отдаст объект (не строку) — сериализуем."""
    obj = {"type": "Polygon", "coordinates": [[[60.0, 56.0], [60.1, 56.0], [60.0, 56.1]]]}
    out = g.extract_geojson({"infosetGeoJson": obj})
    assert out is not None
    assert '"Polygon"' in out


# ── _build_record ─────────────────────────────────────────────────────────────


def test_build_record_from_real_fixtures() -> None:
    rec = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1000210001947595", None)
    assert rec is not None
    assert rec["doc_group"] == "RS"
    assert rec["doc_num"] == "66-45-08-2026"
    assert rec["date_doc"] == date(2026, 6, 2)
    assert rec["date_reg"] == date(2026, 6, 19)
    assert rec["cad_nums"] == ["66:45:0200124:343"]
    assert rec["geojson"] is not None
    assert rec["source_schema"] == "agate_ekbgo"
    assert rec["source_key"] == "1000210001947595"
    assert "Каменск-Уральск" in rec["approved_organization"]


def test_build_record_strips_docnum_whitespace() -> None:
    """~25% номеров в списке с ведущими/хвостовыми пробелами → strip обязателен."""
    item = {**_LIST_ITEM, "docNum": " 66-RU66333000-832-2016 "}
    card = {**_CARD, "docNum": " 66-RU66333000-832-2016 "}
    rec = g._build_record(item, card, "agate_sverdregion", "RS", "1", None)
    assert rec is not None
    assert rec["doc_num"] == "66-RU66333000-832-2016"


def test_build_record_no_docnum_returns_none() -> None:
    rec = g._build_record({}, {"spatialUnit": "66:45:0200124:343"}, "agate_ekbgo", "RS", "9", None)
    assert rec is None


def test_build_record_no_cad_no_geom() -> None:
    """Карточка без spatialUnit и без geometry — запись строится, не падает."""
    card = {"docNum": "66-99-1-2026", "dateReg": "2026-06-01T10:00:00", "hasGeometry": False}
    rec = g._build_record({}, card, "agate_ekbgo", "RV", "77", None)
    assert rec is not None
    assert rec["cad_nums"] == []
    assert rec["geojson"] is None
    assert rec["doc_group"] == "RV"


def test_build_record_list_datereg_fallback() -> None:
    """dateReg берётся из карточки; если её нет — fallback на list_reg из списка."""
    card = {"docNum": "66-1-1-2026", "spatialUnit": "66:45:0200124:343"}
    rec = g._build_record({}, card, "agate_ekbgo", "RS", "5", date(2026, 5, 1))
    assert rec is not None
    assert rec["date_reg"] == date(2026, 5, 1)


# ── Fake DB session (как в test_gas_grs_loader) ───────────────────────────────


class _FakeResult:
    def __init__(self, scalar_value: Any = True, rows: list[Any] | None = None) -> None:
        self._scalar = scalar_value
        self._rows = rows or []

    def scalar(self) -> Any:
        return self._scalar

    def all(self) -> list[Any]:
        return self._rows


class _FakeSession:
    """Мини-заглушка Session: execute + begin_nested SAVEPOINT-контекст.

    scalar_value управляет исходом _upsert_permit (True→insert, False→update,
    None→skipped_unchanged). select_rows отдаётся из .all() для _existing_reg_dates.
    """

    def __init__(
        self,
        scalar_value: Any = True,
        select_rows: list[Any] | None = None,
    ) -> None:
        self.calls: list[tuple[str, Any]] = []
        self._scalar = scalar_value
        self._select_rows = select_rows or []
        self.committed = False
        self.commit_count = 0

    @contextmanager
    def begin_nested(self):  # type: ignore[no-untyped-def]
        yield

    def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
        sql = str(stmt)
        self.calls.append((sql, params))
        if sql.strip().upper().startswith("SELECT"):
            return _FakeResult(self._scalar, self._select_rows)
        return _FakeResult(self._scalar)

    def commit(self) -> None:
        self.committed = True
        self.commit_count += 1

    def rollback(self) -> None:
        pass

    def close(self) -> None:
        pass


# ── _upsert_permit: insert / update / skipped_unchanged ───────────────────────


def _sample_rec(**over: Any) -> dict[str, Any]:
    base = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1000210001947595", None)
    assert base is not None
    base.update(over)
    return base


def test_upsert_permit_insert() -> None:
    db = _FakeSession(scalar_value=True)  # xmax=0 → insert
    assert g._upsert_permit(db, _sample_rec()) == "inserted"  # type: ignore[arg-type]
    # cad_nums параметризуется как list (→ CAST(:cad_nums AS text[]) в SQL).
    assert db.calls[0][1]["cad_nums"] == ["66:45:0200124:343"]
    assert db.calls[0][1]["doc_group"] == "RS"


def test_upsert_permit_update() -> None:
    db = _FakeSession(scalar_value=False)  # xmax<>0 → update
    assert g._upsert_permit(db, _sample_rec()) == "updated"  # type: ignore[arg-type]


def test_upsert_permit_skipped_unchanged() -> None:
    """WHERE-гейт не пропустил апдейт (более старый date_reg) → RETURNING NULL → skip."""
    db = _FakeSession(scalar_value=None)
    assert g._upsert_permit(db, _sample_rec()) == "skipped_unchanged"  # type: ignore[arg-type]


def test_upsert_permit_no_geom_passes_null() -> None:
    """geojson=None → в params передаётся None (SQL CASE запишет geom=NULL), не падает."""
    db = _FakeSession(scalar_value=True)
    rec = _sample_rec(geojson=None, cad_nums=[])
    assert g._upsert_permit(db, rec) == "inserted"  # type: ignore[arg-type]
    assert db.calls[0][1]["geojson"] is None
    assert db.calls[0][1]["cad_nums"] == []


# ── Инкрементальность через _sync_group ───────────────────────────────────────


_COUNT_KEYS = (
    "inserted",
    "updated",
    "skipped_unchanged",
    "fetched_details",
    "no_cad",
    "no_geom",
    "card_failed",
    "group_aborted",
)


def _fresh_counts() -> dict[str, int]:
    """Свежий словарь счётчиков _sync_group (все ключи, что лоадер инкрементит)."""
    return dict.fromkeys(_COUNT_KEYS, 0)


class _RecordingClient:
    """Заглушка httpx-клиента: считает, сколько карточек запрошено (fetch_doc_card)."""


def _patch_card_fetch(monkeypatch: Any, calls: list[Any]) -> None:
    def _fake(client: Any, schema: str, key: Any) -> dict[str, Any]:
        calls.append(key)
        return dict(_CARD)

    monkeypatch.setattr(g, "fetch_doc_card", _fake)


def test_sync_group_incremental_skip(monkeypatch: Any) -> None:
    """Известный key с ТЕМ ЖЕ date_reg → карточка НЕ тянется, счётчик skipped_unchanged."""
    fetched: list[Any] = []
    _patch_card_fetch(monkeypatch, fetched)
    db = _FakeSession(scalar_value=True)
    existing = {"1000210001947595": date(2026, 6, 19)}  # тот же dateReg, что в списке
    counts = _fresh_counts()
    g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], existing, counts)  # type: ignore[arg-type]
    assert fetched == []  # карточку не тянули
    assert counts["skipped_unchanged"] == 1
    assert counts["fetched_details"] == 0


def test_sync_group_changed_datereg_fetches(monkeypatch: Any) -> None:
    """Известный key, но date_reg в списке ИЗМЕНИЛСЯ → карточка тянется + upsert."""
    fetched: list[Any] = []
    _patch_card_fetch(monkeypatch, fetched)
    db = _FakeSession(scalar_value=False)  # update
    existing = {"1000210001947595": date(2026, 6, 10)}  # старее списочного 06-19
    counts = _fresh_counts()
    g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], existing, counts)  # type: ignore[arg-type]
    assert fetched == ["1000210001947595"]  # карточку тянули
    assert counts["fetched_details"] == 1
    assert counts["updated"] == 1


def test_sync_group_new_key_inserts_and_counts_geom(monkeypatch: Any) -> None:
    """Новый key → fetch + insert; карточка с geom+cad → no_cad/no_geom НЕ инкрементятся."""
    fetched: list[Any] = []
    _patch_card_fetch(monkeypatch, fetched)
    db = _FakeSession(scalar_value=True)  # insert
    counts = _fresh_counts()
    g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], {}, counts)  # type: ignore[arg-type]
    assert counts["inserted"] == 1
    assert counts["fetched_details"] == 1
    assert counts["no_cad"] == 0  # карточка с cad
    assert counts["no_geom"] == 0  # карточка с geom


def test_sync_group_no_cad_no_geom_counts(monkeypatch: Any) -> None:
    """Карточка без spatialUnit и без geometry → no_cad и no_geom += 1, upsert идёт."""

    def _fake(client: Any, schema: str, key: Any) -> dict[str, Any]:
        return {"docNum": "66-99-1-2026", "dateReg": "2026-06-01T10:00:00", "hasGeometry": False}

    monkeypatch.setattr(g, "fetch_doc_card", _fake)
    db = _FakeSession(scalar_value=True)
    item = {**_LIST_ITEM, "key": 42, "dateReg": "2026-06-01T10:00:00"}
    counts = _fresh_counts()
    g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RV", [item], {}, counts)  # type: ignore[arg-type]
    assert counts["no_cad"] == 1
    assert counts["no_geom"] == 1
    assert counts["inserted"] == 1


# ── Хотфикс #2370: 5xx-карточки без ретраев, circuit breaker, периодический commit ──


class _FakeResponse:
    """Мини-заглушка httpx.Response: только status_code + json/raise_for_status/request."""

    def __init__(self, status_code: int, payload: Any = None) -> None:
        self.status_code = status_code
        self._payload = payload
        self.request = httpx.Request("GET", "https://example/x")

    def json(self) -> Any:
        return self._payload

    def raise_for_status(self) -> None:
        if self.status_code >= 400:
            raise httpx.HTTPStatusError(
                f"{self.status_code}",
                request=self.request,
                response=None,  # type: ignore[arg-type]
            )


class _CountingClient:
    """Заглушка httpx.Client: считает GET-и, всегда отдаёт заданный status_code."""

    def __init__(self, status_code: int, payload: Any = None) -> None:
        self.status_code = status_code
        self.payload = payload
        self.get_calls: list[str] = []

    def get(self, path: str) -> _FakeResponse:
        self.get_calls.append(path)
        return _FakeResponse(self.status_code, self.payload)


def test_fetch_doc_card_500_single_attempt(monkeypatch: Any) -> None:
    """Хотфикс: карточка отдаёт 500 → ровно 1 HTTP-попытка (retries_5xx=0), затем raise.

    Битый сегмент старых ключей портала детерминированно 5xx — ретраи = ~12с/карточку
    впустую. Убираем sleep, чтоб тест был мгновенным.
    """
    monkeypatch.setattr(g.time, "sleep", lambda _s: None)
    client = _CountingClient(status_code=500)
    raised = False
    try:
        g.fetch_doc_card(client, "agate_ekbgo", "100001007000123")  # type: ignore[arg-type]
    except httpx.HTTPStatusError:
        raised = True
    assert raised is True
    assert len(client.get_calls) == 1  # НИ одного ретрая на 5xx карточки


def test_fetch_group_list_500_retries(monkeypatch: Any) -> None:
    """Список группы 5xx ретраится с дефолтом (1 + _MAX_RETRIES попыток) — контраст с карточкой."""
    monkeypatch.setattr(g.time, "sleep", lambda _s: None)
    client = _CountingClient(status_code=500)
    raised = False
    try:
        g.fetch_group_list(client, "agate_ekbgo", "DocRS")  # type: ignore[arg-type]
    except httpx.HTTPStatusError:
        raised = True
    assert raised is True
    assert len(client.get_calls) == 1 + g._MAX_RETRIES  # 3 попытки


def test_sync_group_circuit_breaker_aborts(monkeypatch: Any) -> None:
    """50 карточек ПОДРЯД с 5xx → группа прервана: group_aborted=1, остаток НЕ запрошен."""
    monkeypatch.setattr(g.time, "sleep", lambda _s: None)
    requested: list[Any] = []

    def _always_fail(client: Any, schema: str, key: Any) -> dict[str, Any] | None:
        requested.append(key)
        raise httpx.HTTPStatusError("500", request=httpx.Request("GET", "x"), response=None)  # type: ignore[arg-type]

    monkeypatch.setattr(g, "fetch_doc_card", _always_fail)
    db = _FakeSession(scalar_value=True)
    # 60 новых ключей — breaker (50) должен сработать раньше, чем дойдём до 60-го.
    items = [{**_LIST_ITEM, "key": 100001007000000 + i} for i in range(60)]
    counts = _fresh_counts()
    g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", items, {}, counts)  # type: ignore[arg-type]
    assert counts["group_aborted"] == 1
    assert counts["card_failed"] == g._CARD_FAIL_BREAKER  # ровно 50 фейлов до аборта
    assert len(requested) == g._CARD_FAIL_BREAKER  # 51-й ключ НЕ запрашивался
    assert counts["inserted"] == 0


def test_sync_group_success_resets_breaker(monkeypatch: Any) -> None:
    """Успешная карточка между фейлами сбрасывает breaker → группа НЕ прерывается.

    49 фейлов, потом успех (reset), потом ещё 49 фейлов — суммарно 98 фейлов, но НИ разу
    50 подряд → group_aborted=0, вся группа обработана.
    """
    monkeypatch.setattr(g.time, "sleep", lambda _s: None)
    # Индексы 49 и 99 (0-based) — успех; остальные — фейл. Между ними ≤49 подряд.
    good_idx = {49, 99}
    seq: list[int] = []

    def _mostly_fail(client: Any, schema: str, key: Any) -> dict[str, Any] | None:
        idx = len(seq)
        seq.append(idx)
        if idx in good_idx:
            return dict(_CARD)
        raise httpx.HTTPStatusError("500", request=httpx.Request("GET", "x"), response=None)  # type: ignore[arg-type]

    monkeypatch.setattr(g, "fetch_doc_card", _mostly_fail)
    db = _FakeSession(scalar_value=True)
    items = [{**_LIST_ITEM, "key": 200000000 + i} for i in range(100)]
    counts = _fresh_counts()
    g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", items, {}, counts)  # type: ignore[arg-type]
    assert counts["group_aborted"] == 0  # breaker сбрасывался — не прервались
    assert counts["card_failed"] == 98  # 100 − 2 успешных
    assert counts["fetched_details"] == 2  # 2 успешные карточки
    assert len(seq) == 100  # прошли ВСЮ группу


def test_sync_group_periodic_commit(monkeypatch: Any) -> None:
    """>200 обработанных записей → db.commit() вызывается периодически (каждые 200)."""
    monkeypatch.setattr(g.time, "sleep", lambda _s: None)
    _patch_card_fetch(monkeypatch, [])
    db = _FakeSession(scalar_value=True)  # каждый upsert → inserted
    # 450 новых ключей → 2 периодических commit (на 200-й и 400-й записи).
    items = [{**_LIST_ITEM, "key": 300000000 + i} for i in range(450)]
    counts = _fresh_counts()
    g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", items, {}, counts)  # type: ignore[arg-type]
    assert counts["inserted"] == 450
    assert db.commit_count == 2  # 450 // 200 = 2 периодических commit


# ── Конфликт двух схем: поздний date_reg параметризуется в UPSERT ──────────────


def test_conflict_resolution_prefers_later_datereg() -> None:
    """Обе схемы содержат один (doc_group, doc_num); UPSERT-параметры несут date_reg
    записи — WHERE-гейт в SQL (EXCLUDED.date_reg > existing) выберет позднюю.

    Проверяем именно то, что лоадер параметризует date_reg корректно (SQL-гейт покрыт
    на уровне БД). ekbgo (позднее 06-19) vs sverdregion (раннее 06-10).
    """
    db = _FakeSession(scalar_value=False)
    ekb = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1", None)
    sverd_card = {**_CARD, "dateReg": "2026-06-10T09:00:00"}
    sverd = g._build_record(_LIST_ITEM, sverd_card, "agate_sverdregion", "RS", "2", None)
    assert ekb is not None and sverd is not None
    g._upsert_permit(db, ekb)  # type: ignore[arg-type]
    g._upsert_permit(db, sverd)  # type: ignore[arg-type]
    assert db.calls[0][1]["date_reg"] == date(2026, 6, 19)  # ekbgo — поздняя
    assert db.calls[1][1]["date_reg"] == date(2026, 6, 10)  # sverdregion — ранняя
    # Оба несут одинаковый бизнес-ключ (doc_group, doc_num).
    assert db.calls[0][1]["doc_num"] == db.calls[1][1]["doc_num"] == "66-45-08-2026"


# ── _existing_reg_dates ───────────────────────────────────────────────────────


def test_existing_reg_dates_maps_key_to_datereg() -> None:
    rows = [("100", date(2026, 6, 1)), ("200", None)]
    db = _FakeSession(select_rows=rows)
    out = g._existing_reg_dates(db, "agate_ekbgo")  # type: ignore[arg-type]
    assert out == {"100": date(2026, 6, 1), "200": None}
