"""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 os
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 (
    _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
