"""§13 SiteFinderReport — структурный контейнер итогового советующего отчёта.

#987 (955-A1, Site Finder v2 / «GG-форсайт» ТЗ §13), EPIC 11 «Отчёт». Это ЧИСТЫЙ
СТРУКТУРНЫЙ объект — frozen, сериализуемый dataclass, фиксирующий ВОСЕМЬ секций §13
отчёта. Он НИЧЕГО не считает и не ходит в БД: его НАПОЛНЯЕТ сборщик #988 (assembler)
и ПОТРЕБЛЯЮТ экспортёры #989 (PDF/Excel/JSON) + чат #957. Здесь — ТОЛЬКО объект +
сериализация (НЕ сборщик, НЕ движок confidence #990, НЕ изменения экспортёров).

ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/вычислений/print — это
зеркало advisory-духа всего форсайт-стека (#980…#986): сам отчёт лишь СОБИРАЕТ уже
посчитанные advisory-выводы, поэтому `advisory` ВСЕГДА True.

СТРУКТУРА (решение — вложенные frozen-dataclass'ы, по одному на секцию §13):
зеркалит house-style (каждый сосед — frozen dataclass + `as_dict()`), даёт типизированные
слоты (частичный отчёт всё равно типобезопасен) и явный JSON-safe `as_dict()`. КАЖДАЯ
секция МОЖЕТ ДЕРЖАТЬ выводы под-сервисов как ПЛОСКИЕ dict'ы (их собственные `as_dict()`
уже JSON-safe — `_round_or_none`/`.isoformat()` сделаны выше по стеку): отчёт НЕ
пере-деривирует внутренности под-объектов, а оборачивает их в типизированные конверты.

ВОСЕМЬ СЕКЦИЙ §13 (каждая — отдельный nested frozen dataclass):
  1. exec_summary  (ReportExecSummary)  — вердикт-заголовок + ключевые числа + общая
     уверенность («что строить здесь» одним абзацем).
  2. market_now    (ReportMarketNow)    — текущий рынок: абсорбция/цены/конкуренты/
     слои предложения (выводы #949/#950 — market_metrics/competitors/supply_layers).
  3. future_market (ReportFutureMarket) — прогноз: спрос/предложение по горизонтам,
     дефицит/затоварка, будущие конкуренты, сводка сценариев (#952/#954).
  4. product_tz    (ReportProductTz)    — рекомендация продукта: класс, квартирография
     (mix), коммерция, USP + §16-причины (#953 recommendation).
  5. scenarios     (ReportScenarios)    — conservative/base/aggressive сводка (#954/#984).
  6. scoring       (ReportScoring)      — 10 продуктовых скоров + 6 спец-индексов +
     overall (#954/#985/#986 — product_scoring/special_indices).
  7. confidence    (ReportConfidence)   — отчётная уверенность (High/Med/Low) + rationale.
     ПЛЕЙСХОЛДЕР: настоящий §15-движок — #990; здесь ТИПИЗИРОВАННЫЙ слот (дефолт пуст).
  8. meta          (ReportMeta)         — cad_num/district/segment/horizons,
     generated_advisory=True, schema_version (named const _SCHEMA_VERSION).

GRACEFUL (дух всего форсайт-стека): КАЖДОЕ поле Optional/с дефолтом — частично
собранный (или вовсе пустой) отчёт ВАЛИДЕН: тонкий анализ всё равно даёт отчёт.
`as_dict()` всегда отдаёт ВОСЕМЬ секций (стабильный контракт для экспортёров/чата),
полностью JSON-сериализуемый (даты→iso, без утечки dataclass'ов/raw-объектов).
"""

from __future__ import annotations

import datetime as dt
from dataclasses import dataclass, field
from typing import Any, Literal

# Версия схемы отчёта — named const (бамп при несовместимом изменении формы `as_dict`).
# Экспортёры/чат/JSON-экспорт сверяются с ней. SemVer-строка ("major.minor").
_SCHEMA_VERSION: str = "1.0"

# Весь отчёт советующий (зеркало advisory-cap форсайт-стека #980…#986): он лишь
# СОБИРАЕТ уже-advisory выводы — не основание для инвест-решения.
_ADVISORY: bool = True

# Допустимые отчётные уровни уверенности (§15). Реальный движок — #990; здесь только
# типизированный слот, дефолт None (ещё не оценено).
ReportConfidenceLevel = Literal["high", "medium", "low"]


def _round_or_none(value: float | None, digits: int) -> float | None:
    """Округлить до digits знаков или вернуть None (зеркало соседей). PURE."""
    return round(value, digits) if value is not None else None


def _iso_or_none(value: dt.date | dt.datetime | str | None) -> str | None:
    """Привести дату/время к ISO-строке для JSON (None passthrough). PURE.

    `date`/`datetime` → `.isoformat()`; уже-строка → как есть (сборщик мог положить
    готовую ISO-строку); None → None. Так `as_dict()` гарантированно JSON-safe без
    утечки объектов `date` в выдачу.
    """
    if value is None:
        return None
    if isinstance(value, (dt.date, dt.datetime)):
        return value.isoformat()
    return value


# ──────────────────────────────────────────────────────────────────────────────
# Секции §13 — вложенные frozen-dataclass'ы. Каждая держит выводы под-сервисов как
# ПЛОСКИЕ JSON-safe dict'ы (их as_dict() уже сериализуемы) + лёгкие скаляры-заголовки.
# Все поля Optional/с дефолтом → частичная/пустая секция валидна (graceful).
# ──────────────────────────────────────────────────────────────────────────────


@dataclass(frozen=True)
class ReportExecSummary:
    """§13.1 exec_summary — вердикт-заголовок + ключевые числа + общая уверенность.

    «Что строить здесь» одним абзацем (`headline`/`verdict`), опорные числа в плоском
    `key_numbers` (JSON-safe), `overall_confidence` — отчётный уровень (High/Med/Low).
    Всё опционально: тонкий анализ → пустой exec_summary (graceful).
    """

    headline: str | None = None  # 1-фразовый вердикт «что строить здесь»
    verdict: str | None = None  # развёрнутый абзац-резюме
    key_numbers: dict[str, Any] = field(default_factory=dict)  # плоские опорные числа
    overall_confidence: ReportConfidenceLevel | None = None  # отчётная уверенность

    def as_dict(self) -> dict[str, Any]:
        return {
            "headline": self.headline,
            "verdict": self.verdict,
            "key_numbers": dict(self.key_numbers),
            "overall_confidence": self.overall_confidence,
        }


@dataclass(frozen=True)
class ReportMarketNow:
    """§13.2 market_now — текущий рынок (выводы #949/#950).

    Держит уже-посчитанные `as_dict()` под-сервисов как плоские JSON-safe dict'ы:
    `market_metrics` (§9.2 абсорбция/цены), `competitors` (§9.1), `supply_layers`
    (§9.3 слои предложения). Отчёт их НЕ пере-деривирует. Всё опционально (graceful).
    """

    market_metrics: dict[str, Any] | None = None  # MarketMetrics.as_dict() (#950)
    competitors: list[dict[str, Any]] = field(default_factory=list)  # Competitor.as_dict() (#949)
    supply_layers: dict[str, Any] | None = None  # supply_layers вывод (#950, §9.3)
    summary: str | None = None  # короткий RU-текст про текущий рынок

    def as_dict(self) -> dict[str, Any]:
        return {
            "market_metrics": self.market_metrics,
            "competitors": [dict(c) for c in self.competitors],
            "supply_layers": self.supply_layers,
            "summary": self.summary,
        }


@dataclass(frozen=True)
class ReportFutureMarket:
    """§13.3 future_market — прогноз (выводы #952/#954).

    `forecasts_by_horizon` — список DemandSupplyForecast.as_dict() (по горизонтам:
    спрос/предложение, дефицит/затоварка). `future_competitors` — будущие конкуренты,
    `future_supply` — давление будущего предложения (FutureSupplyPressure.as_dict()),
    `scenarios_summary` — короткая сводка сценариев. Всё опционально (graceful).
    """

    forecasts_by_horizon: list[dict[str, Any]] = field(default_factory=list)  # #952 по горизонтам
    future_supply: dict[str, Any] | None = None  # FutureSupplyPressure.as_dict() (§9.3)
    future_competitors: list[dict[str, Any]] = field(default_factory=list)  # будущие конкуренты
    scenarios_summary: dict[str, Any] | None = None  # краткая сводка сценариев (#954)
    summary: str | None = None  # короткий RU-текст про будущий рынок

    def as_dict(self) -> dict[str, Any]:
        return {
            "forecasts_by_horizon": [dict(f) for f in self.forecasts_by_horizon],
            "future_supply": self.future_supply,
            "future_competitors": [dict(c) for c in self.future_competitors],
            "scenarios_summary": self.scenarios_summary,
            "summary": self.summary,
        }


@dataclass(frozen=True)
class ReportProductTz:
    """§13.4 product_tz — рекомендация продукта (выводы #953).

    `obj_class` — рекомендованный класс, `mix` — квартирография (доли форматов),
    `commercial` — коммерческий сигнал §10.4, `usp` — USP-из-дефицитов §10.5,
    `reasons` — §16-причины (отвергнутые альтернативы / what-would-change). Держит
    под-выводы #953 как плоские JSON-safe dict'ы. Всё опционально (graceful).
    """

    obj_class: str | None = None  # рекомендованный класс (§10.2)
    mix: list[dict[str, Any]] = field(default_factory=list)  # квартирография (доли форматов)
    commercial: dict[str, Any] | None = None  # коммерческий сигнал (§10.4)
    usp: list[dict[str, Any]] = field(default_factory=list)  # USP-из-дефицитов (§10.5)
    reasons: list[dict[str, Any]] = field(default_factory=list)  # §16-причины
    summary: str | None = None  # короткий RU-текст про рекомендованный продукт

    def as_dict(self) -> dict[str, Any]:
        return {
            "obj_class": self.obj_class,
            "mix": [dict(m) for m in self.mix],
            "commercial": self.commercial,
            "usp": [dict(u) for u in self.usp],
            "reasons": [dict(r) for r in self.reasons],
            "summary": self.summary,
        }


@dataclass(frozen=True)
class ReportScenarios:
    """§13.5 scenarios — сводка conservative/base/aggressive (выводы #954/#984).

    `by_scenario` — карта {scenario: ScenarioForecast.as_dict()} (или иной сводный
    dict на сценарий). Держит advisory-сценарии #984 как плоские JSON-safe dict'ы.
    Пустая карта → секция без сценариев (graceful). advisory наследуется (cap medium).

    COLLAPSE (§22 audit-issue #1871 P1): когда §9.6 rate_sensitivity gate не
    пройден, §9.4 demand_normalization возвращает coefficient=1.0 для всех трёх
    конвертов → conservative/aggressive идентичны base. Это by-design, но без явного
    сигнала frontend/экспортёрам таблица «три одинаковых столбца» читается как баг.
    Поля `scenarios_collapsed`/`scenarios_collapse_reason` пробрасывают вердикт
    `compute_scenarios` (#984): True+RU-причина → плашка-объяснение вместо таблицы;
    False+None → штатная таблица сценариев. Дефолты False/None — обратная
    совместимость для частичных отчётов и прежних путей сборки. as_dict() отдаёт
    оба ключа ВСЕГДА (стабильная схема для фронта).
    """

    by_scenario: dict[str, Any] = field(default_factory=dict)  # {scenario: сводка/as_dict}
    summary: str | None = None  # короткий RU-текст про разброс сценариев
    scenarios_collapsed: bool = False  # §22 P1: True ⇒ три сценария идентичны (failed β-gate)
    scenarios_collapse_reason: str | None = None  # RU-объяснение причины collapse (или None)

    def as_dict(self) -> dict[str, Any]:
        return {
            "by_scenario": dict(self.by_scenario),
            "summary": self.summary,
            "scenarios_collapsed": self.scenarios_collapsed,
            "scenarios_collapse_reason": self.scenarios_collapse_reason,
        }


@dataclass(frozen=True)
class ReportScoring:
    """§13.6 scoring — 10 продуктовых скоров + 6 спец-индексов + overall (#954/#985/#986).

    `product_scores` — ProductScoreCard.as_dict() (#985, 10 скоров + overall),
    `special_indices` — SpecialIndices.as_dict() (#986, 6 индексов), `overall` —
    итоговый продуктовый скор ∈ [0,1] (дубль из карты для удобства экспортёров).
    Держит карты как плоские JSON-safe dict'ы. Всё опционально (graceful).
    """

    product_scores: dict[str, Any] | None = None  # ProductScoreCard.as_dict() (#985)
    special_indices: dict[str, Any] | None = None  # SpecialIndices.as_dict() (#986)
    overall: float | None = None  # итоговый продуктовый скор ∈ [0,1] (дубль, удобство)

    def as_dict(self) -> dict[str, Any]:
        return {
            "product_scores": self.product_scores,
            "special_indices": self.special_indices,
            "overall": _round_or_none(self.overall, 3),
        }


@dataclass(frozen=True)
class ReportConfidence:
    """§13.7 confidence — отчётная уверенность (High/Med/Low) + rationale. ПЛЕЙСХОЛДЕР.

    Настоящий §15-движок уверенности — #990; здесь ТИПИЗИРОВАННЫЙ СЛОТ под него: `level`
    (High/Med/Low) + RU `rationale` + плоский `factors` (что тянуло уверенность). Дефолт
    пуст (уровень ещё не оценён) → секция валидна и до подключения #990 (graceful).
    """

    level: ReportConfidenceLevel | None = None  # High/Med/Low (None = ещё не оценено)
    rationale: str | None = None  # RU-обоснование уровня
    factors: dict[str, Any] = field(default_factory=dict)  # вклады/флаги уверенности (#990)

    def as_dict(self) -> dict[str, Any]:
        return {
            "level": self.level,
            "rationale": self.rationale,
            "factors": dict(self.factors),
        }


@dataclass(frozen=True)
class ReportMeta:
    """§13.8 meta — контекст отчёта (cad_num/district/segment/horizons) + флаги.

    `generated_advisory` ВСЕГДА True (отчёт советующий), `schema_version` —
    _SCHEMA_VERSION (контракт формы для экспортёров/чата), `generated_at` нормализуется
    в ISO-строку в `as_dict()` (JSON-safe). Всё опционально (graceful).
    """

    cad_num: str | None = None  # кадастровый номер участка
    district: str | None = None  # район
    segment: dict[str, Any] = field(default_factory=dict)  # целевой сегмент (оси §9.x)
    horizons: list[int] = field(default_factory=list)  # горизонты прогноза (мес)
    generated_at: dt.date | dt.datetime | str | None = None  # момент сборки (#988)
    generated_advisory: bool = _ADVISORY  # ВСЕГДА True
    schema_version: str = _SCHEMA_VERSION  # версия формы as_dict()

    def as_dict(self) -> dict[str, Any]:
        return {
            "cad_num": self.cad_num,
            "district": self.district,
            "segment": dict(self.segment),
            "horizons": list(self.horizons),
            "generated_at": _iso_or_none(self.generated_at),
            "generated_advisory": self.generated_advisory,
            "schema_version": self.schema_version,
        }


# ──────────────────────────────────────────────────────────────────────────────
# Корневой контейнер §13 — собирает восемь секций. PURE-контейнер: НЕ считает, НЕ
# ходит в БД, НЕ зовёт сервисы (это #988 assembler). as_dict() — JSON-safe, всегда
# отдаёт восемь секций (стабильный контракт экспортёров #989 / чата #957).
# ──────────────────────────────────────────────────────────────────────────────


@dataclass(frozen=True)
class SiteFinderReport:
    """§13 итоговый советующий отчёт Site Finder v2 — frozen структурный контейнер.

    ВОСЕМЬ секций (exec_summary / market_now / future_market / product_tz / scenarios /
    scoring / confidence / meta), каждая — вложенный frozen-dataclass с дефолтом, так
    что частично-собранный (или пустой) отчёт ВАЛИДЕН (graceful: тонкий анализ всё равно
    даёт отчёт). `advisory` ВСЕГДА True — отчёт собирает advisory-выводы форсайт-стека,
    НЕ основание для инвест-решения.

    PURE-контейнер: НИКАКИХ БД/вычислений/сервис-вызовов — НАПОЛНЯЕТ его сборщик #988,
    ПОТРЕБЛЯЮТ экспортёры #989 + чат #957. `as_dict()` полностью JSON-сериализуем
    (даты→iso, без утечки dataclass'ов) и всегда содержит ВОСЕМЬ секций + advisory +
    schema_version — стабильный контракт для экспортёров/JSON-экспорта/чата.
    """

    exec_summary: ReportExecSummary = field(default_factory=ReportExecSummary)
    market_now: ReportMarketNow = field(default_factory=ReportMarketNow)
    future_market: ReportFutureMarket = field(default_factory=ReportFutureMarket)
    product_tz: ReportProductTz = field(default_factory=ReportProductTz)
    scenarios: ReportScenarios = field(default_factory=ReportScenarios)
    scoring: ReportScoring = field(default_factory=ReportScoring)
    confidence: ReportConfidence = field(default_factory=ReportConfidence)
    meta: ReportMeta = field(default_factory=ReportMeta)
    advisory: bool = _ADVISORY  # ВСЕГДА True

    def as_dict(self) -> dict[str, Any]:
        """Полностью JSON-сериализуемое представление отчёта (стабильный контракт).

        Всегда отдаёт ВОСЕМЬ секций + `advisory` + `schema_version` (дубль из meta на
        верхнем уровне — экспортёрам/чату удобно читать версию без захода в meta).
        Внутри секций — только JSON-native типы (даты→iso через `_iso_or_none`, числа
        через `_round_or_none`, под-сервис-dict'ы уже JSON-safe). НИКАКОЙ утечки
        dataclass'ов/raw-объектов.
        """
        return {
            "schema_version": self.meta.schema_version,
            "advisory": self.advisory,
            "exec_summary": self.exec_summary.as_dict(),
            "market_now": self.market_now.as_dict(),
            "future_market": self.future_market.as_dict(),
            "product_tz": self.product_tz.as_dict(),
            "scenarios": self.scenarios.as_dict(),
            "scoring": self.scoring.as_dict(),
            "confidence": self.confidence.as_dict(),
            "meta": self.meta.as_dict(),
        }
