"""Unit tests for §9.6 Almon distributed-lag regression (Forgejo #978 Part B).

Covers the PURE numpy logic on SYNTHETIC series with a KNOWN injected lag effect:
  - _build_lag_matrix     — full-row-only lag profile, drops incomplete/None rows
  - _almon_basis          — W[j,p] = j^p (constrains 7 lags to degree+1 params)
  - newey_west_bandwidth  — floor(4·(n/100)^(2/9)) rule, ≥1 floor
  - newey_west_cov        — HAC covariance differs from naive OLS; PSD; manual NW
  - fit_almon_dl          — recovers the injected best_lag + sign + long-run; R²;
                            per-lag reconstruction; HAC SEs computed
  - build_fit_result      — gate (n≥30 ∧ R²≥0.1 ∧ Σβ<0) → regression vs fallback;
                            fallback on thin n / weak R² / wrong sign (no crash)
  - _build_phrase         — §9.6 text from the lag shape; insufficient on no-gate
  - compute_district_rate_regression — DB orchestrator wiring (mocked session)

NO live DB: the orchestrator test injects a fake session + monkeypatched data
loaders. Set a dummy DATABASE_URL BEFORE importing so app.core.config.Settings
fail-fast doesn't trip (same pattern as test_rate_sensitivity.py).
"""

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")

import numpy as np
import pytest

from app.services.forecasting import regression as reg
from app.services.forecasting.rate_sensitivity import RateSensitivity
from app.services.forecasting.sales_series import SegmentSpec

# --------------------------------------------------------------------------- #
# Synthetic-series helpers — inject a KNOWN distributed-lag effect
# --------------------------------------------------------------------------- #


def _aperiodic_rate_deltas(n: int, *, seed: int = 13) -> list[float]:
    """Δrate series with APERIODIC (LCG) jitter → low autocorrelation across lags.

    A periodic regressor would let false lags compete with the injected one; an
    LCG jitter keeps successive Δ weakly correlated so the true lag shape wins.
    Finite from index 0 (the DL matrix builder drops incomplete leading rows).
    """
    lvl = 10.0
    state = seed
    levels: list[float] = []
    for _ in range(n):
        state = (state * 1103515245 + 12345) % 2147483648
        lvl += 0.3 + (state / 2147483648.0 - 0.5) * 0.8
        levels.append(lvl)
    return [0.0] + [levels[i] - levels[i - 1] for i in range(1, n)]


def _hump_beta(max_lag: int, *, peak: int, scale: float = 0.06) -> np.ndarray:
    """A negative 'hump' lag shape peaking (in magnitude) at ``peak``.

    |β_j| = scale − 0.012·(j−peak)² (floored at 0.005), all signs negative — the
    economically expected shape (rate ↑ → demand ↓, response builds then fades).
    Representable approximately by an Almon deg-2 polynomial, so the fit recovers
    the peak and long-run.
    """
    betas: list[float] = []
    for j in range(max_lag + 1):
        mag = scale - 0.012 * (j - peak) ** 2
        betas.append(-max(0.005, mag))
    return np.asarray(betas, dtype=float)


def _y_from_lag_shape(
    x: list[float], beta: np.ndarray, *, max_lag: int, noise: float = 0.0, seed: int = 0
) -> list[float | None]:
    """y[t] = Σ_j β_j·x[t−j] (+ optional gaussian noise); y[t<max_lag] = None.

    Builds the regressand carrying the injected distributed-lag effect exactly
    (plus noise). Leading months without a full lag profile → None (the builder
    drops them anyway).
    """
    rng = np.random.default_rng(seed)
    y: list[float | None] = [None] * len(x)
    for t in range(max_lag, len(x)):
        val = float(sum(beta[j] * x[t - j] for j in range(max_lag + 1)))
        if noise > 0.0:
            val += float(rng.normal(0.0, noise))
        y[t] = val
    return y


# --------------------------------------------------------------------------- #
# _build_lag_matrix
# --------------------------------------------------------------------------- #


class TestBuildLagMatrix:
    def test_shapes_and_full_rows_only(self) -> None:
        x = [float(i) for i in range(10)]
        y = [float(i) * 0.1 for i in range(10)]
        built = reg._build_lag_matrix(x, y, max_lag=2)
        assert built is not None
        xm, yv = built
        # First usable row is t=max_lag=2 → 10−2 = 8 rows, 3 lag columns.
        assert xm.shape == (8, 3)
        assert yv.shape == (8,)
        # Row 0 corresponds to t=2: [x[2], x[1], x[0]] = [2,1,0].
        assert list(xm[0]) == [2.0, 1.0, 0.0]

    def test_drops_rows_with_none_in_any_lag(self) -> None:
        x: list[float | None] = [0.0, 1.0, None, 3.0, 4.0, 5.0]
        y: list[float | None] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5]
        built = reg._build_lag_matrix(x, y, max_lag=2)
        assert built is not None
        xm, _yv = built
        # t=2 reads x[0..2] (has None) → dropped; t=3 reads x[1..3] (has None) →
        # dropped; t=4 reads x[2..4] (has None) → dropped; t=5 reads x[3..5] OK.
        assert xm.shape == (1, 3)
        assert list(xm[0]) == [5.0, 4.0, 3.0]

    def test_drops_rows_with_none_y(self) -> None:
        x = [float(i) for i in range(6)]
        y: list[float | None] = [0.0, 0.1, None, 0.3, 0.4, 0.5]
        built = reg._build_lag_matrix(x, y, max_lag=1)
        assert built is not None
        xm, yv = built
        # t=2 has y=None → dropped. Usable t ∈ {1,3,4,5} → 4 rows.
        assert xm.shape == (4, 2)
        assert yv.shape == (4,)

    def test_returns_none_when_no_full_row(self) -> None:
        x: list[float | None] = [None, None, None]
        y: list[float | None] = [1.0, 2.0, 3.0]
        assert reg._build_lag_matrix(x, y, max_lag=1) is None


# --------------------------------------------------------------------------- #
# _almon_basis
# --------------------------------------------------------------------------- #


class TestAlmonBasis:
    def test_j_to_the_p(self) -> None:
        w = reg._almon_basis(3, 2)  # lags 0..3, degree 2
        assert w.shape == (4, 3)
        # Column p = j^p: col0 = ones, col1 = j, col2 = j².
        assert list(w[:, 0]) == [1.0, 1.0, 1.0, 1.0]
        assert list(w[:, 1]) == [0.0, 1.0, 2.0, 3.0]
        assert list(w[:, 2]) == [0.0, 1.0, 4.0, 9.0]

    def test_reconstruct_quadratic_beta_exactly(self) -> None:
        # β_j = 2 − 0.5j + 0.1j² is degree-2 → W @ γ reproduces it for γ=[2,−0.5,0.1].
        w = reg._almon_basis(6, 2)
        gamma = np.array([2.0, -0.5, 0.1])
        beta = w @ gamma
        expected = np.array([2.0 - 0.5 * j + 0.1 * j**2 for j in range(7)])
        assert np.allclose(beta, expected)


# --------------------------------------------------------------------------- #
# newey_west_bandwidth / newey_west_cov
# --------------------------------------------------------------------------- #


class TestNeweyWestBandwidth:
    def test_rule_values(self) -> None:
        # floor(4·(n/100)^(2/9)).
        assert reg.newey_west_bandwidth(100) == 4
        assert reg.newey_west_bandwidth(41) == 3
        assert reg.newey_west_bandwidth(50) == 3

    def test_small_n_values(self) -> None:
        # floor(4·(10/100)^(2/9)) = floor(2.398) = 2; n=20 → floor(2.40)·… = 2.
        assert reg.newey_west_bandwidth(10) == 2
        assert reg.newey_west_bandwidth(20) == 2

    def test_floor_at_one_for_tiny_n(self) -> None:
        # n=2 → floor(4·0.02^(2/9)) ≈ floor(1.06) = 1, but the ≥1 floor guarantees
        # at least a lag-1 autocovariance whenever n>1.
        assert reg.newey_west_bandwidth(2) == 1
        assert reg.newey_west_bandwidth(3) == 1

    def test_zero_for_degenerate(self) -> None:
        assert reg.newey_west_bandwidth(1) == 0
        assert reg.newey_west_bandwidth(0) == 0


class TestNeweyWestCov:
    def test_psd_and_symmetric(self) -> None:
        rng = np.random.default_rng(1)
        n = 50
        design = np.column_stack([np.ones(n), rng.normal(size=(n, 2))])
        resid = rng.normal(size=n)
        cov = reg.newey_west_cov(design, resid, bandwidth=4)
        # Symmetric and positive semi-definite (Bartlett weights guarantee PSD).
        assert np.allclose(cov, cov.T, atol=1e-10)
        eig = np.linalg.eigvalsh(cov)
        assert float(eig.min()) >= -1e-8

    def test_bandwidth_zero_equals_white_hc0(self) -> None:
        rng = np.random.default_rng(2)
        n = 40
        design = np.column_stack([np.ones(n), rng.normal(size=(n, 1))])
        resid = rng.normal(size=n)
        cov0 = reg.newey_west_cov(design, resid, bandwidth=0)
        # HC0: (X'X)^-1 (Σ u² x x') (X'X)^-1 — reconstruct manually.
        xtx_inv = np.linalg.inv(design.T @ design)
        ux = design * resid.reshape(-1, 1)
        hc0 = xtx_inv @ (ux.T @ ux) @ xtx_inv
        assert np.allclose(cov0, hc0, atol=1e-12)

    def test_hac_differs_from_naive_under_autocorrelation(self) -> None:
        # Construct strongly AUTOCORRELATED residuals → HAC SE must differ from
        # the naive iid OLS SE (the whole point of NW).
        n = 80
        rng = np.random.default_rng(3)
        x = rng.normal(size=n)
        design = np.column_stack([np.ones(n), x])
        # AR(1) residuals (ρ=0.7) → positive autocorrelation.
        e = np.zeros(n)
        for t in range(1, n):
            e[t] = 0.7 * e[t - 1] + rng.normal(0, 1)
        hac = reg.newey_west_cov(design, e, bandwidth=reg.newey_west_bandwidth(n))
        sigma2 = float(e @ e) / (n - design.shape[1])
        naive = sigma2 * np.linalg.inv(design.T @ design)
        # The slope variance estimates must differ materially under autocorrelation.
        assert not math.isclose(hac[1, 1], naive[1, 1], rel_tol=0.05)


# --------------------------------------------------------------------------- #
# fit_almon_dl — recover the injected lag shape
# --------------------------------------------------------------------------- #


class TestFitAlmonDl:
    def test_recovers_injected_best_lag_and_sign(self) -> None:
        n, max_lag = 60, 6
        x = _aperiodic_rate_deltas(n, seed=13)
        beta = _hump_beta(max_lag, peak=2)
        y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=0)
        fit = reg.fit_almon_dl(x, y, max_lag=max_lag, degree=2)
        assert fit is not None
        # Peak lag recovered.
        assert fit["best_lag"] == 2
        # Long-run sign negative (rate ↑ → demand ↓) and close to the truth.
        assert fit["long_run_coef"] < 0
        assert math.isclose(fit["long_run_coef"], float(beta.sum()), abs_tol=0.02)
        # Clean injected signal → high R².
        assert fit["r2"] is not None and fit["r2"] > 0.8
        # Gate flags all green on this clean, long, correctly-signed series.
        assert fit["gate_n_ok"] and fit["gate_r2_ok"] and fit["gate_sign_ok"]

    def test_recovers_different_peak_lag(self) -> None:
        # Shift the injected peak to lag 4 → the fit must track it.
        n, max_lag = 64, 6
        x = _aperiodic_rate_deltas(n, seed=21)
        beta = _hump_beta(max_lag, peak=4)
        y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=1)
        fit = reg.fit_almon_dl(x, y, max_lag=max_lag, degree=2)
        assert fit is not None
        assert fit["best_lag"] == 4
        assert fit["long_run_coef"] < 0

    def test_per_lag_reconstruction_length_and_finite(self) -> None:
        n, max_lag = 60, 6
        x = _aperiodic_rate_deltas(n)
        beta = _hump_beta(max_lag, peak=2)
        y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.001, seed=2)
        fit = reg.fit_almon_dl(x, y, max_lag=max_lag, degree=2)
        assert fit is not None
        per_lag = fit["per_lag_coef"]
        assert len(per_lag) == max_lag + 1
        assert all(math.isfinite(c) for c in per_lag)

    def test_hac_se_computed_for_every_lag(self) -> None:
        n, max_lag = 60, 6
        x = _aperiodic_rate_deltas(n)
        beta = _hump_beta(max_lag, peak=2)
        y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.003, seed=3)
        fit = reg.fit_almon_dl(x, y, max_lag=max_lag, degree=2)
        assert fit is not None
        hac_se = fit["hac_se"]
        # One HAC SE per reconstructed lag coefficient, all finite and ≥0.
        assert len(hac_se) == max_lag + 1
        assert all(math.isfinite(s) and s >= 0.0 for s in hac_se)
        # Bandwidth follows the NW rule for this n.
        assert fit["hac_bandwidth"] == reg.newey_west_bandwidth(fit["n"])

    def test_degree_must_be_below_lag_count(self) -> None:
        # degree ≥ max_lag+1 is not a constraint (degenerates to free lags) → refuse.
        x = _aperiodic_rate_deltas(40)
        beta = _hump_beta(6, peak=2)
        y = _y_from_lag_shape(x, beta, max_lag=6, noise=0.0)
        assert reg.fit_almon_dl(x, y, max_lag=6, degree=7) is None

    def test_thin_series_returns_none(self) -> None:
        # Too few full rows to fit (< _MIN_FIT_OBS) → None, not a crash.
        x = _aperiodic_rate_deltas(10)
        beta = _hump_beta(6, peak=2)
        y = _y_from_lag_shape(x, beta, max_lag=6, noise=0.0)
        assert reg.fit_almon_dl(x, y, max_lag=6, degree=2) is None

    def test_zero_variance_y_returns_none(self) -> None:
        x = _aperiodic_rate_deltas(50)
        y: list[float | None] = [None] * 6 + [0.0] * 44  # constant → no variance
        assert reg.fit_almon_dl(x, y, max_lag=6, degree=2) is None


# --------------------------------------------------------------------------- #
# build_fit_result — gate (mirror _elasticity_coef) → regression vs fallback
# --------------------------------------------------------------------------- #


_SEG: dict[str, str | None] = {"district": "Академический", "obj_class": None}


class TestBuildFitResult:
    def test_gate_pass_emits_regression(self) -> None:
        n, max_lag = 60, 6
        x = _aperiodic_rate_deltas(n, seed=13)
        beta = _hump_beta(max_lag, peak=2)
        y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=0)
        res = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2)
        assert res.source == "regression"
        assert res.best_lag_months == 2
        assert res.coef is not None and res.coef < 0
        assert res.x_pct is not None and res.x_pct < 0  # demand drops
        assert res.r2 is not None and res.r2 > 0.8
        assert res.per_lag_coef is not None and len(res.per_lag_coef) == max_lag + 1
        assert res.hac_se is not None and len(res.hac_se) == max_lag + 1
        # Phrase carries the magnitude + peak lag.
        assert "снижается" in res.phrase
        assert f"{abs(round(res.x_pct, 1))}" in res.phrase

    def test_thin_n_degrades_to_fallback(self) -> None:
        # Enough to fit, but n < _MIN_OBS (30) → gate fails on n → fallback. We keep
        # the diagnostic numbers (per_lag/r2/n) but make no claim.
        n, max_lag = 28, 6  # ~22 usable rows < 30
        x = _aperiodic_rate_deltas(n, seed=5)
        beta = _hump_beta(max_lag, peak=2)
        y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.001, seed=4)
        res = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2)
        assert res.source == "fallback"
        assert res.n < reg._MIN_OBS
        assert res.coef is None and res.x_pct is None and res.best_lag_months is None
        assert res.phrase == reg._PHRASE_INSUFFICIENT
        # Diagnostics retained (mirror _elasticity_coef returning r2/n in fallback).
        assert res.per_lag_coef is not None

    def test_wrong_sign_degrades_to_fallback(self) -> None:
        # POSITIVE long-run (rate ↑ → demand ↑) violates the gate sign → fallback,
        # even with plenty of obs and a strong fit.
        n, max_lag = 60, 6
        x = _aperiodic_rate_deltas(n, seed=13)
        beta = -_hump_beta(max_lag, peak=2)  # flip all signs → positive long-run
        y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=0)
        res = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2)
        assert res.source == "fallback"
        assert res.coef is None
        assert res.phrase == reg._PHRASE_INSUFFICIENT

    def test_weak_r2_degrades_to_fallback(self) -> None:
        # Pure noise regressand (no rate link) at large n: a 3-param Almon basis
        # cannot overfit ~114 noise points, so R² collapses well below 0.1 → the
        # gate fails on R² (or sign) → fallback. (At small n a flexible basis can
        # spuriously clear R²≥0.1 — which is exactly why the n≥30 gate + advisory
        # status exist; here we use n=120 so the no-signal case is unambiguous.)
        n, max_lag = 120, 6
        x = _aperiodic_rate_deltas(n, seed=13)
        rng = np.random.default_rng(7)
        y: list[float | None] = [None] * max_lag + [
            float(v) for v in rng.normal(0, 0.05, size=n - max_lag)
        ]
        res = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2)
        assert res.source == "fallback"
        assert res.coef is None
        # Confirm it degraded specifically because the fit explains ~no variance.
        assert res.r2 is not None and res.r2 < reg._MIN_R2

    def test_empty_series_is_fallback_not_crash(self) -> None:
        res = reg.build_fit_result([], [], segment=_SEG)
        assert res.source == "fallback"
        assert res.n == 0
        assert res.phrase == reg._PHRASE_INSUFFICIENT

    def test_as_dict_shape(self) -> None:
        n, max_lag = 60, 6
        x = _aperiodic_rate_deltas(n, seed=13)
        beta = _hump_beta(max_lag, peak=2)
        y = _y_from_lag_shape(x, beta, max_lag=max_lag, noise=0.002, seed=0)
        d = reg.build_fit_result(x, y, segment=_SEG, max_lag=max_lag, degree=2).as_dict()
        for key in (
            "segment",
            "best_lag_months",
            "coef",
            "x_pct",
            "r2",
            "n",
            "per_lag_coef",
            "hac_se",
            "hac_bandwidth",
            "almon_degree",
            "source",
            "phrase",
        ):
            assert key in d
        assert d["source"] == "regression"
        assert isinstance(d["per_lag_coef"], list)


# --------------------------------------------------------------------------- #
# _build_phrase
# --------------------------------------------------------------------------- #


class TestBuildPhrase:
    def test_phrase_from_shape(self) -> None:
        p = reg._build_phrase(x_pct=-3.2, best_lag=2, gated=True)
        assert "3.2%" in p
        assert "2 мес" in p
        assert "снижается" in p

    def test_insufficient_when_not_gated(self) -> None:
        assert reg._build_phrase(x_pct=-3.2, best_lag=2, gated=False) == reg._PHRASE_INSUFFICIENT

    def test_insufficient_when_none(self) -> None:
        assert reg._build_phrase(x_pct=None, best_lag=2, gated=True) == reg._PHRASE_INSUFFICIENT
        assert reg._build_phrase(x_pct=-3.2, best_lag=None, gated=True) == reg._PHRASE_INSUFFICIENT


# --------------------------------------------------------------------------- #
# compute_district_rate_regression — DB orchestrator (mocked)
# --------------------------------------------------------------------------- #


class _FakeMacro:
    def __init__(self, month: dt.date, key_rate: float | None) -> None:
        self.month = month
        self.key_rate = key_rate


class _FakeSales:
    def __init__(self, months: list[dt.date], units: list[int]) -> None:
        self.months = months
        self.units = units


def _months(n: int) -> list[dt.date]:
    out: list[dt.date] = []
    y, m = 2021, 1
    for _ in range(n):
        out.append(dt.date(y, m, 1))
        m += 1
        if m == 13:
            m = 1
            y += 1
    return out


class TestComputeDistrictRateRegression:
    def test_orchestrator_wires_macro_and_sales(self, monkeypatch: pytest.MonkeyPatch) -> None:
        # Build a macro key_rate series whose Δ drives a lag-2 demand response, then
        # confirm the orchestrator assembles X (Δrate) and Y (Δln units), aligns
        # them, and recovers the injected lag via the pure fit. (The orchestrator
        # uses the module-default max_lag=6 internally.)
        n = 60
        months = _months(n)
        # key_rate levels: integrate the aperiodic Δ so _delta() recovers them.
        xdelta = _aperiodic_rate_deltas(n, seed=13)
        levels: list[float] = []
        acc = 10.0
        for d in xdelta:
            acc += d
            levels.append(acc)
        macro = [_FakeMacro(months[i], levels[i]) for i in range(n)]

        # Units carrying the lag-2 signal: ln(u_t) = ln(base) + Σ_{k≤t} β·Δrate[k-lag].
        beta_scalar = -0.05
        lag = 2
        ln_u = math.log(1000.0)
        units: list[int] = []
        for t in range(n):
            if t > 0:
                src = xdelta[t - lag] if t - lag >= 0 else 0.0
                ln_u += beta_scalar * src
            units.append(max(1, round(math.exp(ln_u))))
        sales = _FakeSales(months, units)

        monkeypatch.setattr(reg, "get_monthly_macro", lambda db, months_back: macro)
        monkeypatch.setattr(reg, "build_sales_series", lambda db, spec, source, months_back: sales)

        res = reg.compute_district_rate_regression(
            object(),  # type: ignore[arg-type]
            district="Академический",
            months_back=n,
        )
        assert res.segment["district"] == "Академический"
        assert res.source == "regression"
        # The single-lag injection at lag 2 → Almon shape peaks near lag 2.
        assert res.best_lag_months in (1, 2, 3)
        assert res.coef is not None and res.coef < 0
        assert res.n >= reg._MIN_OBS

    def test_orchestrator_graceful_on_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setattr(reg, "get_monthly_macro", lambda db, months_back: [])
        monkeypatch.setattr(
            reg,
            "build_sales_series",
            lambda db, spec, source, months_back: _FakeSales([], []),
        )
        res = reg.compute_district_rate_regression(
            object(),  # type: ignore[arg-type]
            district="Пустой",
        )
        assert res.source == "fallback"
        assert res.phrase == reg._PHRASE_INSUFFICIENT


# --------------------------------------------------------------------------- #
# compute_rate_regime_sensitivity — production adapter (DistributedLagFit →
# RateSensitivity) wiring the validated Almon-ADL estimator into the §9.6 consumers
# --------------------------------------------------------------------------- #

_ADAPT_REG = "app.services.forecasting.regression.compute_district_rate_regression"


def _fit(
    *,
    coef: float | None,
    x_pct: float | None,
    best_lag: int | None,
    r2: float | None,
    n: int,
    source: str,
    phrase: str = "phrase",
    segment: dict[str, str | None] | None = None,
) -> reg.DistributedLagFit:
    """A REAL DistributedLagFit (the dataclass the adapter actually receives).

    Not a hand-faked attribute bag: building the real frozen dataclass guarantees
    the adapter mapping is tested against the true #978 contract and breaks loudly
    if a field is renamed.
    """
    return reg.DistributedLagFit(
        segment=segment or {"district": "Академический", "obj_class": None},
        best_lag_months=best_lag,
        coef=coef,
        x_pct=x_pct,
        r2=r2,
        n=n,
        per_lag_coef=None,
        hac_se=None,
        hac_bandwidth=None,
        almon_degree=2,
        source=source,
        phrase=phrase,
    )


class TestComputeRateRegimeSensitivity:
    def test_regression_source_maps_fields_and_medium(self) -> None:
        # 'regression' (gate passed) → beta==coef (long-run Σβ), x_pct/y_lag/phrase
        # mapped through, confidence 'medium' (advisory-grade, never 'high').
        fit = _fit(
            coef=-0.12,
            x_pct=-11.3,
            best_lag=2,
            r2=0.42,
            n=40,
            source="regression",
            phrase="спрос снижается …",
        )
        with patch(_ADAPT_REG, return_value=fit) as reg_mock:
            out = reg.compute_rate_regime_sensitivity(
                MagicMock(), spec=SegmentSpec(district="Академический", obj_class="комфорт")
            )
        assert isinstance(out, RateSensitivity)
        assert out.beta == -0.12  # beta ← fit.coef (Almon long-run multiplier)
        assert out.x_pct == -11.3
        assert out.y_lag_months == 2
        assert out.phrase == "спрос снижается …"
        assert out.confidence == "medium"
        assert out.r2 == 0.42
        assert out.n_obs == 40
        # Source-B-only fields have no analogue in a district×class fit.
        assert out.z_area_floor is None
        assert out.most_sensitive_bucket is None
        # Adapter forwarded district + obj_class to the district regression.
        call = reg_mock.call_args
        assert call.kwargs["district"] == "Академический"
        assert call.kwargs["obj_class"] == "комфорт"

    def test_fallback_source_maps_none_and_low(self) -> None:
        # 'fallback' (degrade) → beta is None, x_pct None, confidence 'low'; phrase
        # is the insufficient phrase carried from the fit.
        fit = _fit(
            coef=None,
            x_pct=None,
            best_lag=None,
            r2=0.04,
            n=12,
            source="fallback",
            phrase=reg._PHRASE_INSUFFICIENT,
        )
        with patch(_ADAPT_REG, return_value=fit):
            out = reg.compute_rate_regime_sensitivity(
                MagicMock(), spec=SegmentSpec(district="Академический")
            )
        assert out.beta is None
        assert out.x_pct is None
        assert out.y_lag_months is None
        assert out.confidence == "low"
        assert out.phrase == reg._PHRASE_INSUFFICIENT
        # Diagnostic numbers still surface (n carried through).
        assert out.n_obs == 12

    def test_district_none_short_circuits_low_no_call(self) -> None:
        # spec.district is None → adapter must NOT call the district regression (it
        # requires a str) and degrades to a low-confidence, beta=None result.
        with patch(_ADAPT_REG) as reg_mock:
            out = reg.compute_rate_regime_sensitivity(
                MagicMock(), spec=SegmentSpec(obj_class="комфорт")
            )
        reg_mock.assert_not_called()
        assert out.beta is None
        assert out.x_pct is None
        assert out.confidence == "low"
        assert out.phrase == reg._PHRASE_INSUFFICIENT
        # Segment dict still reflects the spec (shape preserved for consumers).
        assert out.segment == SegmentSpec(obj_class="комфорт").as_dict()

    def test_internal_failure_degrades_not_crash(self) -> None:
        # Defensive guard: if the (graceful-by-contract) district regression still
        # raises unexpectedly, the adapter degrades to insufficient, never crashes.
        with patch(_ADAPT_REG, side_effect=RuntimeError("boom")):
            out = reg.compute_rate_regime_sensitivity(
                MagicMock(), spec=SegmentSpec(district="Академический")
            )
        assert out.beta is None
        assert out.confidence == "low"
        assert out.phrase == reg._PHRASE_INSUFFICIENT

    def test_segment_carries_full_spec_shape(self) -> None:
        # The returned segment mirrors spec.as_dict() (4-axis shape the consumers
        # already serialise), not the 2-key regression segment.
        fit = _fit(coef=-0.05, x_pct=-4.9, best_lag=1, r2=0.3, n=33, source="regression")
        spec = SegmentSpec(obj_class="бизнес", room_bucket="2-к 45-60", district="X")
        with patch(_ADAPT_REG, return_value=fit):
            out = reg.compute_rate_regime_sensitivity(MagicMock(), spec=spec)
        assert out.segment == spec.as_dict()
        assert set(out.segment) == {"obj_class", "room_bucket", "district", "price_bucket"}

    def test_uses_real_fit_via_synthetic_series_end_to_end(self, monkeypatch) -> None:  # type: ignore[no-untyped-def]
        # Strongest contract check: run the adapter over the REAL compute_district_
        # rate_regression on a synthetic lag-2 series (no faking of the fit object) →
        # confidence 'medium', beta == the real long-run coef, x_pct negative.
        n = 60
        months = _months(n)
        xdelta = _aperiodic_rate_deltas(n, seed=13)
        levels: list[float] = []
        acc = 10.0
        for d in xdelta:
            acc += d
            levels.append(acc)
        macro = [_FakeMacro(months[i], levels[i]) for i in range(n)]
        beta_scalar, lag = -0.05, 2
        ln_u = math.log(1000.0)
        units: list[int] = []
        for t in range(n):
            if t > 0:
                src = xdelta[t - lag] if t - lag >= 0 else 0.0
                ln_u += beta_scalar * src
            units.append(max(1, round(math.exp(ln_u))))
        sales = _FakeSales(months, units)
        monkeypatch.setattr(reg, "get_monthly_macro", lambda db, months_back: macro)
        monkeypatch.setattr(reg, "build_sales_series", lambda db, spec, source, months_back: sales)

        out = reg.compute_rate_regime_sensitivity(
            object(),  # type: ignore[arg-type]
            spec=SegmentSpec(district="Академический"),
            months_back=n,
        )
        assert out.confidence == "medium"
        assert out.beta is not None and out.beta < 0
        assert out.x_pct is not None and out.x_pct < 0
        assert out.y_lag_months in (1, 2, 3)
