"""Unit-тесты §9.6 чувствительности к ключевой ставке (#951d, CORE-модуль).

Чистые тесты — БЕЗ живой БД (numpy-математика на синтетике + мок PR1/PR2):
  • ols_slope_r2 — восстановление известного slope из y=a+b·x+шум; нулевая
    дисперсия → None; <2 точек → None; дроп None/NaN-пар.
  • best_lag — ловит инжектированный лаг (sales реагируют на rate на лаге 3);
    отвергает неправильный знак; None когда ни один лаг не прошёл gate.
  • shrink — математика w=n/(n+k); тонкий n тянет к prior'у; большой n ≈ сегмент.
  • compute_rate_sensitivity (мок build_sales_series + get_monthly_macro): фраза
    заполняет X/Y/Z; деградирует (insufficient, EKB-широкая форма) на тонком/
    неправильном знаке; confounded-флаг протекает; graceful пусто → low; знак x_pct.

ADVISORY-статус (до бэктеста PR6) проверяется на уровне поведения деградации.
"""

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.macro_series import MonthlyMacro
from app.services.forecasting.rate_sensitivity import (
    _BUCKET_AREA_FLOOR_M2,
    _MIN_OBS,
    _PHRASE_INSUFFICIENT,
    _SHRINK_K,
    RateSensitivity,
    _delta,
    best_lag,
    compute_rate_sensitivity,
    ols_slope_r2,
    shrink,
)
from app.services.forecasting.sales_series import (
    ROOM_AREA_BUCKET_2K,
    SalesSeries,
    SegmentSpec,
)

_BUILD = "app.services.forecasting.rate_sensitivity.build_sales_series"
_MACRO = "app.services.forecasting.rate_sensitivity.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))


# ── pure: ols_slope_r2 ────────────────────────────────────────────────────────


class TestOlsSlopeR2:
    def test_recovers_known_slope(self) -> None:
        # y = 2 + (-1.5)·x + крошечный детерминированный «шум» → slope ≈ -1.5, R² высок.
        xs = [float(i) for i in range(20)]
        noise = [((i % 3) - 1) * 0.001 for i in range(20)]
        ys = [2.0 - 1.5 * x + n for x, n in zip(xs, noise, strict=True)]
        slope, r2, n = ols_slope_r2(xs, ys)
        assert n == 20
        assert slope is not None and r2 is not None
        assert math.isclose(slope, -1.5, abs_tol=1e-3)
        assert r2 > 0.999

    def test_recovers_positive_slope(self) -> None:
        xs = [float(i) for i in range(15)]
        ys = [1.0 + 0.8 * x for x in xs]
        slope, r2, n = ols_slope_r2(xs, ys)
        assert slope is not None and math.isclose(slope, 0.8, abs_tol=1e-6)
        assert r2 is not None and math.isclose(r2, 1.0, abs_tol=1e-9)
        assert n == 15

    def test_zero_variance_x_returns_none(self) -> None:
        slope, r2, n = ols_slope_r2([3.0, 3.0, 3.0], [1.0, 2.0, 3.0])
        assert slope is None and r2 is None and n == 3

    def test_zero_variance_y_returns_none(self) -> None:
        slope, r2, n = ols_slope_r2([1.0, 2.0, 3.0], [5.0, 5.0, 5.0])
        assert slope is None and r2 is None and n == 3

    def test_fewer_than_two_points_returns_none(self) -> None:
        assert ols_slope_r2([1.0], [2.0]) == (None, None, 1)
        assert ols_slope_r2([], []) == (None, None, 0)

    def test_drops_none_pairs(self) -> None:
        # None в любой позиции пары → пара выбрасывается; slope считается по остатку.
        xs: list[float | None] = [0.0, None, 2.0, 3.0, None]
        ys: list[float | None] = [0.0, 5.0, -2.0, -3.0, 9.0]
        slope, _r2, n = ols_slope_r2(xs, ys)
        assert n == 3  # (0,0),(2,-2),(3,-3)
        assert slope is not None and math.isclose(slope, -1.0, abs_tol=1e-9)

    def test_drops_nan_and_inf_pairs(self) -> None:
        xs = [0.0, 1.0, 2.0, float("nan"), float("inf")]
        ys = [0.0, -1.0, -2.0, 10.0, 10.0]
        slope, _r2, n = ols_slope_r2(xs, ys)
        assert n == 3
        assert slope is not None and math.isclose(slope, -1.0, abs_tol=1e-9)


# ── pure: _delta ──────────────────────────────────────────────────────────────


class TestDelta:
    def test_first_diff(self) -> None:
        assert _delta([10.0, 12.0, 11.0]) == [None, 2.0, -1.0]

    def test_none_breaks_pair(self) -> None:
        assert _delta([1.0, None, 3.0]) == [None, None, None]

    def test_empty_and_single(self) -> None:
        assert _delta([]) == [None]  # out always starts with None sentinel
        assert _delta([5.0]) == [None]


# ── pure: best_lag ────────────────────────────────────────────────────────────


class TestBestLag:
    def _rate_deltas(self, n: int) -> list[float | None]:
        """Детерминированный Δrate с НИЗКОЙ автокорреляцией.

        Важно: периодический (sin) регрессор имеет знак-переворачивающую
        автокорреляцию (corr(x[t], x[t-3]) < 0 для sin), поэтому позитивная связь
        на лаге 0 ложно «всплывает» негативной на лаге 3. Берём апериодический
        зубчатый ряд (LCG-стиль, центрированный) — лаги слабо коррелируют, так что
        выигрывает ИСТИННЫЙ инжектированный лаг, а «неправильный знак» остаётся
        неправильным на всех лагах.
        """
        out: list[float | None] = []
        state = 7
        for _ in range(n):
            state = (state * 1103515245 + 12345) % 2147483648
            out.append(state / 2147483648.0 - 0.5)  # ∈ [-0.5, 0.5)
        return out

    def test_picks_injected_lag_three(self) -> None:
        # sales[t] реагируют на rate[t-3]: Δln(sales)[t] = -2.0·Δrate[t-3].
        n = 36
        rate = self._rate_deltas(n)
        lag = 3
        b = -2.0
        sales: list[float | None] = []
        for t in range(n):
            src = rate[t - lag] if t - lag >= 0 else None
            sales.append(b * src if src is not None else None)
        out = best_lag(sales, rate)
        assert out is not None
        assert out["lag"] == 3
        assert math.isclose(out["slope"], b, abs_tol=1e-6)
        assert out["r2"] > 0.99
        assert out["n"] >= _MIN_OBS

    def test_rejects_wrong_sign(self) -> None:
        # Положительная связь (продажи РАСТУТ при ↑ставки): на апериодическом
        # регрессоре slope позитивен на всех лагах → gate(slope<0) отвергает все.
        n = 30
        rate = self._rate_deltas(n)
        sales: list[float | None] = [1.5 * r for r in rate]  # b>0
        assert best_lag(sales, rate) is None

    def test_none_when_too_few_obs(self) -> None:
        # Идеальная негативная связь, но всего < _MIN_OBS точек → gate(n) не пускает.
        n = 5
        rate = self._rate_deltas(n)
        sales: list[float | None] = [-2.0 * r for r in rate]
        assert best_lag(sales, rate) is None

    def test_none_when_no_correlation(self) -> None:
        # Y — нулевая дисперсия после дропа: фит невозможен → None.
        n = 30
        rate = self._rate_deltas(n)
        sales: list[float | None] = [0.0 for _ in range(n)]
        assert best_lag(sales, rate) is None

    def test_prefers_most_negative_slope(self) -> None:
        # Чистый лаг-0 vs лаг-1 на ОРТОГОНАЛЬНЫХ драйверах: лаг 0 → b=-1, лаг 1 → b=-3.
        # Используем разреженные импульсы на чётных/нечётных t, чтобы лаги не смешивались.
        n = 40
        rate = self._rate_deltas(n)
        sales: list[float | None] = []
        for t in range(n):
            v0 = -1.0 * rate[t]
            v1 = -3.0 * rate[t - 1] if t - 1 >= 0 else 0.0
            sales.append(v0 + v1)
        out = best_lag(sales, rate, lags=(0, 1))
        assert out is not None and out["lag"] == 1  # -3 негативнее -1


# ── pure: shrink ──────────────────────────────────────────────────────────────


class TestShrink:
    def test_weight_math(self) -> None:
        shrunk, w = shrink(beta_seg=-2.0, n_seg=10, beta_ekb=-1.0, k=10.0)
        assert math.isclose(w, 10 / 20)  # n/(n+k)
        assert math.isclose(shrunk, 0.5 * -2.0 + 0.5 * -1.0)

    def test_thin_segment_pulled_toward_prior(self) -> None:
        # n=2, k=10 → w≈0.167 → шринкнутый ближе к prior'у (-1.0), чем к сегменту (-5.0).
        shrunk, w = shrink(beta_seg=-5.0, n_seg=2, beta_ekb=-1.0, k=_SHRINK_K)
        assert w < 0.3
        assert abs(shrunk - (-1.0)) < abs(shrunk - (-5.0))

    def test_large_segment_approx_segment(self) -> None:
        # n=1000, k=10 → w≈0.99 → шринкнутый ≈ сегмент.
        shrunk, w = shrink(beta_seg=-3.0, n_seg=1000, beta_ekb=0.0, k=_SHRINK_K)
        assert w > 0.98
        assert math.isclose(shrunk, -3.0, abs_tol=0.05)

    def test_zero_n_is_full_prior(self) -> None:
        shrunk, w = shrink(beta_seg=-9.0, n_seg=0, beta_ekb=-1.0, k=_SHRINK_K)
        assert w == 0.0
        assert math.isclose(shrunk, -1.0)


# ── compute_rate_sensitivity (мок PR1/PR2) ────────────────────────────────────


def _macro_series(rate_levels: list[float | None], months: list[dt.date]) -> list[MonthlyMacro]:
    """Список MonthlyMacro с заданными уровнями key_rate (прочие поля None)."""
    out: list[MonthlyMacro] = []
    for month, kr in zip(months, rate_levels, 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 _sales(months: list[dt.date], units: list[int], *, source: str) -> SalesSeries:
    return SalesSeries(
        months=months,
        units=units,
        area_m2=[None] * len(months),
        avg_price_per_m2=[None] * len(months),
        n_months=len(months),
        source=source,  # type: ignore[arg-type]
        segment={},
        confidence="high",
    )


def _synth_sales_units(
    rate_levels: list[float],
    *,
    lag: int,
    beta: float,
    base: float = 1000.0,
) -> list[int]:
    """units так, что log_diff(units)[t] ≈ beta·Δrate[t-lag] (инжектируем связь).

    Строим уровни мультипликативно: ln(u_t) = ln(u_{t-1}) + beta·Δrate[t-lag].
    Округляем в int (units — счётчик); шаг малый, чтобы округление не убило связь.
    """
    rate_deltas = [0.0] + [rate_levels[i] - rate_levels[i - 1] for i in range(1, len(rate_levels))]
    ln_u = math.log(base)
    units: list[int] = [round(math.exp(ln_u))]
    for t in range(1, len(rate_levels)):
        src = rate_deltas[t - lag] if t - lag >= 0 else 0.0
        ln_u += beta * src
        units.append(max(1, round(math.exp(ln_u))))
    return units


class _Dispatcher:
    """side_effect для build_sales_series: разный ряд per (source, room_bucket)."""

    def __init__(
        self,
        *,
        months: list[dt.date],
        source_a_units: list[int],
        bucket_units: dict[str, list[int]] | None = None,
        default_units: list[int] | None = None,
    ) -> None:
        self.months = months
        self.source_a_units = source_a_units
        self.bucket_units = bucket_units or {}
        self.default_units = default_units or [0] * len(months)

    def __call__(
        self, _db: object, *, spec: SegmentSpec, source: str, **_kw: object
    ) -> SalesSeries:
        if source == "corpus_room_month":
            return _sales(self.months, self.source_a_units, source=source)
        # Source B — per-bucket.
        units = self.bucket_units.get(spec.room_bucket or "", self.default_units)
        return _sales(self.months, units, source=source)


def _jittered_rate_levels(n: int, *, seed: int = 13) -> list[float]:
    """Уровни ставки с подъёмом + АПЕРИОДИЧНЫМ джиттером (низкая автокорреляция Δ).

    Периодический (sin) джиттер даёт знак-переворачивающую автокорреляцию Δ —
    инжектированный лаг тогда конкурирует с ложными лагами. LCG-джиттер делает
    лаги слабо коррелированными, так что best_lag находит ИСТИННЫЙ лаг устойчиво.
    """
    lvl = 10.0
    state = seed
    out: list[float] = []
    for _ in range(n):
        state = (state * 1103515245 + 12345) % 2147483648
        lvl += 0.3 + (state / 2147483648.0 - 0.5) * 0.4
        out.append(lvl)
    return out


class TestComputeRateSensitivityHappy:
    def _rate_levels(self, n: int) -> list[float]:
        return _jittered_rate_levels(n)

    def test_phrase_fills_x_y_z_and_sign(self) -> None:
        n = 36
        months = _months(n)
        rate_levels = self._rate_levels(n)
        lag = 3
        beta = -0.05
        seg_units = _synth_sales_units(rate_levels, lag=lag, beta=beta)
        # Самый чувствительный bucket — "2-к 45-60" (сильнее реакция: beta·1.5).
        bucket_units = {
            ROOM_AREA_BUCKET_2K: _synth_sales_units(rate_levels, lag=lag, beta=beta * 1.5),
        }
        dispatcher = _Dispatcher(
            months=months,
            source_a_units=seg_units,
            bucket_units=bucket_units,
        )
        macro = _macro_series(list(rate_levels), months)
        with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher):
            out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec(district="Академический"))
        assert isinstance(out, RateSensitivity)
        assert out.confidence in ("high", "medium")
        # Y: инжектированный лаг.
        assert out.y_lag_months == lag
        # X: отрицательный (продажи падают).
        assert out.x_pct is not None and out.x_pct < 0
        assert out.beta is not None and out.beta < 0
        # Z: самый чувствительный bucket "2-к 45-60" → floor 45.
        assert out.most_sensitive_bucket == ROOM_AREA_BUCKET_2K
        assert out.z_area_floor == _BUCKET_AREA_FLOOR_M2[ROOM_AREA_BUCKET_2K]
        # Фраза — полная форма, с заполненными X/Y/Z.
        assert "снижались в среднем на" in out.phrase
        assert f"через {lag} месяцев" in out.phrase
        assert ROOM_AREA_BUCKET_2K in out.phrase
        assert "более 45.0 м²" in out.phrase
        # X в фразе — положительная магнитуда (= |x_pct|).
        assert str(round(abs(out.x_pct), 1)) in out.phrase

    def test_x_pct_matches_beta_exp_formula(self) -> None:
        n = 30
        months = _months(n)
        rate_levels = self._rate_levels(n)
        seg_units = _synth_sales_units(rate_levels, lag=1, beta=-0.04)
        dispatcher = _Dispatcher(months=months, source_a_units=seg_units)
        macro = _macro_series(list(rate_levels), months)
        with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher):
            out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec())
        assert out.beta is not None and out.x_pct is not None
        expected = 100.0 * (math.exp(out.beta) - 1.0)
        assert math.isclose(out.x_pct, expected, rel_tol=1e-9)


class TestComputeRateSensitivityDegrade:
    def test_thin_segment_insufficient(self) -> None:
        # Слишком короткий ряд: < _MIN_OBS валидных Δln-точек → нет gate-лага.
        n = 5
        months = _months(n)
        rate_levels = [10.0 + i for i in range(n)]
        seg_units = _synth_sales_units(rate_levels, lag=1, beta=-0.05)
        dispatcher = _Dispatcher(months=months, source_a_units=seg_units)
        macro = _macro_series(list(map(float, rate_levels)), months)
        with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher):
            out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec())
        assert out.confidence == "low"
        assert out.phrase == _PHRASE_INSUFFICIENT
        assert out.x_pct is None and out.y_lag_months is None

    def test_wrong_sign_degrades_to_insufficient(self) -> None:
        # Продажи РАСТУТ при ↑ставки (β>0) — выживший неправильный знак → insufficient.
        # Апериодический подъём ставки (LCG-джиттер) → позитивная связь держит знак
        # на всех лагах (без sin-переворота), так что gate(slope<0) валит все лаги.
        n = 30
        months = _months(n)
        lvl = 10.0
        state = 11
        rate_levels: list[float] = []
        for _ in range(n):
            state = (state * 1103515245 + 12345) % 2147483648
            lvl += 0.2 + (state / 2147483648.0) * 0.3  # всегда положительный шаг
            rate_levels.append(lvl)
        seg_units = _synth_sales_units(rate_levels, lag=1, beta=+0.05)  # положительный
        dispatcher = _Dispatcher(months=months, source_a_units=seg_units)
        macro = _macro_series(list(rate_levels), months)
        with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher):
            out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec())
        assert out.confidence == "low"
        assert out.phrase == _PHRASE_INSUFFICIENT

    def test_ekb_wide_form_when_no_sensitive_bucket(self) -> None:
        # Сегмент даёт валидный негативный β, но НИ один Source-B bucket не проходит
        # gate (все плоские) → фраза деградирует к EKB-широкой форме (без 2-го предл.).
        n = 36
        months = _months(n)
        rate_levels = _jittered_rate_levels(n)
        seg_units = _synth_sales_units(rate_levels, lag=2, beta=-0.05)
        flat = [500] * n  # все bucket-ряды плоские → нет реакции → нет gate-лага
        dispatcher = _Dispatcher(
            months=months,
            source_a_units=seg_units,
            default_units=flat,
        )
        macro = _macro_series(list(rate_levels), months)
        with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher):
            out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec())
        assert out.y_lag_months == 2
        assert out.x_pct is not None and out.x_pct < 0
        assert out.most_sensitive_bucket is None
        assert out.z_area_floor is None
        # EKB-широкая форма: 1-е предложение есть, 2-е («наиболее чувствительны») нет.
        assert "снижались в среднем на" in out.phrase
        assert "Наиболее чувствительны" not in out.phrase

    def test_confounded_flag_flows_and_caps_confidence(self) -> None:
        # Окно ряда пересекает шок 2022-02 → confounded=True; даже сильный фит → не 'high'.
        n = 36
        months = _months(n, end=dt.date(2022, 12, 1))  # окно охватывает 2022-02
        assert any(m == dt.date(2022, 2, 1) for m in months)
        rate_levels = _jittered_rate_levels(n)
        seg_units = _synth_sales_units(rate_levels, lag=2, beta=-0.05)
        bucket_units = {
            ROOM_AREA_BUCKET_2K: _synth_sales_units(rate_levels, lag=2, beta=-0.07),
        }
        dispatcher = _Dispatcher(months=months, source_a_units=seg_units, bucket_units=bucket_units)
        macro = _macro_series(list(rate_levels), months)
        with patch(_MACRO, return_value=macro), patch(_BUILD, side_effect=dispatcher):
            out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec())
        assert out.confounded is True
        assert out.confidence != "high"  # confounded окно срезает high

    def test_graceful_empty_is_low(self) -> None:
        # Пустые ряды (нет данных) → low, insufficient, не crash.
        months: list[dt.date] = []
        dispatcher = _Dispatcher(months=months, source_a_units=[])
        with patch(_MACRO, return_value=[]), patch(_BUILD, side_effect=dispatcher):
            out = compute_rate_sensitivity(MagicMock(), spec=SegmentSpec())
        assert out.confidence == "low"
        assert out.phrase == _PHRASE_INSUFFICIENT
        assert out.n_obs == 0


class TestRateSensitivityAsDict:
    def test_serialises_and_rounds(self) -> None:
        rs = RateSensitivity(
            segment={"district": "X", "obj_class": None, "room_bucket": None, "price_bucket": None},
            x_pct=-12.3456,
            y_lag_months=3,
            z_area_floor=45.0,
            most_sensitive_bucket=ROOM_AREA_BUCKET_2K,
            beta=-0.131234,
            r2=0.456789,
            n_obs=28,
            shrinkage_weight=0.7361,
            confounded=False,
            confidence="high",
            phrase="…",
        )
        d = rs.as_dict()
        assert d["x_pct"] == -12.3
        assert d["beta"] == -0.1312
        assert d["r2"] == 0.4568
        assert d["shrinkage_weight"] == 0.736
        assert d["y_lag_months"] == 3
        assert d["confidence"] == "high"

    def test_none_numerics_survive(self) -> None:
        rs = RateSensitivity(
            segment={},
            x_pct=None,
            y_lag_months=None,
            z_area_floor=None,
            most_sensitive_bucket=None,
            beta=None,
            r2=None,
            n_obs=0,
            shrinkage_weight=0.0,
            confounded=False,
            confidence="low",
            phrase=_PHRASE_INSUFFICIENT,
        )
        d = rs.as_dict()
        assert d["x_pct"] is None
        assert d["beta"] is None
        assert d["phrase"] == _PHRASE_INSUFFICIENT
