"""Read-only достаёт персистентный §22-форсайт участка для чата (#957, Step 1).

Тонкая обёртка над `latest_run_for(..., schema_version="1.0")` — ровно тот же seam,
что читает `GET /{cad_num}/forecast` (parcels.py): последний ран ИМЕННО §22-схемы
('1.0', НЕ analyze-1.0 inline-dict). `run.result` — это `SiteFinderReport.as_dict()`
(стабильный 8-секционный контракт), `run.id` — run_id для grounding.

Q2-РЕШЕНИЕ = READ-ONLY: рана нет → возвращаем (None, None). Эндпоинт по этому отдаёт
report_status='pending' + детерминированный RU-ответ «отчёт ещё не готов, запустите
анализ участка». НИЧЕГО не считаем, НЕ enqueue'им, НЕ пере-вычисляем (чат не триггерит
форсайт — это делает POST /analyze). Сбой БД пробрасываем НЕ из этого модуля —
гасит/логирует его вызывающий эндпоинт (как get_parcel_forecast → pending).
"""

from __future__ import annotations

import logging
from typing import Any

from sqlalchemy.orm import Session

from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION, latest_run_for

logger = logging.getLogger(__name__)

# Версия схемы §22-форсайта (зеркало _FORECAST_SCHEMA_VERSION в parcels.py). Читаем
# именно её, а не analyze-1.0: view вернул бы §22-ран поверх analyze, но фильтр по
# схеме явный и устойчивый.
_FORECAST_SCHEMA_VERSION = "1.0"

# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
_MAX_ZOUIT_NAMES = 15


def get_report_for_chat(
    db: Session,
    cad_num: str,
) -> tuple[dict[str, Any] | None, int | None]:
    """Достать последний §22-отчёт участка как (report_dict, run_id) или (None, None).

    READ-ONLY (Q2): рана нужной схемы нет → (None, None), БЕЗ enqueue/пере-расчёта.
    `run.result` — `SiteFinderReport.as_dict()` (8 секций). Если result почему-то не
    dict (мусор/частичная запись) — логируем и отдаём (None, None) как «нет отчёта»,
    чтобы чат не упал (graceful).
    """
    run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
    if run is None:
        return None, None

    result = run.result
    if not isinstance(result, dict):
        # Ран есть, но result не похож на отчёт (старая/битая запись) — не валим чат,
        # трактуем как «отчёта ещё нет» (pending). Логируем для диагностики.
        logger.warning(
            "chat: run %s for cad=%s has non-dict result (%s) — treating as pending",
            run.id,
            cad_num,
            type(result).__name__,
        )
        return None, None

    return result, run.id


def _as_dict(value: Any) -> dict[str, Any]:
    """Секция payload как dict или {} (graceful — как _as_dict в full_report_html)."""
    return value if isinstance(value, dict) else {}


def _put(target: dict[str, Any], key: str, value: Any) -> None:
    """Положить ТОЛЬКО скаляр в курируемый срез (§19 data-residency).

    Гейт двойной: и по КЛЮЧУ (вызывающий передаёт фиксированный whitelist), и по ТИПУ
    значения. Пропускаем лишь str|int|float|bool — dict/list/любая вложенная структура
    ОТБРАСЫВАЕТСЯ (иначе сырой под-словарь ЕГРН/зонирования с PII мог бы утечь наружу;
    redaction-слой ловит regex-PII в строках, но НЕ структуры). Пустую строку опускаем.

    UP038: union-форма isinstance (не кортеж) — pre-commit пинит старый ruff.
    """
    if not isinstance(value, str | int | float | bool):
        return
    if value == "":
        return
    target[key] = value


def _zouit_names(overlaps: list[Any]) -> list[str]:
    """Собрать dedup-список названий ЗОУИТ-зон (type_zone|name), обрезанный до лимита.

    §19: ТОЛЬКО строки-названия — никакой геометрии/reg_numb/coverage сырых пересечений.
    Не-строковые значения (dict/list/число) ОТБРАСЫВАЕМ — не приводим str()'ом, иначе
    вложенная структура утекла бы как её repr. dict.fromkeys держит порядок и dedup
    (зеркало _build_zouit в §1-рендере).
    """
    # Фильтруем до str ДО dict.fromkeys: не-str (dict/list) не только небезопасны для
    # §19, но и unhashable → уронили бы dict.fromkeys TypeError'ом.
    raw = (ov.get("type_zone") or ov.get("name") for ov in overlaps if isinstance(ov, dict))
    names = list(dict.fromkeys(n for n in raw if isinstance(n, str) and n))
    return names[:_MAX_ZOUIT_NAMES]


def get_parcel_context_for_chat(db: Session, cad_num: str) -> dict[str, Any] | None:
    """Курируемый паспорт участка + градрегламент для чата (§19 data-residency).

    READ-ONLY: последний analyze-ран (schema_version='analyze-1.0', НЕ §22-форсайт) →
    ПЛОСКИЙ whitelist-дикт из ФИКСИРОВАННОГО набора публичных градо-полей. Сырой
    analyze-blob во внешний LLM ЗАПРЕЩЁН (см. safe_payload.py) — поэтому собираем только
    скаляры и список строк по явным ключам, НИКОГДА не протаскивая под-словари/геометрию.

    Ключи payload — те же, что читает §1-рендер отчёта (full_report_html:_build_parcel_facts
    / _build_zoning / _build_zouit): egrn, nspd_zoning, encumbrance, nspd_zouit_overlaps.

    None если analyze-рана/результата нет (graceful, как get_report_for_chat) — вызывающий
    работает как раньше (только §22-отчёт, без паспорта участка).
    """
    run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
    if run is None:
        return None

    result = run.result
    if not isinstance(result, dict):
        logger.warning(
            "chat: analyze run %s for cad=%s has non-dict result (%s) — no parcel context",
            run.id,
            cad_num,
            type(result).__name__,
        )
        return None

    egrn = _as_dict(result.get("egrn"))
    nspd_zoning = _as_dict(result.get("nspd_zoning"))
    encumbrance = _as_dict(result.get("encumbrance"))
    overlaps = [ov for ov in (result.get("nspd_zouit_overlaps") or []) if isinstance(ov, dict)]

    context: dict[str, Any] = {}

    # ── Кадастровые факты (ЕГРН) — скаляры, без геометрии ────────────────────────
    _put(context, "address", egrn.get("address"))
    _put(context, "area_m2", egrn.get("area_m2"))
    _put(context, "land_category", egrn.get("land_category"))
    _put(context, "permitted_use_text", egrn.get("permitted_use_text"))
    _put(context, "cadastral_value_rub", egrn.get("cadastral_value_rub"))
    _put(context, "parcel_status", egrn.get("parcel_status"))
    _put(context, "ownership_type", egrn.get("ownership_type"))

    # ── Территориальная зона ПЗЗ + лимиты регламента (те же ключи, что §1-рендер) ─
    _put(
        context,
        "zone_code",
        nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index"),
    )
    _put(context, "zone_name", nspd_zoning.get("zone_name"))
    _put(context, "max_far", nspd_zoning.get("max_far"))
    _put(context, "max_floors", nspd_zoning.get("max_floors"))
    _put(context, "max_height_m", nspd_zoning.get("max_height_m"))
    _put(context, "max_building_pct", nspd_zoning.get("max_building_pct"))
    _put(context, "min_parcel_area_m2", nspd_zoning.get("min_parcel_area_m2"))
    _put(context, "regulation_source", nspd_zoning.get("regulation_source"))

    # ── ЗОУИТ-обременения: сводка + список названий (dedup, capped) ───────────────
    has_zouit = encumbrance.get("has_zouit")
    zouit_count = encumbrance.get("zouit_count")
    zouit_names = _zouit_names(overlaps)
    # Противоречие «encumbrance=нет, но пересечения ЕСТЬ» — доверяем фактам НСПД (тот же
    # приём, что _build_zouit): свежий геослой перекрывает устаревшую сводку.
    if not has_zouit and zouit_names:
        has_zouit = True
        if zouit_count in (None, 0):
            zouit_count = len(overlaps)
    _put(context, "has_zouit", has_zouit)
    _put(context, "zouit_count", zouit_count)
    if zouit_names:
        context["zouit_zone_names"] = zouit_names

    return context or None
