"""§9.6 distributed-lag регрессия спроса района на ключевую ставку (Almon / ADL).

Forgejo #978 (#951-B), §9.6 «lagged key_rate → demand». DoD требует *настоящую*
distributed-lag модель отклика месячного спроса района на key_rate при лагах 0..6,
а НЕ текущий single-lag OLS (`rate_sensitivity.best_lag` берёт ОДИН лучший лаг) и
НЕ unconstrained free-lags (7 коллинеарных лагов Δrate → раздутые дисперсии,
скачущие знаки — оценка непригодна на коротком месячном ряде).

ПОДХОД — Almon polynomial distributed lag (Almon 1965; Stock & Watson, DL):
накладываем НИЗКОСТЕПЕННОЙ полином на 7 лаговых коэффициентов β_0..β_6,
  β_j = Σ_{p=0..d} γ_p · j^p           (d = _ALMON_DEGREE, по умолчанию 2),
и оцениваем d+1 параметр γ (а не 7 свободных β) через OLS на Almon-преобразованных
регрессорах z_p[t] = Σ_j j^p · x[t−j]. Это резко снижает коллинеарность (3 гладких
параметра вместо 7 шумных) — стандартный приём для коротких лаговых рядов. Per-lag
β_j реконструируем обратно из γ. Альтернатива ADL(p,q) задокументирована, но Almon
выбран: он напрямую даёт ФОРМУ отклика по лагам, нужную для фразы §9.6.

HAC (Newey-West) стандартные ошибки — РУЧНОЙ numpy, БЕЗ statsmodels (тяжёлая
зависимость + пересборка prod-образа; ручной NW ~30 строк). Δln-остатки на
месячном ряде автокоррелированы (перекрытие лаговых окон + инерция спроса) →
обычные OLS-SE занижены. NW-ковариация = взвешенная сумма автоковариаций остатков
с окном Бартлетта; bandwidth L = floor(4·(n/100)^(2/9)) (Newey-West 1994 rule).

ВЫХОД на район: `{best_lag_months, coef, r2, n, ...}` (см. DistributedLagFit).
  • best_lag_months — лаг с пиком |β_j| из ОЦЕНЁННОЙ Almon-формы (момент сильнейшего
    отклика; для фразы §9.6 «через Y мес»).
  • coef — long-run (кумулятивный) мультипликатор Σ_j β_j на Δln: суммарный
    %-эффект от удержания ставки на +1 п.п. (а не отклик одного месяца). Документ:
    coef = долгосрочный мультипликатор; per_lag_coef несёт всю форму.

GATE (зеркало analytics_queries._elasticity_coef, L1826-1852: n≥30 ∧ R²≥0.1 ∧
slope<0 иначе fallback). Адаптация — gate смотрит на ЗНАК long-run β (ЦБ ↑ставку →
спрос ↓ → Σβ<0); n<30 ИЛИ R²<0.1 → degrade (source='fallback', claim не делаем).
Дух forecasting-модулей: PURE/детерминированно, graceful-on-thin-data, без LLM.

ADVISORY: §9.6-стек советующий (как rate_sensitivity). Модуль самостоятелен —
ПОДКЛЮЧЕНИЕ к §9.6-консьюмеру отложено (точка интеграции — в docstring
compute_district_rate_regression), чтобы не задеть рабочий best_lag-путь
(product_scoring / demand_normalization / demand_supply_forecast зовут
compute_rate_sensitivity). Зеркалит дисциплину #979 (ship module + tests + note).

psycopg v3 / SQLAlchemy text: bind ВСЕГДА через CAST(:x AS type) — НИКОГДА :x::type.
"""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from datetime import date
from typing import Any, Literal

import numpy as np
from sqlalchemy.orm import Session

from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
from app.services.forecasting.sales_series import (
    SegmentSpec,
    build_sales_series,
    log_diff,
)

logger = logging.getLogger(__name__)

# ── Named-константы ───────────────────────────────────────────────────────────

# Глубина ряда по умолчанию (месяцев назад) — зеркалит _DEFAULT_MONTHS_BACK
# rate_sensitivity / macro_series (48 ≈ 4 года): §9.6 join-ит demand↔macro
# месяц-в-месяц, окна одной длины.
_DEFAULT_MONTHS_BACK: int = 48

# Максимальный лаг key_rate (мес). 0..6 — полугодовое окно отклика спроса на
# смену ставки (ипотека/сделки оформляются месяцами; полугодовой хвост ловит
# долгий эффект). Совпадает с верхней границей _LAGS rate_sensitivity (там {0,1,2,3,6}).
_MAX_LAG: int = 6

# Степень полинома Алмона над 7 лаговыми коэффициентами. 2 (квадратичная) —
# стандартный минимум, дающий «горб» (рост→пик→спад) отклика: реакция спроса
# нарастает, достигает максимума через несколько месяцев, затухает. deg<7
# (=число лагов) — суть Алмона: 3 гладких параметра вместо 7 шумных коллинеарных.
_ALMON_DEGREE: int = 2

# GATE-пороги (зеркало _elasticity_coef L1856): n≥30 строк ∧ R²≥0.1 ∧ верный знак
# (для DL — long-run Σβ<0). Здесь одна «строка» = один Δln-МЕСЯЦ с полным набором
# лагов; на 48-мес окне их ≤ ~41 (минус _MAX_LAG на разогрев лагов и дыры).
_MIN_OBS: int = 30
_MIN_R2: float = 0.1

# Минимум наблюдений, ниже которого Almon-OLS вообще не пытаемся (нужно > числа
# параметров d+1 с запасом на остаточные степени свободы для R²/HAC). 8 ≈ дух
# rate_sensitivity._MIN_OBS — но это лишь «можно ли фитить», НЕ gate-порог для
# claim (тот — _MIN_OBS=30 выше).
_MIN_FIT_OBS: int = 8

# Текст §9.6 (НЕ LLM) — шаблон фразы из оценённой лаговой формы.
_PHRASE_TEMPLATE: str = (
    "При росте ключевой ставки на 1 п.п. спрос снижается в среднем на {x}% "
    "(пик эффекта через {y} мес.)."
)
_PHRASE_INSUFFICIENT: str = (
    "недостаточно данных для distributed-lag оценки чувствительности к ставке"
)

# Survivorship-FREE помесячный агрегат сделок (зеркало rate_sensitivity._SOURCE_A);
# Literal — чтобы build_sales_series принял его как SalesSource без приведения.
_SOURCE_A: Literal["corpus_room_month"] = "corpus_room_month"


@dataclass(frozen=True)
class DistributedLagFit:
    """Результат Almon distributed-lag регрессии Δln(demand) ~ Δrate[0..K].

    Детерминированный. Числовые поля = None при недостатке данных / провале gate
    (никогда 0-как-заглушка). `phrase` ВСЕГДА заполнена. ADVISORY до подключения.

    coef — LONG-RUN (кумулятивный) мультипликатор Σ_j β_j на Δln: суммарный
    %-эффект (в exp-масштабе через x_pct) от удержания ставки на +1 п.п. Полную
    ФОРМУ отклика по лагам несёт per_lag_coef; пик |β_j| → best_lag_months.
    """

    segment: dict[str, str | None]
    best_lag_months: int | None  # лаг пика |β_j| оценённой формы (момент сильнейшего отклика)
    coef: float | None  # long-run Σ_j β_j на Δln (кумулятивный мультипликатор)
    x_pct: float | None  # 100·(exp(coef)−1): %-эффект на +1 п.п. (NEGATIVE при ↓)
    r2: float | None  # R² distributed-lag регрессии
    n: int  # число использованных наблюдений (полных Δln-месяцев с лагами)
    per_lag_coef: tuple[float, ...] | None  # β_0..β_K из Almon-формы (вся форма отклика)
    hac_se: tuple[float, ...] | None  # Newey-West SE для β_0..β_K (ручной NW)
    hac_bandwidth: int | None  # окно Бартлетта L, на котором считались HAC SE
    almon_degree: int  # степень полинома Алмона
    source: str  # 'regression' (gate пройден) | 'fallback' (degrade)
    phrase: str

    def as_dict(self) -> dict[str, Any]:
        return {
            "segment": dict(self.segment),
            "best_lag_months": self.best_lag_months,
            "coef": _round_or_none(self.coef, 4),
            "x_pct": _round_or_none(self.x_pct, 1),
            "r2": _round_or_none(self.r2, 4),
            "n": self.n,
            "per_lag_coef": (
                [round(c, 4) for c in self.per_lag_coef] if self.per_lag_coef is not None else None
            ),
            "hac_se": ([round(s, 4) for s in self.hac_se] if self.hac_se is not None else None),
            "hac_bandwidth": self.hac_bandwidth,
            "almon_degree": self.almon_degree,
            "source": self.source,
            "phrase": self.phrase,
        }


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


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


def _build_lag_matrix(
    x: list[float | None], y: list[float | None], *, max_lag: int
) -> tuple[np.ndarray, np.ndarray] | None:
    """Собрать матрицу лагов регрессора и выровненный y, дропнув неполные строки.

    Для каждого месяца t строим вектор регрессоров [x[t], x[t−1], …, x[t−max_lag]]
    и спариваем с y[t]. Строку используем ТОЛЬКО если y[t] и ВСЕ max_lag+1 лаговых
    значений конечны (None/NaN/Inf в любом лаге → строку дропаем: distributed-lag
    требует полный лаговый профиль, частичный сместил бы оценку). PURE, без БД.

    Args:
        x: регрессор по месяцам (обычно Δrate), None-дыры ок.
        y: зависимая (Δln(demand)) по тем же месяцам, None-дыры ок.
        max_lag: максимальный лаг (включительно) — матрица имеет max_lag+1 столбец.

    Returns:
        (X, yv): X формы (n, max_lag+1) [lag0..lagK], yv формы (n,). None если
        ни одной полной строки (n=0).
    """
    n_months = min(len(x), len(y))
    rows: list[list[float]] = []
    ys: list[float] = []
    for t in range(max_lag, n_months):
        yv = y[t]
        if yv is None:
            continue
        yf = float(yv)
        if not math.isfinite(yf):
            continue
        lagvec: list[float] = []
        ok = True
        for j in range(max_lag + 1):
            xv = x[t - j]
            if xv is None:
                ok = False
                break
            xf = float(xv)
            if not math.isfinite(xf):
                ok = False
                break
            lagvec.append(xf)
        if not ok:
            continue
        rows.append(lagvec)
        ys.append(yf)

    if not rows:
        return None
    return np.asarray(rows, dtype=float), np.asarray(ys, dtype=float)


def _almon_basis(max_lag: int, degree: int) -> np.ndarray:
    """Матрица Almon-весов W формы (max_lag+1, degree+1): W[j, p] = j^p.

    β_j = Σ_p γ_p · j^p = (W @ γ)[j]. Преобразование регрессоров: если X — матрица
    лагов (n, max_lag+1), то Almon-регрессоры Z = X @ W (n, degree+1), и OLS y~Z
    даёт γ; per-lag β = W @ γ. j^0 столбец = 1 (intercept полинома → β-уровень).
    PURE.
    """
    lags = np.arange(max_lag + 1, dtype=float)
    return np.vander(lags, N=degree + 1, increasing=True)


def _ols_lstsq(z: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
    """OLS y ~ [1, Z] через numpy lstsq → (coef_with_intercept, residuals). PURE.

    Добавляем столбец-константу (свободный член регрессии — НЕ Almon-уровень).
    Возвращает None при недостатке наблюдений (n ≤ #параметров → нет степеней
    свободы) или вырожденной (rank-deficient) матрице плана.

    Returns:
        (coef, resid): coef[0] = intercept, coef[1:] = γ; resid = y − ŷ. None если
        фит невозможен.
    """
    n = z.shape[0]
    design = np.column_stack([np.ones(n), z])
    k = design.shape[1]
    if n <= k:
        return None
    # rank-проверка: коллинеарный план → оценка γ не определена однозначно.
    if np.linalg.matrix_rank(design) < k:
        return None
    coef, _res, _rank, _sv = np.linalg.lstsq(design, y, rcond=None)
    resid = y - design @ coef
    return coef, resid


def _r2(y: np.ndarray, resid: np.ndarray) -> float | None:
    """R² = 1 − SS_res/SS_tot. None при нулевой дисперсии y (R² не определён). PURE."""
    ss_res = float(np.sum(resid**2))
    ss_tot = float(np.sum((y - float(np.mean(y))) ** 2))
    if ss_tot == 0.0:
        return None
    return 1.0 - ss_res / ss_tot


def newey_west_bandwidth(n: int) -> int:
    """Окно Бартлетта L для Newey-West по правилу floor(4·(n/100)^(2/9)).

    Newey-West (1994) automatic bandwidth: растёт с n, но медленно. Нижняя
    граница 1 (нужна хотя бы lag-1 автоковариация, иначе HAC == обычная OLS).
    PURE.
    """
    if n <= 1:
        return 0
    bw: int = math.floor(4.0 * (n / 100.0) ** (2.0 / 9.0))
    return max(1, bw)


def newey_west_cov(x_design: np.ndarray, resid: np.ndarray, *, bandwidth: int) -> np.ndarray:
    """HAC (Newey-West) ковариация оценок OLS — РУЧНОЙ numpy, БЕЗ statsmodels.

    Heteroskedasticity-and-autocorrelation-consistent ковариация:
        V = (X'X)^{-1} · S · (X'X)^{-1},   S = Σ_0 + Σ_{l=1..L} w_l (Σ_l + Σ_l'),
    где Σ_0 = Σ_t u_t² x_t x_t' (мясо White/HC0), Σ_l = Σ_t u_t u_{t−l} x_t x_{t−l}'
    (lag-l автоковариация моментов), w_l = 1 − l/(L+1) — вес Бартлетта (гарантирует
    положительную полуопределённость S). u_t — остатки, x_t — строка плана.

    Δln-остатки месячного спроса автокоррелированы (перекрытие лаговых окон +
    инерция) → обычные OLS-SE занижены; NW их корректирует. PURE, без БД.

    Args:
        x_design: матрица плана (n, k) — та же [1, Z], что в _ols_lstsq.
        resid: остатки OLS (n,).
        bandwidth: окно Бартлетта L (≥0); 0 → только Σ_0 (== White HC0).

    Returns:
        Ковариационная матрица (k, k). При вырожденной X'X — псевдообратная
        (graceful, не crash).
    """
    n = x_design.shape[0]
    xtx = x_design.T @ x_design
    try:
        xtx_inv = np.linalg.inv(xtx)
    except np.linalg.LinAlgError:  # вырожденная X'X → псевдообратная (graceful)
        xtx_inv = np.linalg.pinv(xtx)

    u = resid.reshape(-1, 1)
    ux = x_design * u  # (n, k): строка t = u_t · x_t
    # Σ_0 — «мясо» White (HC0).
    s = ux.T @ ux
    l_bw = max(0, min(bandwidth, n - 1))  # эффективное окно Бартлетта (L)
    for lag in range(1, l_bw + 1):
        w = 1.0 - lag / (l_bw + 1.0)  # вес Бартлетта
        gamma = ux[lag:].T @ ux[:-lag]  # Σ_t u_t u_{t-l} x_t x_{t-l}'
        s = s + w * (gamma + gamma.T)
    cov: np.ndarray = xtx_inv @ s @ xtx_inv
    return cov


def _peak_lag(per_lag: np.ndarray) -> int:
    """Лаг с максимальным |β_j| — момент сильнейшего отклика спроса. PURE.

    При нескольких равных максимумах берём ПЕРВЫЙ (наименьший лаг — самый ранний
    пик). Для фразы §9.6 «через Y мес».
    """
    return int(np.argmax(np.abs(per_lag)))


def _x_pct_from_coef(coef: float) -> float:
    """Long-run β на Δln → %-эффект на +1 п.п. ставки: 100·(exp(β)−1). PURE.

    β<0 → отрицательный % (спрос падает). exp т.к. Y=Δln (мультипликативный
    масштаб). Зеркало rate_sensitivity._x_pct_from_beta.
    """
    return 100.0 * (math.exp(coef) - 1.0)


def fit_almon_dl(
    x: list[float | None],
    y: list[float | None],
    *,
    max_lag: int = _MAX_LAG,
    degree: int = _ALMON_DEGREE,
    min_obs: int = _MIN_OBS,
    min_r2: float = _MIN_R2,
) -> dict[str, Any] | None:
    """Almon polynomial distributed-lag фит Δln(demand) ~ Δrate[0..max_lag]. PURE.

    Шаги:
      1. Собрать матрицу лагов X (n, max_lag+1) + выровненный y, дропнув строки с
         неполным лаговым профилем (_build_lag_matrix).
      2. Almon-преобразование Z = X @ W (W[j,p]=j^p) → degree+1 регрессоров вместо
         max_lag+1 коллинеарных. OLS y~[1,Z] (lstsq) → intercept + γ.
      3. Реконструкция per-lag β = W @ γ; long-run Σβ; R²; пик-лаг.
      4. HAC (Newey-West) ковариация на плане [1,Z] → SE для γ; SE для per-lag β
         через delta-method (J = [0|W], V_β = J·V_γ·J').

    Возвращает dict (см. ключи ниже) либо None если фит невозможен (n < запас по
    степеням свободы / вырожденный план / нулевая дисперсия y). GATE (n≥min_obs ∧
    R²≥min_r2 ∧ Σβ<0) здесь НЕ применяется — это делает оркестратор (чтобы pure-фит
    был переиспользуем и для диагностики «почти прошёл»).

    Args:
        x: регрессор по месяцам (Δrate), None-дыры ок.
        y: зависимая (Δln(demand)) по тем же месяцам, None-дыры ок.
        max_lag: макс. лаг (включительно).
        degree: степень полинома Алмона (< max_lag+1).
        min_obs / min_r2: пороги для УДОБСТВА вызывающего (возвращаются в dict как
            gate_n_ok / gate_r2_ok), сам фит ими не отсекается.

    Returns:
        dict с ключами: per_lag_coef (tuple), long_run_coef (float), best_lag (int),
        r2 (float|None), n (int), hac_se (tuple), hac_bandwidth (int),
        intercept (float), gate_n_ok (bool), gate_r2_ok (bool), gate_sign_ok (bool).
        None если фит математически невозможен.
    """
    if degree >= max_lag + 1:
        # Полином не должен иметь параметров ≥ числа лагов — иначе это не
        # ограничение (вырождается в free-lags). Защита от мисконфига.
        logger.warning("fit_almon_dl: degree=%d >= max_lag+1=%d — refusing", degree, max_lag + 1)
        return None

    built = _build_lag_matrix(x, y, max_lag=max_lag)
    if built is None:
        return None
    x_lags, yv = built
    n = int(x_lags.shape[0])
    if n < _MIN_FIT_OBS:
        return None
    # Нулевая дисперсия зависимой → R²/наклоны не определены.
    if float(np.var(yv)) == 0.0:
        return None

    w = _almon_basis(max_lag, degree)  # (max_lag+1, degree+1)
    z = x_lags @ w  # (n, degree+1) — Almon-регрессоры
    fit = _ols_lstsq(z, yv)
    if fit is None:
        return None
    coef, resid = fit  # coef = [intercept, γ_0..γ_d]
    intercept = float(coef[0])
    gamma = coef[1:]
    per_lag = w @ gamma  # (max_lag+1,) — реконструированные β_j
    long_run = float(np.sum(per_lag))
    r2 = _r2(yv, resid)
    best_lag = _peak_lag(per_lag)

    # HAC (Newey-West) на ТОМ ЖЕ плане [1, Z].
    design = np.column_stack([np.ones(n), z])
    bw = newey_west_bandwidth(n)
    cov = newey_west_cov(design, resid, bandwidth=bw)
    # SE per-lag β через delta-method: β = W·γ = J·coef, J = [0_col | W] (intercept
    # не входит в β). V_β = J·cov·J'; диагональ ≥0 → sqrt (отрицательные FP-края → 0).
    j = np.column_stack([np.zeros((max_lag + 1, 1)), w])  # (max_lag+1, degree+2)
    cov_beta = j @ cov @ j.T
    var_beta = np.clip(np.diag(cov_beta), a_min=0.0, a_max=None)
    hac_se = tuple(float(s) for s in np.sqrt(var_beta))

    gate_n_ok = n >= min_obs
    gate_r2_ok = r2 is not None and r2 >= min_r2
    gate_sign_ok = long_run < 0.0

    return {
        "per_lag_coef": tuple(float(c) for c in per_lag),
        "long_run_coef": long_run,
        "best_lag": best_lag,
        "r2": r2,
        "n": n,
        "hac_se": hac_se,
        "hac_bandwidth": bw,
        "intercept": intercept,
        "gate_n_ok": gate_n_ok,
        "gate_r2_ok": gate_r2_ok,
        "gate_sign_ok": gate_sign_ok,
    }


def _build_phrase(*, x_pct: float | None, best_lag: int | None, gated: bool) -> str:
    """Фраза §9.6 (НЕ LLM) из оценённой лаговой формы. PURE.

    gate провален / нет valid эффекта → «недостаточно данных…». Иначе:
    «при росте ставки +1 п.п. спрос снижается на X% (пик через Y мес)». X —
    положительная МАГНИТУДА %-эффекта (long-run), Y — пик-лаг.
    """
    if not gated or x_pct is None or best_lag is None:
        return _PHRASE_INSUFFICIENT
    return _PHRASE_TEMPLATE.format(x=round(abs(x_pct), 1), y=best_lag)


def _insufficient(segment: dict[str, str | None], *, n: int = 0) -> DistributedLagFit:
    """Граничный результат «недостаточно данных» (fallback, фраза-заглушка). PURE."""
    return DistributedLagFit(
        segment=segment,
        best_lag_months=None,
        coef=None,
        x_pct=None,
        r2=None,
        n=n,
        per_lag_coef=None,
        hac_se=None,
        hac_bandwidth=None,
        almon_degree=_ALMON_DEGREE,
        source="fallback",
        phrase=_PHRASE_INSUFFICIENT,
    )


def build_fit_result(
    x: list[float | None],
    y: list[float | None],
    *,
    segment: dict[str, str | None],
    max_lag: int = _MAX_LAG,
    degree: int = _ALMON_DEGREE,
    min_obs: int = _MIN_OBS,
    min_r2: float = _MIN_R2,
) -> DistributedLagFit:
    """Прогнать Almon-DL фит и обернуть в DistributedLagFit с gate-деградацией. PURE.

    GATE (зеркало _elasticity_coef): n≥min_obs ∧ R²≥min_r2 ∧ long-run Σβ<0 →
    source='regression' (claim). Иначе → degrade: source='fallback', фраза
    «недостаточно данных», но per_lag_coef/r2/n СОХРАНЯЕМ для диагностики (как
    _elasticity_coef возвращает r2/n в fallback). НЕ crash на тонких данных.

    Это чистая обёртка (без БД) — тестируется на синтетике с известным лагом.
    """
    fit = fit_almon_dl(x, y, max_lag=max_lag, degree=degree, min_obs=min_obs, min_r2=min_r2)
    if fit is None:
        return _insufficient(segment)

    n = int(fit["n"])
    gated = bool(fit["gate_n_ok"] and fit["gate_r2_ok"] and fit["gate_sign_ok"])
    long_run = float(fit["long_run_coef"])
    r2 = fit["r2"]
    per_lag = tuple(fit["per_lag_coef"])
    hac_se = tuple(fit["hac_se"])
    best_lag = int(fit["best_lag"])

    if not gated:
        # Degrade: сохраняем числа для диагностики, но source='fallback' и фраза-заглушка.
        logger.info(
            "regression: gate failed (segment=%s n=%d r2=%s long_run=%.4f "
            "n_ok=%s r2_ok=%s sign_ok=%s) → fallback",
            segment,
            n,
            None if r2 is None else round(r2, 4),
            long_run,
            fit["gate_n_ok"],
            fit["gate_r2_ok"],
            fit["gate_sign_ok"],
        )
        return DistributedLagFit(
            segment=segment,
            best_lag_months=None,
            coef=None,
            x_pct=None,
            r2=_round_or_none(r2, 4),
            n=n,
            per_lag_coef=per_lag,
            hac_se=hac_se,
            hac_bandwidth=int(fit["hac_bandwidth"]),
            almon_degree=degree,
            source="fallback",
            phrase=_PHRASE_INSUFFICIENT,
        )

    x_pct = _x_pct_from_coef(long_run)
    phrase = _build_phrase(x_pct=x_pct, best_lag=best_lag, gated=True)
    logger.info(
        "regression(OK): segment=%s long_run=%.4f x_pct=%.1f best_lag=%d r2=%.4f n=%d bw=%d",
        segment,
        long_run,
        x_pct,
        best_lag,
        r2 if r2 is not None else float("nan"),
        n,
        int(fit["hac_bandwidth"]),
    )
    return DistributedLagFit(
        segment=segment,
        best_lag_months=best_lag,
        coef=long_run,
        x_pct=x_pct,
        r2=r2,
        n=n,
        per_lag_coef=per_lag,
        hac_se=hac_se,
        hac_bandwidth=int(fit["hac_bandwidth"]),
        almon_degree=degree,
        source="regression",
        phrase=phrase,
    )


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


def _align_demand_deltas(
    sales_months: list[date], sales_units: list[int], macro_months: list[date]
) -> list[float | None]:
    """Выровнять Δln(units) спроса по сетке макро-месяцев (общая ось X↔Y).

    Зеркало rate_sensitivity._align_sales_deltas: log_diff даёт Δln по сетке
    ПРОДАЖ, перекладываем на macro_months (месяц без продаж → None), чтобы пары
    (Δrate[t−L], Δln[t]) были month-в-month и лаговая матрица строилась по единой
    временной оси. PURE.

    NB (#979 дух): дессзонивание ПЕРЕД log_diff здесь НЕ применяется — та же
    оговорка, что в rate_sensitivity._align_sales_deltas (на коротком ряде ratio-
    to-mean фактор смещает восстановленный лаг). Отложено той же задачей.
    """
    deltas = log_diff(sales_units)
    by_month = dict(zip(sales_months, deltas, strict=False))
    return [by_month.get(m) for m in macro_months]


def compute_district_rate_regression(
    db: Session,
    *,
    district: str,
    obj_class: str | None = None,
    months_back: int = _DEFAULT_MONTHS_BACK,
    max_lag: int = _MAX_LAG,
    degree: int = _ALMON_DEGREE,
) -> DistributedLagFit:
    """§9.6 Almon distributed-lag регрессия месячного спроса РАЙОНА на key_rate.

    Constrained DL (Almon, deg `degree`) Δln(demand_district) ~ Δkey_rate при лагах
    0..max_lag, с реконструкцией per-lag β и HAC (Newey-West) SE. GATE зеркалит
    _elasticity_coef (n≥30 ∧ R²≥0.1 ∧ long-run Σβ<0 иначе fallback). ДЕТЕРМИНИРОВАНО.

    Данные:
      • key_rate — get_monthly_macro (PR2), Δ первой разностью (_delta) → X-ось.
      • спрос района — build_sales_series Source A (objective_corpus_room_month,
        survivorship-FREE помесячный агрегат сделок), Δln (log_diff) → Y-ось,
        выровненная на сетку макро (_align_demand_deltas).

    Graceful-on-thin-data: пустой/тонкий ряд / провал gate → source='fallback',
    фраза «недостаточно данных…», НЕ crash (дух forecasting-модулей).

    ADVISORY + НЕ ПОДКЛЮЧЕНО (отложенная интеграция, #978 Part B):
        Точка интеграции в §9.6 — там же, где сейчас зовётся compute_rate_sensitivity
        (product_scoring._build → mortgage_sensitivity; demand_normalization;
        demand_supply_forecast explain-фраза). Подключение ОТЛОЖЕНО, чтобы не задеть
        рабочий single-lag best_lag-путь (риск регресса в трёх консьюмерах). Зеркалит
        дисциплину #979: ship module + tests + note integration point. §9.6-стек
        advisory в любом случае.

    Args:
        db: SQLAlchemy sync Session.
        district: район ЕКБ (Source A column `district`).
        obj_class: класс ЖК (None → агрегат по району); регистр нормализуется в SQL.
        months_back: глубина ряда (по умолчанию 48).
        max_lag / degree: окно лагов и степень Алмона.

    Returns:
        DistributedLagFit (всегда; фраза заполнена даже при нехватке данных).
    """
    segment: dict[str, str | None] = {"district": district, "obj_class": obj_class}

    macro = get_monthly_macro(db, months_back=months_back)
    rate_deltas = _delta([m.key_rate for m in macro])
    macro_months = [m.month for m in macro]

    spec = SegmentSpec(obj_class=obj_class, district=district)
    sales = build_sales_series(db, spec=spec, source=_SOURCE_A, months_back=months_back)
    demand_deltas = _align_demand_deltas(sales.months, sales.units, macro_months)

    return build_fit_result(
        rate_deltas,
        demand_deltas,
        segment=segment,
        max_lag=max_lag,
        degree=degree,
    )


# ──────────────────────────────────────────────────────────────────────────────
# §9.6 production adapter — wraps the validated Almon-ADL estimator behind the
# RateSensitivity contract the three §9.6 consumers already speak.
# ──────────────────────────────────────────────────────────────────────────────
#
# WHY this exists: the out-of-sample backtest (backend/scripts/backtest_rate_
# sensitivity.py) found the single-best-lag OLS (`rate_sensitivity.compute_rate_
# sensitivity`) directionally NOISE OOS (hit-rate 0.148 on Source B EKB-wide,
# worse than coin-flip), while this Almon distributed-lag estimator is strictly
# less noisy on every tier (0.407 Source B / 0.60 survivorship-free Source A).
# So the rate-regime discount (§9.4 demand_normalization), the mortgage_sensitivity
# score (§14.2 product_scoring) and the explain-phrase (§9.8 demand_supply_forecast)
# are repointed onto this estimator. REVERSIBLE: this adapter is the single seam —
# reverting the 3 call-site imports restores the old path. compute_rate_sensitivity
# stays as-is (still used by the backtest + its own tests).


def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity:
    """Low-confidence RateSensitivity with no usable β/x_pct (graceful degrade).

    Used when there is no district to fit the district×class regression on
    (spec.district is None): demand_normalization then degrades to a neutral
    norm=1.0 (applied=False) and product_scoring's mortgage_sensitivity takes the
    low-confidence path — honest, not invented. PURE.
    """
    return RateSensitivity(
        segment=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,
    )


def _fit_to_sensitivity(
    fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
) -> RateSensitivity:
    """Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.

    Field mapping (see compute_rate_regime_sensitivity docstring for the rationale):
      • beta          ← fit.coef        (LONG-RUN Σβ — see beta-semantics note below)
      • x_pct         ← fit.x_pct
      • y_lag_months  ← fit.best_lag_months
      • phrase        ← fit.phrase
      • r2 / n_obs    ← fit.r2 / fit.n
      • confidence    ← 'regression' → "medium" (gated-OK but advisory-grade) |
                        'fallback'   → "low"
      • confounded    ← passed in by the caller (computed from the ACTUAL fit window
                        via is_confounded_window — #1636). The §9.6 production path
                        OR-aggregates this with §9.5 macro_coefficient.confounded in
                        demand_supply_forecast._series_confounded → шок-фактор (#1222).
                        The 48-мес regression window overlaps the 2024-07-01 shock long
                        after the 12-мес macro window stops doing so, so hardcoding
                        False here silently dropped the shock signal on this channel.
    Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have
    no analogue in a district×class distributed-lag fit (no room×area bucketing here)
    → None / sensible defaults. PURE.

    BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier
    Σ_j β_j on Δln — the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT
    a single-lag slope. That is exactly the quantity demand_normalization wants for
    a future-regime discount (exp(β·Δrate) over a sustained Δrate), and it stays
    clamped to [0.5, 1.2] downstream so a large coef saturates rather than blows up.
    """
    confidence: Confidence = "medium" if fit.source == "regression" else "low"
    return RateSensitivity(
        segment=segment,
        x_pct=fit.x_pct,
        y_lag_months=fit.best_lag_months,
        z_area_floor=None,
        most_sensitive_bucket=None,
        beta=fit.coef,
        r2=fit.r2,
        n_obs=fit.n,
        shrinkage_weight=0.0,
        confounded=confounded,
        confidence=confidence,
        phrase=fit.phrase,
    )


@cached(
    lambda db, *, spec, months_back=_DEFAULT_MONTHS_BACK: (spec, months_back),
    label="compute_rate_regime_sensitivity",
)
def compute_rate_regime_sensitivity(
    db: Session,
    *,
    spec: SegmentSpec,
    months_back: int = _DEFAULT_MONTHS_BACK,
) -> RateSensitivity:
    """§9.6 rate sensitivity for a market segment via the Almon-ADL estimator.

    Thin adapter over `compute_district_rate_regression` (the validated #978
    distributed-lag model) that returns the existing `RateSensitivity` dataclass so
    the three §9.6 consumers (demand_normalization / product_scoring /
    demand_supply_forecast) use it with NO body changes beyond the call site.
    Replaces the OOS-noisy single-best-lag `compute_rate_sensitivity` in production.

    Confidence is capped at "medium" even on a gate-passing fit: the §9.6 stack is
    advisory until the engine is fully validated, so we never advertise "high".

    Graceful degradation (NEVER crashes):
      • spec.district is None → no district to fit the district×class regression on
        → low-confidence result with beta=None / x_pct=None and the insufficient-
        data phrase (demand_normalization → neutral, product_scoring → low). We do
        NOT call compute_district_rate_regression with district=None (it requires a
        str).
      • compute_district_rate_regression is already graceful (returns a 'fallback'
        DistributedLagFit on thin/failed data), but we still wrap it defensively and
        degrade to the insufficient result on any unexpected error.

    Args:
        db: SQLAlchemy sync Session.
        spec: target segment; `district` (and optionally `obj_class`) drive the fit.
        months_back: series depth (defaults to _DEFAULT_MONTHS_BACK).

    Returns:
        RateSensitivity (always; phrase populated even when data is insufficient).
    """
    segment = spec.as_dict()

    if spec.district is None:
        logger.info(
            "rate_regime_sensitivity: spec.district is None (segment=%s) → "
            "insufficient (district×class regression needs a district)",
            segment,
        )
        return _insufficient_sensitivity(segment)

    try:
        fit = compute_district_rate_regression(
            db,
            district=spec.district,
            obj_class=spec.obj_class,
            months_back=months_back,
        )
    except Exception:
        # compute_district_rate_regression is graceful by contract; this guard only
        # catches truly unexpected failures so the §9.6 consumers never crash on the
        # rate channel. Log with traceback (never swallow silently), then degrade.
        logger.exception(
            "rate_regime_sensitivity: district regression raised (segment=%s) → "
            "degrading to insufficient",
            segment,
        )
        return _insufficient_sensitivity(segment)

    # #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits
    # over the same macro grid as compute_district_rate_regression (get_monthly_macro,
    # months_back); re-reading it here is a cache hit (same args). The 48-мес window
    # crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this
    # is exactly the channel that was silently never raising the shock flag (#1222).
    confounded = _macro_window_confounded(db, months_back=months_back)
    return _fit_to_sensitivity(fit, segment=segment, confounded=confounded)


def _macro_window_confounded(db: Session, *, months_back: int) -> bool:
    """True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636).

    Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded
    (PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что
    использует compute_district_rate_regression → cache-hit, без лишнего запроса.
    Пустая сетка / сбой → False (нет окна — нечего конфаундить), НЕ crash.
    """
    try:
        months = [m.month for m in get_monthly_macro(db, months_back=months_back)]
    except Exception:
        logger.exception(
            "rate_regime_sensitivity: macro window read for confounded-flag failed "
            "(months_back=%d) → treating as not confounded",
            months_back,
        )
        return False
    if not months:
        return False
    return is_confounded_window(min(months), max(months))
