"""§11 макро-сценарии: прогон #952 движка под тремя конвертами ключевой ставки.

#984 (954-A, Site Finder v2 / «GG-форсайт» ТЗ §11), EPIC 10 «Сценарный анализ».
Это **СБОРОЧНЫЙ слой над #952**: он НЕ пересобирает §9.x-математику и НЕ строит
своих rate-path вручную per-горизонт. Он берёт ОДИН рычаг — `rate_path` входа
`compute_demand_supply_forecast` (#952) — и прогоняет тот же движок ТРИЖДЫ под
тремя конвертами ключевой ставки, давая по `ScenarioForecast` на сценарий:

  • base         — текущая ключевая ставка, удержанная ПЛОСКО (#952 hold_last_rate).
  • conservative — ставка ВЫШЕ базы (+_CONSERVATIVE_RATE_DELTA_PP п.п.): дороже
                   ипотека → спрос МЯГЧЕ → консервативный (осторожный) прогноз.
  • aggressive   — ставка НИЖЕ базы (−_AGGRESSIVE_RATE_DELTA_PP п.п.): дешевле
                   ипотека → спрос СИЛЬНЕЕ → агрессивный (оптимистичный) прогноз.

ЕДИНСТВЕННОЕ ОТЛИЧИЕ сценариев — это rate_path; всё остальное (сегмент, район,
участок, горизонты) общее. Сам §9.4 demand_normalization внутри #952 превращает
Δ ставки в Δ спроса (через β, учтённый там РОВНО ОДИН РАЗ), поэтому здесь мы лишь
сдвигаем ставку — НИКАКОЙ арифметики над спросом тут нет.

Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистый сдвиг ставки + reuse #952; своего SQL НЕТ).

ADVISORY-СТАТУС: каждый сценарий наследует advisory-статус #952 и его жёсткий
потолок confidence ≤ 'medium' (движок не провалидирован до бэктеста #951). Поэтому
и весь сценарный слой СОВЕТУЮЩИЙ: `advisory` ВСЕГДА True. Цифры — для
explainability/прототипа, не основание для инвест-решения.

КОНВЕРТ ШИРИТСЯ С ГОРИЗОНТОМ (сознательно, документируем): дальние горизонты
неопределённее, поэтому Δ ставки растёт линейно с горизонтом —
delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). При
_HORIZON_WIDEN_PER_YEAR=0.5: на h=12 мес конверт = base_delta × 1.5, на h=24 мес
= base_delta × 2.0 (на каждый год горизонта +50% к base_delta).
Держим ПРОСТО (один линейный множитель) и tunable. Ставка КЛАМПится ≥ 0 (нет
отрицательной ключевой ставки) — на дальнем агрессивном горизонте конверт может
упереть ставку в 0.

Graceful-on-thin-data (дух #952): нет макро / нет базовой ставки → база None →
конверты None на всех горизонтах → #952 деградирует к нейтрали ВНУТРИ себя
(прогнозы с None-полями), но всё равно возвращаем ТРИ сценария. НИКОГДА не crash.
"""

from __future__ import annotations

import logging
import math
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Final, Literal

from sqlalchemy.orm import Session

from app.services.forecasting.demand_supply_forecast import (
    DemandSupplyForecast,
    compute_demand_supply_forecast,
    hold_last_rate,
)
from app.services.forecasting.macro_series import get_monthly_macro
from app.services.forecasting.sales_series import SegmentSpec

logger = logging.getLogger(__name__)

Scenario = Literal["conservative", "base", "aggressive"]

# ── Named-константы (§11 конверт ставки) ──────────────────────────────────────

# Горизонты прогноза по умолчанию (мес) — зеркало #952 _DEFAULT_HORIZONS
# (competitors / future_supply горизонт-сетка): полгода…2 года.
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)

# КОНСЕРВАТИВНЫЙ сдвиг ставки ВВЕРХ относительно базы (п.п.). +2.0 п.п. ≈ один
# крупный цикл ужесточения ДКП (совет директоров двигает ставку шагами 0.5–1.0 п.п.;
# +2.0 п.п. = «ещё пара шоковых повышений» — осторожный, но не апокалиптичный
# сценарий). Ставка выше → ипотека дороже → спрос мягче (§9.4 внутри #952). Tunable.
_CONSERVATIVE_RATE_DELTA_PP: float = 2.0

# АГРЕССИВНЫЙ сдвиг ставки ВНИЗ относительно базы (п.п.). −3.0 п.п. ≈ полноценный
# цикл смягчения ДКП (несимметрично шире консервативного: ЦБ снижает медленнее, чем
# поднимает, поэтому «оптимизм» требует большего хода ставки, чтобы заметно
# разогнать спрос). Ставка ниже → ипотека дешевле → спрос сильнее. Кламп ≥0. Tunable.
_AGGRESSIVE_RATE_DELTA_PP: float = 3.0

# Расширение конверта с горизонтом (доля от base_delta на КАЖДЫЙ год горизонта).
# delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12): дальние горизонты
# неопределённее, поэтому разброс ставки растёт. 0.5 = «+50% к Δ за каждый год»
# (на 24 мес конверт ×2 шире, чем на 12 мес). Простой линейный множитель. Tunable.
_HORIZON_WIDEN_PER_YEAR: float = 0.5

# Минимально допустимая ключевая ставка (%). Отрицательной ключевой ставки не
# бывает — кламп снизу при агрессивном (вниз) сдвиге на дальнем горизонте.
_MIN_RATE_PCT: float = 0.0

# §22 audit-issue #1871 P1: RU-объяснение, почему сценарии conservative/aggressive
# идентичны base. Срабатывает, когда §9.6 rate_sensitivity gate (n≥30 ∧ R²≥0.1 ∧
# slope<0) НЕ пройден на текущих данных ЕКБ — §9.4 demand_normalization возвращает
# coefficient=1.0 на любой rate_path → сценарии схлопываются. Это by-design (защита
# от шумной β), но фронт обязан показать ПОЧЕМУ три колонки совпадают, иначе таблица
# читается как баг. Текст уходит в отчёт через ReportScenarios.scenarios_collapse_reason
# и далее в PDF/Excel-плашку вместо таблицы трёх одинаковых столбцов.
_COLLAPSE_REASON_LOW_BETA: Final[str] = (
    "Чувствительность спроса к ключевой ставке не удалось оценить на коротком "
    "ряде по Екатеринбургу, поэтому консервативный и агрессивный сценарии "
    "совпали с базовым — показываем один базовый сценарий вместо трёх. Три "
    "сценария вернутся, когда накопится более длинная история по ставке."
)

# Допуски сравнения metric'ов сценариев при детекции collapse (math.isclose). Берём
# rel_tol=1e-9 (~машинная точность float — после прогона #952 с одинаковым coefficient
# любые расхождения = только численный шум) + abs_tol=1e-6 (страхует около нуля, где
# rel_tol вырождается). Шире 1e-6 нельзя — рискуем затереть подлинные slight
# расхождения, когда β только-только прошёл gate.
_COLLAPSE_REL_TOL: Final[float] = 1e-9
_COLLAPSE_ABS_TOL: Final[float] = 1e-6


@dataclass(frozen=True)
class ScenarioForecast:
    """Один §11 макро-сценарий: #952 движок под конкретным конвертом ставки.

    Все три сценария (conservative / base / aggressive) отличаются ТОЛЬКО `rate_path`
    (сдвиг ключевой ставки относительно базы); `forecasts` — выход #952 под этим
    путём, по одному DemandSupplyForecast на горизонт. `advisory` ВСЕГДА True —
    сценарий наследует advisory-статус #952 (движок не для production-решений до
    бэктеста #951). rate_path может содержать None-значения (нет базовой ставки —
    graceful: #952 деградирует к нейтрали внутри себя).
    """

    scenario: Scenario
    rate_path: dict[int, float | None]  # {горизонт: ставка сценария}; None = нет базы
    forecasts: list[DemandSupplyForecast]  # выход #952 (по одному на горизонт)
    advisory: bool  # ВСЕГДА True (наследует advisory #952)

    def as_dict(self) -> dict[str, Any]:
        return {
            "scenario": self.scenario,
            "rate_path": {h: _round_or_none(rate, 2) for h, rate in sorted(self.rate_path.items())},
            "forecasts": [f.as_dict() for f in self.forecasts],
            "advisory": self.advisory,
        }


def _round_or_none(value: float | None, digits: int) -> float | None:
    return round(value, digits) if value is not None else None


# ──────────────────────────────────────────────────────────────────────────────
# Pure-логика — без БД, полностью юнит-тестируемо.
# ──────────────────────────────────────────────────────────────────────────────


def _horizon_widen_factor(
    horizon_months: int, *, widen_per_year: float = _HORIZON_WIDEN_PER_YEAR
) -> float:
    """Множитель расширения конверта на горизонте ∈ [1, ∞). PURE.

    1 + widen_per_year × h/12: на 12 мес = 1 + widen_per_year×1; нормируем так, чтобы
    h=12 давал «один год расширения». h ≤ 0 → 1.0 (нет расширения). widen_per_year ≤ 0
    → 1.0 (расширение выключено — плоский конверт по горизонтам). PURE.

    Args:
        horizon_months: горизонт прогноза (мес); ≤0 → 1.0.
        widen_per_year: доля расширения base_delta на каждый год горизонта; ≤0 → 1.0.

    Returns:
        Множитель ≥ 1.0.
    """
    if horizon_months <= 0 or widen_per_year <= 0.0:
        return 1.0
    return 1.0 + widen_per_year * (horizon_months / 12.0)


def _shift_rate(base_rate: float | None, delta_pp: float) -> float | None:
    """Сдвинуть базовую ставку на delta_pp п.п., кламп ≥ _MIN_RATE_PCT. PURE.

    base None → None (нет базы — нечего сдвигать; #952 деградирует к нейтрали).
    delta_pp > 0 = вверх (консервативный), < 0 = вниз (агрессивный). Результат
    клампится снизу к _MIN_RATE_PCT (отрицательной ключевой ставки не бывает). PURE.

    Args:
        base_rate: базовая ключевая ставка (%); None → None.
        delta_pp: сдвиг в п.п. (знаковый).

    Returns:
        Сдвинутая ставка (≥ _MIN_RATE_PCT) или None.
    """
    if base_rate is None:
        return None
    return max(_MIN_RATE_PCT, base_rate + delta_pp)


def build_rate_envelopes(
    base_rate: float | None,
    horizons: Sequence[int],
    *,
    conservative_delta_pp: float = _CONSERVATIVE_RATE_DELTA_PP,
    aggressive_delta_pp: float = _AGGRESSIVE_RATE_DELTA_PP,
) -> dict[str, dict[int, float | None]]:
    """Построить ТРИ именованных rate-path (§11 конверт ставки) из базовой ставки. PURE.

    Конверт по сценариям (на КАЖДЫЙ горизонт h):
      • base         = base_rate (плоско — ставка не меняется).
      • conservative = base_rate + conservative_delta_pp × widen(h)  (ставка ВЫШЕ).
      • aggressive   = base_rate − aggressive_delta_pp × widen(h)    (ставка НИЖЕ).
    где widen(h) = _horizon_widen_factor(h) расширяет конверт с горизонтом (дальние
    горизонты неопределённее). Все ставки клампятся ≥ _MIN_RATE_PCT.

    base_rate None → ВСЕ пути None на всех горизонтах (graceful: #952 деградирует к
    нейтрали внутри себя; мы всё равно отдаём три именованных конверта). PURE.

    ИНВАРИАНТ (при заданной базе): conservative[h] ≥ base[h] ≥ aggressive[h] на любом
    горизонте (равенство возможно лишь у aggressive при упоре в кламп _MIN_RATE_PCT).

    Args:
        base_rate: базовая ключевая ставка (%); None → все пути None.
        horizons: горизонты (мес), под которые строим пути.
        conservative_delta_pp: сдвиг вверх для conservative (по умолч.
            _CONSERVATIVE_RATE_DELTA_PP).
        aggressive_delta_pp: сдвиг вниз для aggressive (по умолч.
            _AGGRESSIVE_RATE_DELTA_PP).

    Returns:
        {"conservative"|"base"|"aggressive": {горизонт: ставка|None}}.
    """
    conservative: dict[int, float | None] = {}
    base: dict[int, float | None] = {}
    aggressive: dict[int, float | None] = {}
    for h in horizons:
        widen = _horizon_widen_factor(h)
        base[h] = _shift_rate(base_rate, 0.0)
        conservative[h] = _shift_rate(base_rate, +conservative_delta_pp * widen)
        aggressive[h] = _shift_rate(base_rate, -aggressive_delta_pp * widen)
    return {"conservative": conservative, "base": base, "aggressive": aggressive}


# ──────────────────────────────────────────────────────────────────────────────
# Collapse-детектор (§22 audit-issue #1871 P1). PURE, без БД, юнит-тестируется.
# ──────────────────────────────────────────────────────────────────────────────


def _metrics_equal(a: float | None, b: float | None) -> bool:
    """Сравнить две метрики (projected_demand_units / deficit_index) на «равенство». PURE.

    Оба None → True (одинаково «нет данных»). Один None, другой число → False (это
    подлинное расхождение — нельзя считать схлопнутым). Оба числа → math.isclose с
    _COLLAPSE_REL_TOL / _COLLAPSE_ABS_TOL (после §9.4 coefficient=1.0 любые расхождения
    = численный шум; шире — рискуем замаскировать настоящие slight расхождения).
    """
    if a is None and b is None:
        return True
    if a is None or b is None:
        return False
    return math.isclose(a, b, rel_tol=_COLLAPSE_REL_TOL, abs_tol=_COLLAPSE_ABS_TOL)


def _detect_collapsed(out: list[ScenarioForecast]) -> bool:
    """§22 audit-issue #1871 P1: схлопнулись ли conservative/aggressive в base? PURE.

    «Схлопнулось» = на КАЖДОМ пересекающемся горизонте `projected_demand_units` И
    `deficit_index` conservative совпадают с aggressive (с допуском `_metrics_equal`).
    Это маркер «§9.4 demand_normalization вернул coefficient=1.0» — что бывает, когда
    §9.6 rate_sensitivity gate не пройден и β-ом нельзя двигать спрос. Тогда три
    сценария идентичны base, и фронт обязан показать ПОЧЕМУ.

    Защитная семантика:
      • Любой из сценариев отсутствует → False (без полной пары вердикта нет).
      • Пустой `forecasts` у одного из сценариев → False (нет данных → нет вердикта
        «схлопнулось»; иначе пустой отчёт ложно помечался бы как collapsed).
      • Хоть один горизонт даёт расхождение по demand ИЛИ deficit → False
        (защита от ЧАСТИЧНОГО collapse: если хотя бы один metric разошёлся, β
        уже работает).
      • Только когда ОБА metric'а совпадают на ВСЕХ пересекающихся горизонтах → True.

    NB: base сравнивать не обязательно — §9.4 при failed gate возвращает 1.0 для
    ВСЕХ rate_path, так что conservative ≡ base ≡ aggressive автоматически.
    Проверяем только пару conservative/aggressive (крайние конверты — самый сильный
    разнос ставки, поэтому самое чувствительное место для β).

    Args:
        out: три ScenarioForecast (conservative/base/aggressive) — выход
            `compute_scenarios`.

    Returns:
        True, если crash β-gate схлопнул сценарии; False иначе.
    """
    by_name = {s.scenario: s for s in out}
    conservative = by_name.get("conservative")
    aggressive = by_name.get("aggressive")
    if conservative is None or aggressive is None:
        return False
    if not conservative.forecasts or not aggressive.forecasts:
        return False

    cons_by_h = {f.horizon_months: f for f in conservative.forecasts}
    aggr_by_h = {f.horizon_months: f for f in aggressive.forecasts}
    shared = set(cons_by_h.keys()) & set(aggr_by_h.keys())
    if not shared:
        return False

    for h in shared:
        cons_f = cons_by_h[h]
        aggr_f = aggr_by_h[h]
        if not _metrics_equal(cons_f.projected_demand_units, aggr_f.projected_demand_units):
            return False
        if not _metrics_equal(cons_f.deficit_index, aggr_f.deficit_index):
            return False
    return True


# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────


def compute_scenarios(
    db: Session,
    *,
    spec: SegmentSpec,
    district: str | None,
    cad_num: str,
    horizons: Sequence[int] = _DEFAULT_HORIZONS,
) -> tuple[list[ScenarioForecast], bool, str | None]:
    """§11 три макро-сценария: прогон #952 движка под тремя конвертами ставки.

    ADVISORY (каждый сценарий наследует advisory-статус #952, см. module docstring) —
    НЕ подключать в production-эндпоинт. СБОРКА над #952, НЕ пересчёт §9.x.

    Поток:
      1. base_rate = последняя известная key_rate (get_monthly_macro → hold_last_rate),
         удержанная плоско. Нет макро / нет точки key_rate → base_rate None.
      2. build_rate_envelopes(base_rate, horizons) → три именованных rate-path.
      3. Для КАЖДОГО сценария прогон compute_demand_supply_forecast ОДИН раз с его
         rate_path → list[DemandSupplyForecast] (по одному на горизонт).
      4. Обернуть в ScenarioForecast (advisory=True).

    ⚠️ Сценарии отличаются ТОЛЬКО rate_path. Δ ставки → Δ спроса делает §9.4 внутри
    #952 (β учтён там РОВНО ОДИН РАЗ); здесь НИКАКОЙ арифметики над спросом нет.

    Graceful: нет макро / нет базовой ставки → конверты None → #952 отдаёт прогнозы
    с None-полями, но ТРИ сценария возвращаются всегда. НИКОГДА не crash. advisory
    всегда True (наследует #952 cap ≤ 'medium').

    COLLAPSE-ДЕТЕКЦИЯ (§22 audit-issue #1871 P1): когда §9.6 rate_sensitivity gate
    не пройден на текущих данных ЕКБ, §9.4 demand_normalization возвращает
    coefficient=1.0 для ЛЮБОГО rate_path → conservative/aggressive prognosis
    идентичны base. Это by-design, но в payload отчёта нужен флаг, чтобы фронт/
    экспортёры показали ПОЧЕМУ три колонки совпадают (иначе таблица читается как баг).
    Возвращаем кортеж `(scenarios, collapsed, collapse_reason)`, где
    `collapse_reason = _COLLAPSE_REASON_LOW_BETA` ПРИ collapsed=True, иначе None.

    Args:
        db: SQLAlchemy sync Session.
        spec: целевой сегмент рынка (любой subset осей) — общий для всех сценариев.
        district: район для §9.3 supply + §9.2 metrics (None → ЕКБ-wide).
        cad_num: кадастровый номер участка — вход #952 (§9.7 конкуренты).
        horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS).

    Returns:
        Кортеж `(scenarios, collapsed, collapse_reason)`:
          • scenarios — список из ТРЁХ ScenarioForecast (conservative/base/aggressive);
          • collapsed — True, если §22 audit-issue #1871 P1 collapse-детектор поймал
            идентичность conservative/aggressive (failed β-gate);
          • collapse_reason — `_COLLAPSE_REASON_LOW_BETA` при collapsed=True; None иначе.
    """
    horizon_list = list(horizons)

    # ── База: последняя известная key_rate, удержанная плоско (graceful → None) ──
    macro = get_monthly_macro(db)
    held = hold_last_rate(macro, horizon_list)
    base_rate = _first_non_none(held)

    # ── Три именованных конверта ставки (§11) ──────────────────────────────────
    envelopes = build_rate_envelopes(base_rate, horizon_list)

    out: list[ScenarioForecast] = []
    for scenario in ("conservative", "base", "aggressive"):
        rate_path = envelopes[scenario]
        forecasts = compute_demand_supply_forecast(
            db,
            spec=spec,
            district=district,
            cad_num=cad_num,
            horizons=horizon_list,
            rate_path=_drop_none_rates(rate_path),
        )
        out.append(
            ScenarioForecast(
                scenario=scenario,  # type: ignore[arg-type]
                rate_path=rate_path,
                forecasts=forecasts,
                advisory=True,
            )
        )

    collapsed = _detect_collapsed(out)
    collapse_reason = _COLLAPSE_REASON_LOW_BETA if collapsed else None

    logger.info(
        "scenarios: district=%s cad_num=%s base_rate=%s horizons=%s scenarios=%d "
        "collapsed=%s (ADVISORY)",
        district,
        cad_num,
        _round_or_none(base_rate, 2),
        horizon_list,
        len(out),
        collapsed,
    )
    return out, collapsed, collapse_reason


def _first_non_none(rate_path: dict[int, float | None]) -> float | None:
    """Первая НЕ-None ставка из path (hold_last_rate отдаёт одно значение на всё). PURE.

    hold_last_rate присваивает одну и ту же last key_rate всем горизонтам, поэтому
    любая не-None — это и есть база. Все None (нет макро) → None (graceful).
    """
    for rate in rate_path.values():
        if rate is not None:
            return rate
    return None


def _drop_none_rates(rate_path: dict[int, float | None]) -> dict[int, float] | None:
    """Свести сценарный path к {горизонт: ставка} для #952 (None-значения убрать). PURE.

    #952 ждёт `rate_path: dict[int, float] | None`; None-значения (нет базы) туда не
    кладём — пусть #952 сам деградирует к hold_last_rate / нейтрали для этих
    горизонтов. Path целиком из None → None (передаём None → #952 берёт свой дефолт).
    """
    cleaned = {h: rate for h, rate in rate_path.items() if rate is not None}
    return cleaned or None
