"""Unit-тесты district-resolver (Step 2 — мост admin↔micro вокабуляров ЕКБ).

Резолвер лечит баг §9.x: /analyze передаёт ОФИЦИАЛЬНОЕ админ-имя («Кировский»), а
objective_lots.district хранит НЕФОРМАЛЬНЫЕ микро («Втузгородок»/«ЖБИ») → админ-имя
давало 0 строк → молча пустой прогноз (см. data/sql/131_district_vocab_map.sql).

Контракт resolve_objective_districts(db, district):
  • None / 'не определён'           → None  (EKB-wide, без district-фильтра)
  • админ-имя с чистыми алиасами     → [micro, …]  (note IS NULL)
  • админ-имя без чистых алиасов      → None  (EKB-wide fallback, НЕ пустой набор)
  • не-админ (raw микро, legacy)      → [district]  (фильтр-по-микро как есть)

Тесты бьют МОК-сессию (без живой БД): резолвер делает максимум 2 execute —
admin-names (.scalars().all()) + micro-lookup (.scalars().all()). КРИТИЧНО: тест на
админ-вход ОБЯЗАН падать, если резолвер регрессирует к pass-through админ-имени.
"""

from __future__ import annotations

import contextlib
import os
from collections.abc import Callable, Iterator
from typing import Any
from unittest.mock import MagicMock

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

import pytest

from app.services.site_finder import district_resolver
from app.services.site_finder.district_resolver import (
    _admin_names,
    _reset_admin_cache,
    resolve_objective_districts,
)

# 8 официальных админ-районов ЕКБ (district_name из ekb_districts с непустым geom).
_ADMIN_NAMES = [
    "Академический",
    "Верх-Исетский",
    "Железнодорожный",
    "Кировский",
    "Ленинский",
    "Октябрьский",
    "Орджоникидзевский",
    "Чкаловский",
]

# Чистые микро (note IS NULL) для админов, которые тестим (срез prod-сида 131_*).
_CLEAN_MICROS: dict[str, list[str]] = {
    "Кировский": ["Втузгородок", "ЖБИ"],
    "Ленинский": ["Автовокзал", "УНЦ", "Юго-Западный"],
    "Орджоникидзевский": ["Уралмаш", "Эльмаш"],
}


@pytest.fixture(autouse=True)
def _reset_cache_between_tests() -> None:
    """Сбрасывать per-process кеш админ-имён — иначе мок одного теста течёт в другой."""
    _reset_admin_cache()
    yield
    _reset_admin_cache()


def _make_db(
    admin_names: list[str] | None = None,
    micros: list[str] | None = None,
) -> MagicMock:
    """Мок-сессия: 1-й execute → admin-names; 2-й (если есть) → micro-lookup.

    Оба читаются через .scalars().all(). admin_names=None → дефолтные 8.
    """
    names = _ADMIN_NAMES if admin_names is None else admin_names
    db = MagicMock()
    admin_result = MagicMock()
    admin_result.scalars.return_value.all.return_value = names
    micro_result = MagicMock()
    micro_result.scalars.return_value.all.return_value = micros if micros is not None else []
    db.execute.side_effect = [admin_result, micro_result]
    return db


def _micro_lookup_param(db: MagicMock) -> str | None:
    """Достать bind :d из ВТОРОГО execute (micro-lookup). None если его не было."""
    if len(db.execute.call_args_list) < 2:
        return None
    args, _kwargs = db.execute.call_args_list[1]
    return args[1]["d"]


# ── None / 'не определён' → None (EKB-wide) ───────────────────────────────────


class TestNoneAndUndetermined:
    def test_none_returns_none(self) -> None:
        db = _make_db()
        assert resolve_objective_districts(db, None) is None
        # ранний выход — БД даже не трогается
        assert db.execute.call_count == 0

    def test_undetermined_returns_none(self) -> None:
        db = _make_db()
        assert resolve_objective_districts(db, "не определён") is None
        assert db.execute.call_count == 0


# ── админ-имя → его чистые микро (THE FIX) ─────────────────────────────────────


class TestAdminExpandsToMicros:
    @pytest.mark.parametrize("admin", list(_CLEAN_MICROS.keys()))
    def test_admin_expands_to_clean_micros(self, admin: str) -> None:
        expected = _CLEAN_MICROS[admin]
        db = _make_db(micros=expected)
        out = resolve_objective_districts(db, admin)
        assert out == expected

    def test_does_not_pass_admin_name_through(self) -> None:
        # РЕГРЕССИОННЫЙ guard: резолвер НЕ должен возвращать [admin] (это и был баг —
        # админ-имя → 0 строк). Должен вернуть микро, а админ-имя НЕ должно быть в наборе.
        db = _make_db(micros=["Втузгородок", "ЖБИ"])
        out = resolve_objective_districts(db, "Кировский")
        assert out == ["Втузгородок", "ЖБИ"]
        assert out != ["Кировский"]
        assert "Кировский" not in out

    def test_micro_lookup_bound_with_admin_name(self) -> None:
        # micro-lookup параметризован именно админ-именем (CAST(:d AS text)).
        db = _make_db(micros=["Уралмаш", "Эльмаш"])
        resolve_objective_districts(db, "Орджоникидзевский")
        assert _micro_lookup_param(db) == "Орджоникидзевский"


# ── админ без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор) ───────


class TestAdminNoCleanAliasesFallback:
    def test_admin_without_clean_micros_returns_none(self) -> None:
        # Админ в ekb_districts, но micro-lookup пуст (note IS NULL отсёк всё) →
        # EKB-wide fallback. НИКОГДА не пустой список (иначе гарантированно 0 строк).
        db = _make_db(micros=[])
        out = resolve_objective_districts(db, "Чкаловский")
        assert out is None

    def test_never_returns_empty_list(self) -> None:
        db = _make_db(micros=[])
        out = resolve_objective_districts(db, "Ленинский")
        assert out != []
        assert out is None


# ── raw микро (legacy внутренний вызов) → [district] как есть ──────────────────


class TestRawMicroPassThrough:
    def test_raw_micro_returned_as_is(self) -> None:
        # 'Втузгородок' — НЕ админ-имя → фильтруем по нему как есть (legacy-поведение).
        db = _make_db()
        out = resolve_objective_districts(db, "Втузгородок")
        assert out == ["Втузгородок"]

    def test_raw_micro_does_not_hit_micro_lookup(self) -> None:
        # raw-микро не разворачивается → второй execute (micro-lookup) НЕ делается.
        db = _make_db()
        resolve_objective_districts(db, "Уралмаш")
        assert db.execute.call_count == 1  # только admin-names


# ── кеш админ-имён ─────────────────────────────────────────────────────────────


class TestAdminNamesCache:
    def test_admin_names_cached_across_calls(self) -> None:
        # admin-names читается ОДИН раз и кешируется per-process: второй резолв
        # raw-микро НЕ делает повторный admin-names запрос.
        db = MagicMock()
        admin_result = MagicMock()
        admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES
        db.execute.return_value = admin_result  # любой вызов вернёт admin-names

        resolve_objective_districts(db, "Уралмаш")  # raw micro → 1 execute (admins)
        resolve_objective_districts(db, "Эльмаш")  # raw micro → 0 execute (cache hit)
        assert db.execute.call_count == 1

    def test_admin_lookup_failure_treats_input_as_raw_micro(self) -> None:
        # Сбой admin-names lookup → пустой admin-set → ЛЮБОЙ вход трактуется как raw
        # микро (pass-through), НЕ роняем /analyze. Кеш-от-сбоя не залипает.
        db = MagicMock()
        db.execute.side_effect = RuntimeError("db down")
        out = resolve_objective_districts(db, "Кировский")
        # admin-set пуст → 'Кировский' не распознан как админ → [district] как есть.
        assert out == ["Кировский"]


# ── сбой micro-lookup → EKB-wide fallback (не crash) ──────────────────────────


class TestMicroLookupFailure:
    def test_micro_lookup_exception_returns_none(self) -> None:
        db = MagicMock()
        admin_result = MagicMock()
        admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES
        db.execute.side_effect = [admin_result, RuntimeError("micro lookup down")]
        out = resolve_objective_districts(db, "Кировский")
        # админ распознан, но lookup упал → EKB-wide (None), а не crash/пустой набор.
        assert out is None


# ── форма SQL: CAST(:d AS text), без :d::text ─────────────────────────────────


class TestResolverSqlShape:
    def test_micro_sql_uses_cast_not_double_colon(self) -> None:
        sql = str(district_resolver._MICROS_FOR_ADMIN_SQL)
        assert "CAST(:d AS text)" in sql
        assert ":d::" not in sql

    def test_micro_sql_filters_note_is_null(self) -> None:
        # cross-city/ambiguous (note IS NOT NULL) ОБЯЗАНЫ быть отсечены.
        sql = str(district_resolver._MICROS_FOR_ADMIN_SQL)
        assert "note IS NULL" in sql

    def test_admin_sql_filters_non_null_geom(self) -> None:
        # 8 админов = ekb_districts с непустым geom ('не определён' без geom исключён).
        sql = str(district_resolver._ADMIN_NAMES_SQL)
        assert "geom IS NOT NULL" in sql


# ──────────────────────────────────────────────────────────────────────────────
# RELEASE-SAVEPOINT trap (#2464 cluster A, second-order): резолвер зовётся §22-
# билдерами (sales_series `_query_source_a/_b`, `_query_artificial_demand`) на общей
# сессии отчёта ПОД внешним SAVEPOINT. Если внутренний swallow (_admin_names /
# micro-lookup) съедает db-сбой БЕЗ своего savepoint, транзакция остаётся aborted;
# внешний savepoint выходит «нормально» и делает RELEASE SAVEPOINT, который САМ падает
# в aborted-tx (Postgres допускает лишь ROLLBACK / ROLLBACK TO SAVEPOINT) — внешний
# except ловит RELEASE-ошибку, но tx так и не откачена → отравление каскадит. Fix =
# savepoint В ТОЧКЕ ПЕРЕХВАТА. Тест воспроизводит ИМЕННО RELEASE-trap (не плоский
# swallow): `_ReleaseTrapSession` моделирует, что RELEASE в aborted-tx падает.
# ──────────────────────────────────────────────────────────────────────────────


class _FakeResult:
    """Мини-результат db.execute() — покрывает `.scalars().all()` и `.mappings().first()`."""

    def __init__(self, rows: list[Any]) -> None:
        self._rows = list(rows)

    def scalars(self) -> _FakeResult:
        return self

    def mappings(self) -> _FakeResult:
        return self

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

    def first(self) -> Any:
        return self._rows[0] if self._rows else None


class _ReleaseTrapSession:
    """Fake Session, моделирующая Postgres SAVEPOINT/RELEASE/ROLLBACK в aborted-tx.

    - `execute()` на `fail_on_call`-м вызове бросает РОВНО раз и помечает tx aborted
      (poisoned). Пока poisoned — любой execute бросает 'current transaction is aborted'.
    - `begin_nested()` моделирует SAVEPOINT:
        • ВХОД в poisoned-tx → SAVEPOINT запрещён → бросает (как реальный Postgres);
        • ВЫХОД с исключением → ROLLBACK TO SAVEPOINT (снимает poison), пробрасывает;
        • ВЫХОД нормально → RELEASE SAVEPOINT, и если tx poisoned — RELEASE ПАДАЕТ
          (это и есть RELEASE-trap; без внутреннего savepoint тест краснеет).
    """

    def __init__(
        self, *, fail_on_call: int = 1, results: dict[int, list[Any]] | None = None
    ) -> None:
        self.poisoned = False
        self.calls = 0
        self.fail_on_call = fail_on_call
        self._results = results or {}

    def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
        self.calls += 1
        if self.poisoned:
            raise RuntimeError(
                "current transaction is aborted, commands ignored until end of transaction block"
            )
        if self.calls == self.fail_on_call:
            self.poisoned = True
            raise RuntimeError("simulated inner helper query failure")
        return _FakeResult(self._results.get(self.calls, []))

    @contextlib.contextmanager
    def begin_nested(self) -> Iterator[None]:
        if self.poisoned:
            # SAVEPOINT запрещён в aborted-tx.
            raise RuntimeError(
                "current transaction is aborted, commands ignored until end of transaction block"
            )
        try:
            yield
        except Exception:
            # ROLLBACK TO SAVEPOINT — разрешён в aborted, снимает poison.
            self.poisoned = False
            raise
        else:
            if self.poisoned:
                # RELEASE SAVEPOINT в aborted-tx ПАДАЕТ — тот самый trap.
                raise RuntimeError(
                    "current transaction is aborted, "
                    "commands ignored until end of transaction block"
                )


def _outer_savepoint_call(db: Any, fn: Callable[[], Any], *, fallback: Any) -> Any:
    """Зеркало `_run`/`_safe_call`: обернуть fn() во ВНЕШНИЙ savepoint (RELEASE на норм-выходе).

    Именно этот RELEASE падает в aborted-tx, если внутренний helper отравил сессию без
    своего savepoint → возвращается `fallback` (сигнал, что trap сработал).
    """
    try:
        with db.begin_nested():
            return fn()
    except Exception:
        return fallback


class TestReleaseSavepointTrap:
    """Внутренние swallow-сайты резолвера НЕ отравляют внешний SAVEPOINT (RELEASE-trap)."""

    def test_admin_names_failure_survives_outer_release(self) -> None:
        _reset_admin_cache()
        db = _ReleaseTrapSession(fail_on_call=1)  # admin-names lookup падает
        result = _outer_savepoint_call(db, lambda: _admin_names(db), fallback="OUTER_FALLBACK")
        # _admin_names вернул СВОЙ fallback (пустой frozenset) — внешний RELEASE прошёл,
        # значит внутренний savepoint откатил сбой (иначе result был бы OUTER_FALLBACK).
        assert result == frozenset()
        # КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
        db.execute("SELECT 1")

    def test_micro_lookup_failure_survives_outer_release(self) -> None:
        _reset_admin_cache()
        # call 1 = admin-names (успех, "Кировский" распознан); call 2 = micro-lookup (падает).
        db = _ReleaseTrapSession(fail_on_call=2, results={1: ["Кировский"]})
        result = _outer_savepoint_call(
            db,
            lambda: resolve_objective_districts(db, "Кировский"),
            fallback="OUTER_FALLBACK",
        )
        # micro-lookup проглочен → EKB-wide fallback (None), НЕ внешний OUTER_FALLBACK.
        assert result is None
        # КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
        db.execute("SELECT 1")
