"""District-resolver — мост между ДВУМЯ районными вокабулярами ЕКБ (Step 2).

Контекст бага (ground-truthed на prod, см. `data/sql/131_district_vocab_map.sql`):
  • §9.x market/supply/sales метрики фильтруют `objective_lots.district` (и
    `objective_corpus_room_month.district`) — это НЕФОРМАЛЬНЫЕ МИКРО-районы ЕКБ
    («Втузгородок», «ЖБИ», «Уралмаш» … — 35 distinct значений).
  • Но `/analyze` резолвит у участка ОФИЦИАЛЬНЫЙ АДМИН-район из `ekb_districts`
    (8 полигонов: «Кировский», «Ленинский», «Орджоникидзевский» …).
  • Админ-имя → 0 строк в objective_lots → молча пустой прогноз. Напр.
    market_metrics('Кировский') → obj_count=0 (а 'Академический' случайно работает,
    т.к. он есть в ОБОИХ вокабулярах).

Step 1 (PR #1053, УЖЕ НА ПРОДЕ) добавил таблицу-словарь `ekb_district_alias`
(admin_district → micro_district, note). Этот модуль — Step 2: резолвер, который
разворачивает админ-имя в список микро-районов, чтобы фильтры §9.x перестали
возвращать пустоту.

Семантика `resolve_objective_districts(db, district)` — см. docstring функции.
psycopg v3: все binds через `CAST(:x AS type)`, НИКОГДА `:x::type`. Read-only SELECT.
"""

from __future__ import annotations

import logging

from sqlalchemy import text
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

# Сентинел «район не определён» — приходит из /analyze, когда участок не попал ни в
# один из 8 полигонов ekb_districts (вне ЕКБ / без координат). Трактуем как None →
# EKB-wide fallback (без district-фильтра), а не как буквальное имя района.
_UNDETERMINED_DISTRICT: str = "не определён"

# Список 8 официальных АДМИН-районов ЕКБ (district_name из ekb_districts с непустым
# geom). Кешируем per-process: он фактически статичен (8 полигонов, меняется только
# при пере-импорте границ), а резолвер бьётся per-analyze — лишний round-trip ни к
# чему. Lazy-init на первом вызове; сбрасывается через `_reset_admin_cache()` в тестах.
_ADMIN_NAMES_CACHE: frozenset[str] | None = None

# Имена 8 админ-районов: те, у кого в ekb_districts есть непустой geom (= ekb_districts_geom
# view). 'не определён' (9-я строка-dimension без geom) сюда НЕ попадает — и не должен.
_ADMIN_NAMES_SQL = text(
    """
    SELECT district_name
      FROM ekb_districts
     WHERE geom IS NOT NULL
    """
)

# Чистые ЕКБ-микро для одного админ-района. note IS NULL отсекает cross-city
# (Н.Тагил/Каменск, собранные в bucket «Чкаловский») и ambiguous-пометки —
# нам нужны только однозначные ЕКБ-микро (см. 131_district_vocab_map.sql).
_MICROS_FOR_ADMIN_SQL = text(
    """
    SELECT micro_district
      FROM ekb_district_alias
     WHERE admin_district = CAST(:d AS text)
       AND note IS NULL
     ORDER BY micro_district
    """
)


def _admin_names(db: Session) -> frozenset[str]:
    """Множество 8 официальных админ-имён ЕКБ (с непустым geom), кеш per-process.

    На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ
    вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.

    SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): резолвер
    зовётся §22-форсайт-билдерами (sales_series `_query_source_a/_b`,
    `_query_artificial_demand`) на ОБЩЕЙ сессии отчёта, которые сами обёрнуты во
    внешние SAVEPOINT'ы. БЕЗ `db.begin_nested():` здесь сбойный `db.execute`
    проглатывается (→ frozenset()), оставляя транзакцию aborted; тогда внешний
    SAVEPOINT при RELEASE падает (RELEASE запрещён в aborted-tx) и отравление
    каскадит. Savepoint в точке перехвата откатывает сбой (ROLLBACK TO SAVEPOINT),
    сессия остаётся рабочей.
    """
    global _ADMIN_NAMES_CACHE
    if _ADMIN_NAMES_CACHE is not None:
        return _ADMIN_NAMES_CACHE
    try:
        with db.begin_nested():
            rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
        _ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
    except Exception:
        # Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
        logger.exception("district_resolver: admin-names lookup failed")
        return frozenset()
    return _ADMIN_NAMES_CACHE


def _reset_admin_cache() -> None:
    """Сбросить per-process кеш админ-имён (для тестов / форс-рефреша границ)."""
    global _ADMIN_NAMES_CACHE
    _ADMIN_NAMES_CACHE = None


def resolve_objective_districts(db: Session, district: str | None) -> list[str] | None:
    """Развернуть district-вход в набор МИКРО-районов для фильтра objective_lots.

    Решает рассинхрон вокабуляров ЕКБ (admin polygons vs informal micro-districts).
    Возвращаемое значение — контракт для вызывающего фильтра:

      • ``None``        → фильтровать EKB-wide (БЕЗ district-фильтра). Это происходит
                          когда:
                            - вход ``None`` или ``'не определён'`` (участок вне 8
                              полигонов — нечего сужать), ЛИБО
                            - вход — валидное админ-имя, но у него НЕТ чистых алиасов
                              (на практике не случается: все 8 покрыты сидом; страховка).
      • ``[micro, …]``  → фильтровать ``ol.district = ANY(...)``:
                            - вход — одно из 8 АДМИН-имён → его чистые микро
                              (note IS NULL: cross-city/ambiguous исключены), ЛИБО
                            - вход — НЕ админ-имя (raw МИКРО, переданный внутренним
                              legacy-вызовом) → ``[district]`` как есть (сохраняем
                              прежнее поведение фильтра-по-микро).

    Никогда не возвращает пустой список — вместо «0 строк гарантированно» отдаёт
    ``None`` (EKB-wide), чтобы прогноз не схлопывался молча в пустоту (это и есть
    суть фикса).

    psycopg v3: bind через CAST(:d AS text). Read-only.
    """
    if district is None or district == _UNDETERMINED_DISTRICT:
        return None

    admin_names = _admin_names(db)
    if district not in admin_names:
        # Raw микро-район (legacy внутренний вызов) — фильтруем по нему как есть.
        return [district]

    # district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
    # SAVEPOINT на внутреннем swallow-сайте (#2464 cluster A, RELEASE-trap): см.
    # `_admin_names` — тот же общий-сессионный вызов из §22-билдеров под внешним
    # SAVEPOINT. `db.begin_nested():` откатывает сбой в точке перехвата, чтобы
    # внешний RELEASE не упал в aborted-tx (RELEASE запрещён в aborted).
    try:
        with db.begin_nested():
            rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
        micros = [str(m) for m in rows]
    except Exception:
        logger.exception(
            "district_resolver: micro-lookup failed (admin=%s) — falling back to EKB-wide",
            district,
        )
        return None

    if not micros:
        # Админ без чистых алиасов (дыра в словаре) → EKB-wide fallback, НЕ пустой набор.
        logger.warning(
            "district_resolver: admin '%s' has no clean micro aliases — EKB-wide fallback",
            district,
        )
        return None

    logger.debug("district_resolver: admin '%s' → micros %s", district, micros)
    return micros
