"""Unit-тесты §9.4 коэффициента нормализации спроса (#951f, ADVISORY).

Чистые тесты — БЕЗ живой БД (чистая математика + мок PR2/PR3):
  • normalization_factor — pure clamp(exp(β·Δ)): β<0 & future>window → дисконт <1;
    режимы совпали (future==window) → 1.0; β None / β=0 → 1.0; клэмп на MIN при
    огромном разрыве; β>0-край → аплифт, но клэмп на MAX; rate_window_avg None → 1.0.
  • _window_avg_rate — среднее НЕпустых key_rate; все None → None.
  • compute_demand_normalization (мок compute_district_rate_regression +
    get_monthly_macro): надёжный β + более жёсткое будущее → coef<1 + applied=True;
    fallback-фит (β=None/low) → 1.0 + applied=False; пустой макро-ряд → 1.0/low;
    confidence наследуется (не выше §9.6); future<window → аплифт >1; знак Δ.

§9.6-источник β — теперь Almon-ADL `compute_district_rate_regression` (#978),
обёрнутый адаптером `compute_rate_regime_sensitivity`. Тесты оркестратора патчат
РЕАЛЬНЫЙ Almon-вход настоящим DistributedLagFit и прогоняют адаптер целиком (а не
мокают уже-неиспользуемый single-lag compute_rate_sensitivity) → проверяем НОВЫЙ
путь: 'regression'-фит → confidence='medium', β=coef; 'fallback'-фит → β=None,
confidence='low' → нейтраль.

ЗНАКОВАЯ ЛОГИКА (тестируем явно): β<0, future>window → β·(+)<0 → exp<1 → ДИСКОНТ
(суть §9.4 — не тащить бумный темп в более жёсткий режим). ЧЕСТНОСТЬ: applied=False,
когда β ненадёжен/недоступен (нейтраль 1.0 без выдуманного дисконта).
"""

from __future__ import annotations

import datetime as dt
import math
import os
from unittest.mock import MagicMock, patch

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from app.services.forecasting.demand_normalization import (
    _NORM_MAX,
    _NORM_MIN,
    _NORM_NEUTRAL,
    DemandNormalization,
    _window_avg_rate,
    compute_demand_normalization,
    normalization_factor,
)
from app.services.forecasting.macro_series import MonthlyMacro
from app.services.forecasting.regression import DistributedLagFit
from app.services.forecasting.sales_series import SegmentSpec

# Адаптер §9.6 (compute_rate_regime_sensitivity) импортирован в namespace
# demand_normalization; он зовёт compute_district_rate_regression в namespace
# regression. Патчим РЕАЛЬНЫЙ Almon-вход и прогоняем адаптер целиком (а не мокаем
# уже-неиспользуемый single-lag compute_rate_sensitivity — это был бы false-green).
_REG = "app.services.forecasting.regression.compute_district_rate_regression"
_MACRO = "app.services.forecasting.demand_normalization.get_monthly_macro"


def _months(n: int, *, end: dt.date | None = None) -> list[dt.date]:
    """n подряд идущих 1-х чисел месяцев, заканчивая end (по умолчанию 2023-12)."""
    end = end or dt.date(2023, 12, 1)
    out: list[dt.date] = []
    y, m = end.year, end.month
    for _ in range(n):
        out.append(dt.date(y, m, 1))
        m -= 1
        if m == 0:
            m = 12
            y -= 1
    return list(reversed(out))


def _macro(months: list[dt.date], rates: list[float | None]) -> list[MonthlyMacro]:
    """MonthlyMacro с заданными key_rate (прочие поля None)."""
    out: list[MonthlyMacro] = []
    for month, kr in zip(months, rates, strict=True):
        out.append(
            MonthlyMacro(
                month=month,
                key_rate=kr,
                mortgage_rate_weighted=None,
                mortgage_issued_count=None,
                mortgage_issued_volume=None,
                mortgage_debt=None,
                mortgage_overdue=None,
            )
        )
    return out


def _reg_fit(
    *,
    coef: float | None,
    source: str = "regression",
    segment: dict[str, str | None] | None = None,
) -> DistributedLagFit:
    """РЕАЛЬНЫЙ Almon DistributedLagFit-вход §9.6 (#978), который адаптер мапит в β.

    'regression'-source → адаптер выдаёт confidence='medium', beta=coef; 'fallback'
    → beta=None/confidence='low' (coef/x_pct=None — контракт build_fit_result). Это
    НАСТОЯЩИЙ контракт DistributedLagFit→адаптер, не hand-faked attribute bag.
    """
    gated = source == "regression"
    return DistributedLagFit(
        segment=segment or {"district": "X", "obj_class": None},
        best_lag_months=3 if gated else None,
        coef=coef if gated else None,
        x_pct=None if (coef is None or not gated) else 100.0 * (math.exp(coef) - 1.0),
        r2=0.5 if gated else 0.05,
        n=40 if gated else 12,
        per_lag_coef=None,
        hac_se=None,
        hac_bandwidth=None,
        almon_degree=2,
        source=source,
        phrase="…",
    )


# ── pure: normalization_factor ────────────────────────────────────────────────


class TestNormalizationFactor:
    def test_higher_future_rate_discounts(self) -> None:
        # β<0, future(20) > window(8) → β·(+12)<0 → exp<1 → ДИСКОНТ (суть §9.4).
        v = normalization_factor(-0.1, 20.0, 8.0)
        assert v < _NORM_NEUTRAL
        # Сверяем с формулой (до клэмпа): exp(-0.1·12)=exp(-1.2)≈0.3012 → клэмп MIN.
        assert v == _NORM_MIN  # exp(-1.2)=0.301 < 0.5 → срезано до пола

    def test_modest_higher_future_discounts_within_band(self) -> None:
        # Умеренный разрыв: future 10 vs window 8 → exp(-0.1·2)=exp(-0.2)=0.8187.
        v = normalization_factor(-0.1, 10.0, 8.0)
        assert math.isclose(v, math.exp(-0.2), rel_tol=1e-9)
        assert _NORM_MIN < v < _NORM_NEUTRAL

    def test_equal_regimes_is_neutral_one(self) -> None:
        # future == window → Δ=0 → exp(0)=1.0 (режимы совпали, темп не трогаем).
        assert normalization_factor(-0.1, 8.0, 8.0) == 1.0

    def test_beta_none_is_neutral_one(self) -> None:
        assert normalization_factor(None, 20.0, 8.0) == _NORM_NEUTRAL

    def test_beta_zero_is_neutral_one(self) -> None:
        # β=0 (нет чувствительности) → exp(0)=1.0 при любом Δ.
        assert normalization_factor(0.0, 20.0, 8.0) == _NORM_NEUTRAL

    def test_window_avg_none_is_neutral_one(self) -> None:
        assert normalization_factor(-0.1, 20.0, None) == _NORM_NEUTRAL

    def test_clamped_at_min_on_huge_gap(self) -> None:
        # Огромный разрыв вверх → exp уезжает к 0 → клэмп на _NORM_MIN.
        assert normalization_factor(-0.5, 30.0, 5.0) == _NORM_MIN

    def test_lower_future_rate_uplifts(self) -> None:
        # β<0, future(5) < window(12) → β·(−7)>0 → exp>1 → АПЛИФТ (будущее мягче окна).
        v = normalization_factor(-0.02, 5.0, 12.0)
        assert v > _NORM_NEUTRAL
        assert math.isclose(v, math.exp(-0.02 * (5.0 - 12.0)), rel_tol=1e-9)

    def test_uplift_clamped_at_max(self) -> None:
        # Сильный аплифт упирается в _NORM_MAX.
        assert normalization_factor(-0.2, 2.0, 20.0) == _NORM_MAX

    def test_positive_beta_edge_uplifts_then_clamps(self) -> None:
        # Аномальный β>0 (продажи якобы растут со ставкой) + future>window →
        # β·(+)>0 → exp>1 → аплифт; большой разрыв → клэмп на MAX. Формула честно
        # отрабатывает, но §9.6 такой β отдаёт low → оркестратор деградирует (см. ниже).
        assert normalization_factor(0.3, 25.0, 5.0) == _NORM_MAX

    def test_custom_bounds_respected(self) -> None:
        # Передаём свою полосу — клэмп её уважает.
        v = normalization_factor(-0.1, 30.0, 5.0, norm_min=0.1, norm_max=2.0)
        assert v == 0.1  # exp(-2.5)=0.082 < 0.1 → пол кастомной полосы


# ── pure: _window_avg_rate ────────────────────────────────────────────────────


class TestWindowAvgRate:
    def test_mean_of_known_rates(self) -> None:
        months = _months(3)
        macro = _macro(months, [8.0, 10.0, 12.0])
        assert _window_avg_rate(macro) == 10.0

    def test_ignores_none_rates(self) -> None:
        # None-месяцы не подмешиваются (не считаем 0): среднее по двум известным.
        months = _months(4)
        macro = _macro(months, [None, 8.0, None, 12.0])
        assert _window_avg_rate(macro) == 10.0

    def test_all_none_is_none(self) -> None:
        months = _months(3)
        macro = _macro(months, [None, None, None])
        assert _window_avg_rate(macro) is None

    def test_empty_is_none(self) -> None:
        assert _window_avg_rate([]) is None


# ── compute_demand_normalization (мок PR2/PR3) ────────────────────────────────


class TestComputeDemandNormalizationApplied:
    def test_gated_beta_higher_future_discounts_and_applies(self) -> None:
        # 'regression'-фит (β=coef<0) + future(18) > window(avg≈8) → coef<1, applied=True.
        n = 12
        months = _months(n)
        macro = _macro(months, [8.0] * n)  # окно «бума» — низкая ставка 8
        fit = _reg_fit(coef=-0.03, source="regression")
        with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro):
            out = compute_demand_normalization(
                MagicMock(), spec=SegmentSpec(district="Академический"), rate_future=18.0
            )
        assert isinstance(out, DemandNormalization)
        assert out.applied is True
        assert out.coefficient < _NORM_NEUTRAL  # дисконт: бумный темп срезан
        assert out.beta == -0.03  # β = long-run Σβ из Almon-фита (= coef)
        assert out.rate_window_avg == 8.0
        assert out.rate_delta == 18.0 - 8.0
        # Almon-адаптер кэпит confidence на 'medium' (advisory-grade, никогда 'high').
        assert out.confidence == "medium"
        # Сверяем с pure-формулой (clamp(exp(β·Δ))).
        assert out.coefficient == normalization_factor(-0.03, 18.0, 8.0)

    def test_gated_beta_lower_future_uplifts(self) -> None:
        # Наблюдали при жёсткой ставке (window≈16), будущее мягче (8) → аплифт >1.
        n = 12
        months = _months(n)
        macro = _macro(months, [16.0] * n)
        fit = _reg_fit(coef=-0.02, source="regression")
        with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro):
            out = compute_demand_normalization(
                MagicMock(), spec=SegmentSpec(district="X"), rate_future=8.0
            )
        assert out.applied is True
        assert out.coefficient > _NORM_NEUTRAL
        assert out.rate_delta == 8.0 - 16.0
        assert out.confidence == "medium"  # наследуется от §9.6 (capped)

    def test_confidence_capped_at_sensitivity(self) -> None:
        # Coef не «надёжнее» своего β: confidence ровно = §9.6 confidence (medium).
        n = 12
        months = _months(n)
        macro = _macro(months, [10.0] * n)
        fit = _reg_fit(coef=-0.04, source="regression")
        with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro):
            out = compute_demand_normalization(
                MagicMock(), spec=SegmentSpec(district="X"), rate_future=15.0
            )
        assert out.confidence == "medium"
        assert out.applied is True

    def test_equal_regime_applies_neutral_coefficient(self) -> None:
        # Надёжный β, но future == window → coef=1.0, всё равно applied=True (это
        # ПРИМЕНЁННАЯ оценка «режимы совпали», а не деградация).
        n = 12
        months = _months(n)
        macro = _macro(months, [12.0] * n)
        fit = _reg_fit(coef=-0.05, source="regression")
        with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro):
            out = compute_demand_normalization(
                MagicMock(), spec=SegmentSpec(district="X"), rate_future=12.0
            )
        assert out.coefficient == _NORM_NEUTRAL
        assert out.applied is True  # коррекция оценена (Δ≈0), не деградация


class TestComputeDemandNormalizationDegrade:
    def test_fallback_fit_neutral_not_applied(self) -> None:
        # 'fallback'-фит §9.6 (gate провален → β=None, confidence='low') → нейтраль
        # 1.0, applied=False, low. Честность: НЕ переносим бумный темп, но и НЕ
        # выдумываем дисконт. Это ОСНОВНОЙ контракт fallback→neutral (end-to-end).
        n = 12
        months = _months(n)
        macro = _macro(months, [8.0] * n)
        fit = _reg_fit(coef=None, source="fallback")
        with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro):
            out = compute_demand_normalization(
                MagicMock(), spec=SegmentSpec(district="X"), rate_future=18.0
            )
        assert out.coefficient == _NORM_NEUTRAL
        assert out.applied is False
        assert out.confidence == "low"
        # rate_delta всё равно заполнен для explainability (оба конца известны).
        assert out.rate_delta == 18.0 - 8.0
        assert out.beta is None  # fallback → adapter выдаёт beta=None

    def test_district_none_neutral_not_applied(self) -> None:
        # spec.district=None → адаптер не зовёт регрессию (нет района) → β=None,
        # low → нейтраль 1.0, applied=False. Patching get_monthly_macro достаточно;
        # compute_district_rate_regression НЕ должен вызываться вовсе.
        n = 12
        months = _months(n)
        macro = _macro(months, [9.0] * n)
        with patch(_REG) as reg_mock, patch(_MACRO, return_value=macro):
            out = compute_demand_normalization(MagicMock(), spec=SegmentSpec(), rate_future=20.0)
        reg_mock.assert_not_called()  # district=None → регрессию не зовём
        assert out.coefficient == _NORM_NEUTRAL
        assert out.applied is False
        assert out.beta is None
        assert out.confidence == "low"

    def test_graceful_empty_macro_is_neutral_low(self) -> None:
        # Пустой макро-ряд → rate_window_avg=None → нейтраль 1.0, applied=False, low.
        fit = _reg_fit(coef=-0.05, source="regression")  # даже надёжный β
        with patch(_REG, return_value=fit), patch(_MACRO, return_value=[]):
            out = compute_demand_normalization(
                MagicMock(), spec=SegmentSpec(district="X"), rate_future=15.0
            )
        assert out.coefficient == _NORM_NEUTRAL
        assert out.applied is False
        assert out.confidence == "low"
        assert out.rate_window_avg is None
        assert out.rate_delta is None  # нет окна → нет Δ

    def test_all_none_rates_is_neutral_low(self) -> None:
        # Сетка есть, но все key_rate None → окно не определено → нейтраль.
        n = 12
        months = _months(n)
        macro = _macro(months, [None] * n)
        fit = _reg_fit(coef=-0.05, source="regression")
        with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro):
            out = compute_demand_normalization(
                MagicMock(), spec=SegmentSpec(district="X"), rate_future=15.0
            )
        assert out.coefficient == _NORM_NEUTRAL
        assert out.applied is False
        assert out.rate_window_avg is None

    def test_coefficient_always_within_band_when_applied(self) -> None:
        # Любой режим при надёжном β → coef в [MIN, MAX] (клэмп). Экстремальный разрыв.
        n = 12
        months = _months(n)
        macro = _macro(months, [5.0] * n)
        fit = _reg_fit(coef=-0.4, source="regression")
        with patch(_REG, return_value=fit), patch(_MACRO, return_value=macro):
            out = compute_demand_normalization(
                MagicMock(), spec=SegmentSpec(district="X"), rate_future=30.0
            )
        assert _NORM_MIN <= out.coefficient <= _NORM_MAX
        assert out.coefficient == _NORM_MIN  # огромный разрыв → пол


# ── as_dict ───────────────────────────────────────────────────────────────────


class TestDemandNormalizationAsDict:
    def test_serialises_and_rounds(self) -> None:
        dn = DemandNormalization(
            coefficient=0.812345,
            beta=-0.034567,
            rate_future=18.0,
            rate_window_avg=8.123456,
            rate_delta=9.876543,
            applied=True,
            segment={"district": "X", "obj_class": None, "room_bucket": None, "price_bucket": None},
            confidence="high",
        )
        d = dn.as_dict()
        assert d["coefficient"] == 0.8123
        assert d["beta"] == -0.0346
        assert d["rate_future"] == 18.0
        assert d["rate_window_avg"] == 8.12
        assert d["rate_delta"] == 9.88
        assert d["applied"] is True
        assert d["confidence"] == "high"

    def test_none_numerics_survive(self) -> None:
        dn = DemandNormalization(
            coefficient=_NORM_NEUTRAL,
            beta=None,
            rate_future=20.0,
            rate_window_avg=None,
            rate_delta=None,
            applied=False,
            segment={},
            confidence="low",
        )
        d = dn.as_dict()
        assert d["coefficient"] == 1.0
        assert d["beta"] is None
        assert d["rate_window_avg"] is None
        assert d["rate_delta"] is None
        assert d["applied"] is False
