"""§9.6 чувствительность продаж сегмента к ключевой ставке ЦБ — X/Y/Z + фраза.

#951 (Site Finder v2 / «GG-форсайт» ТЗ §9.6), EPIC 7 «Чувствительность к ключевой
ставке», sub-PR 3 (#951d). Это **CORE-модуль** §9.6: для сегмента рынка регрессируем
месячное изменение продаж на изменение ключевой ставки ЦБ при нескольких лагах,
выбираем лучший лаг, шринкуем к EKB-широкому prior'у и собираем фразу-объяснение
§9.6. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (numpy-математика, шаблон фразы).

ADVISORY-СТАТУС: модуль остаётся СОВЕТУЮЩИМ, пока бэктест PR6 его не валидирует —
он НЕ подключается ни к одному production-эндпоинту в этом PR. Цифры пригодны для
explainability/прототипа, но НЕ для принятия решений до проверки на out-of-sample.

Конвейер (всё на месячной сетке PR1/PR2 — окна по длине совпадают):
  • Y-ось = Δln(units) сегмента (build_sales_series → log_diff, PR1). Стационарная,
    рост в %.
  • X-ось = Δ(key_rate) (get_monthly_macro, PR2). Лагируем X относительно Y, т.к.
    реакция продаж на смену ставки запаздывает (ипотека/сделки оформляются месяцами).
  • β = slope OLS(Δln(units) ~ Δrate[t-lag]); ЦБ ↑ставку → продажи ↓ → β<0.
  • x_pct = 100·(exp(β)−1): % изменения продаж на +1 п.п. ставки (NEGATIVE при ↓).
  • Z = нижняя граница площади самого чувствительного room×area-бакета (Source B).

GATE-философия (зеркало analytics_queries._elasticity_coef: n≥MIN ∧ r2≥R2_MIN ∧
slope<0 иначе FALLBACK). Адаптируем пороги под КОРОТКИЙ месячный Δ-ряд: Δln-точек
всего ~12-48 (минус дыры/нули), а НЕ 30+ строк сделок, поэтому _MIN_OBS=8 (а не 30).

SHRINKAGE: тонкий сегмент шумен → шринкуем β_seg к EKB-широкому prior'у β_EKB
весом w=n/(n+k). Мало точек → ближе к prior; много → ≈сегмент. Это снижает дисперсию
оценки на тонких сегментах (James-Stein дух), но именно поэтому модуль advisory до
бэктеста: оптимальный k подбирается на out-of-sample, текущий — эвристический.

Graceful-on-thin-data (дух market_metrics.py): пустой/тонкий ряд / нет прошедшего
gate лага / выживший неправильный знак (β≥0) → confidence='low', фраза «недостаточно
данных…», НЕ crash. Детерминированно.
"""

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.forecasting.macro_series import (
    get_monthly_macro,
    is_confounded_window,
)
from app.services.forecasting.sales_series import (
    ROOM_AREA_BUCKET_1K,
    ROOM_AREA_BUCKET_2K,
    ROOM_AREA_BUCKET_3K,
    ROOM_AREA_BUCKET_LARGE,
    ROOM_AREA_BUCKET_STUDIO,
    SegmentSpec,
    build_sales_series,
    log_diff,
)

logger = logging.getLogger(__name__)

Confidence = Literal["high", "medium", "low"]

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

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

# Лаги (мес) X относительно Y для перебора в best_lag. 0/1/2/3/6 — те же, что
# документированы в macro_at_lag (PR2): реакция продаж на ставку обычно
# запаздывает на квартал, полугодовой лаг ловит долгий ипотечный эффект.
_LAGS: tuple[int, ...] = (0, 1, 2, 3, 6)

# GATE-пороги (зеркало _elasticity_coef, но адаптированы под КОРОТКИЙ месячный
# Δ-ряд). _elasticity_coef фитит по 30+ строкам сделок; здесь же одна точка = один
# Δln-МЕСЯЦ, а их всего ~12-48 минус дыры/нули → 30 недостижимо. 8 ≈ «минимум,
# чтобы slope/R² не были чистым шумом на 2-3 точках» (полгода-плюс наблюдений).
_MIN_OBS: int = 8
# R²-минимум совпадает с _elasticity_coef (0.1): фит должен объяснять хотя бы
# ~10% дисперсии Δln, иначе связь rate↔sales не считаем установленной.
_MIN_R2: float = 0.1

# Сила шринка к EKB-prior'у: w = n/(n+k). k=10 ≈ «сегмент с 10 Δln-точками весит
# поровну с prior'ом (w=0.5)»; на ~24 точках w≈0.7 (доминирует сегмент), на тонких
# ~6 точках w≈0.375 (тянет к prior'у). Подобрано под глубину месячного ряда (≤48);
# advisory — точный k валидирует бэктест PR6.
_SHRINK_K: float = 10.0

# Confidence-пороги (зеркало духа PR1/PR2). 'high' требует И длины ряда, И силы
# фита, И доминирования сегмента над prior'ом, И отсутствия шок-окна.
_CONF_HIGH_MIN_OBS: int = 24  # ≥2 года Δln-наблюдений
_CONF_HIGH_MIN_R2: float = 0.3  # фит объясняет ≥30% дисперсии
_CONF_HIGH_MIN_WEIGHT: float = 0.7  # сегмент доминирует над prior'ом

# Порог shrinkage-веса, ниже которого per-segment β считаем недостаточно
# самостоятельным для адресной фразы → деградируем во 2-м предложении к
# EKB-широкой форме (без «наиболее чувствительны …»).
_PHRASE_SEGMENT_WEIGHT_MIN: float = 0.5

# Нижняя граница площади (м²) для room×area-бакетов Source B (Z-выход §9.6).
# Зеркало area-меток ROOM_AREA_BUCKET_* (sales_series): "Студии 15-30"→15,
# "1-к 30-45"→30, "2-к 45-60"→45, "3-к 60-80"→60, "80+ м²"→80. Держим явной
# таблицей (а не парсингом метки), чтобы переименование метки не сломало Z молча.
_BUCKET_AREA_FLOOR_M2: dict[str, float] = {
    ROOM_AREA_BUCKET_STUDIO: 15.0,
    ROOM_AREA_BUCKET_1K: 30.0,
    ROOM_AREA_BUCKET_2K: 45.0,
    ROOM_AREA_BUCKET_3K: 60.0,
    ROOM_AREA_BUCKET_LARGE: 80.0,
}

# Source B room-бакеты, по которым ищем «самый чувствительный» (Z-выход). Только
# area-меченые — unknown-бакет осмысленной площади не несёт, в Z-перебор не идёт.
_SOURCE_B_ROOM_BUCKETS: tuple[str, ...] = (
    ROOM_AREA_BUCKET_STUDIO,
    ROOM_AREA_BUCKET_1K,
    ROOM_AREA_BUCKET_2K,
    ROOM_AREA_BUCKET_3K,
    ROOM_AREA_BUCKET_LARGE,
)

# Шаблоны фразы §9.6 (НЕ LLM). Полная форма с адресным бакетом / EKB-широкая форма
# (когда per-bucket слаб) / форма «нет данных» (нет gate-лага или выжил β≥0).
_PHRASE_FULL: str = (
    "При росте ключевой ставки на 1 п.п. в аналогичных условиях продажи в этой "
    "локации снижались в среднем на {x}% через {y} месяцев. Наиболее чувствительны "
    "{bucket} площадью более {z} м²."
)
_PHRASE_EKB_WIDE: str = (
    "При росте ключевой ставки на 1 п.п. в аналогичных условиях продажи в этой "
    "локации снижались в среднем на {x}% через {y} месяцев."
)
_PHRASE_INSUFFICIENT: str = "недостаточно данных для оценки чувствительности к ставке"

_SOURCE_A: Literal["corpus_room_month"] = "corpus_room_month"
_SOURCE_B: Literal["objective_lots"] = "objective_lots"


@dataclass(frozen=True)
class RateSensitivity:
    """§9.6 чувствительность продаж сегмента к ключевой ставке ЦБ.

    Детерминированный результат. Любое числовое поле = None при недостатке данных
    (никогда 0-как-заглушка). `phrase` ВСЕГДА заполнена (в т.ч. «недостаточно
    данных…»). ADVISORY до валидации бэктестом PR6 — не для production-решений.
    """

    segment: dict[str, str | None]
    x_pct: float | None  # % изменения продаж на +1 п.п. ставки (NEGATIVE при ↓)
    y_lag_months: int | None  # лаг (мес), на котором связь сильнейшая
    z_area_floor: float | None  # нижняя граница площади самого чувствит. бакета, м²
    most_sensitive_bucket: str | None  # метка этого бакета (Source B)
    beta: float | None  # шринкнутый slope на Δln (сырой коэффициент)
    r2: float | None  # R² выигравшего лага сегмента
    n_obs: int  # число Δln-точек, по которым фитили сегмент
    shrinkage_weight: float  # w = n/(n+k) ∈ [0,1] (вес сегмента против prior'а)
    confounded: bool  # окно ряда пересекает шок-дату (PR2)
    confidence: Confidence
    phrase: str

    def as_dict(self) -> dict[str, Any]:
        return {
            "segment": dict(self.segment),
            "x_pct": _round_or_none(self.x_pct, 1),
            "y_lag_months": self.y_lag_months,
            "z_area_floor": _round_or_none(self.z_area_floor, 1),
            "most_sensitive_bucket": self.most_sensitive_bucket,
            "beta": _round_or_none(self.beta, 4),
            "r2": _round_or_none(self.r2, 4),
            "n_obs": self.n_obs,
            "shrinkage_weight": round(self.shrinkage_weight, 3),
            "confounded": self.confounded,
            "confidence": self.confidence,
            "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 ols_slope_r2(
    x: list[float | None], y: list[float | None]
) -> tuple[float | None, float | None, int]:
    """Univariate OLS y~x через numpy → (slope, R², n) на парных конечных точках.

    Дропаем любую пару, где x или y = None / NaN / Inf (Δln-ряды дырявые: нули и
    первая точка → None). Slope/R² через numpy.polyfit(deg=1) + ручной R²
    (1 − SS_res/SS_tot). PURE, без БД.

    Возвращает (None, None, n) если n<2 (нечего фитить) или нулевая дисперсия x/y
    (вертикальная/горизонтальная линия — slope/R² не определены).

    Args:
        x: регрессор (обычно Δrate при лаге), None/NaN допустимы.
        y: зависимая (обычно Δln(units)), None/NaN допустимы.

    Returns:
        (slope, r2, n_used): slope/r2 = None при невозможности фита; n_used —
        число использованных (конечных) пар.
    """
    xs: list[float] = []
    ys: list[float] = []
    for xv, yv in zip(x, y, strict=False):
        if xv is None or yv is None:
            continue
        xf = float(xv)
        yf = float(yv)
        if not (math.isfinite(xf) and math.isfinite(yf)):
            continue
        xs.append(xf)
        ys.append(yf)

    n = len(xs)
    if n < 2:
        return None, None, n

    xa = np.asarray(xs, dtype=float)
    ya = np.asarray(ys, dtype=float)
    # Нулевая дисперсия → slope/R² не определены (деление на ноль в фите/R²).
    if float(np.var(xa)) == 0.0 or float(np.var(ya)) == 0.0:
        return None, None, n

    slope, intercept = np.polyfit(xa, ya, 1)
    y_hat = slope * xa + intercept
    ss_res = float(np.sum((ya - y_hat) ** 2))
    ss_tot = float(np.sum((ya - np.mean(ya)) ** 2))
    if ss_tot == 0.0:  # уже отсечено var(y)==0, но страховка от FP-краёв
        return None, None, n
    r2 = 1.0 - ss_res / ss_tot
    return float(slope), float(r2), n


def _delta(series: list[float | None]) -> list[float | None]:
    """Простая первая разность ряда: out[t] = x_t − x_{t-1}; out[0]=None.

    Применяем к key_rate (Δ в п.п.). None, если любая из двух точек None. PURE.
    """
    out: list[float | None] = [None]
    for i in range(1, len(series)):
        cur = series[i]
        prev = series[i - 1]
        if cur is None or prev is None:
            out.append(None)
        else:
            out.append(float(cur) - float(prev))
    return out


def best_lag(
    deltas_sales: list[float | None],
    monthly_macro_rate_deltas: list[float | None],
    *,
    lags: tuple[int, ...] = _LAGS,
    min_obs: int = _MIN_OBS,
    min_r2: float = _MIN_R2,
) -> dict[str, Any] | None:
    """Выбрать лаг с сильнейшей (самой негативной) gate-прошедшей связью rate→sales.

    Для каждого лага L выравниваем Δln(sales)[t] с Δrate[t−L] (rate ОТСТАЁТ: продажи
    реагируют на прошлое изменение ставки), фитим ols_slope_r2, применяем GATE
    (n≥min_obs ∧ r2≥min_r2 ∧ slope<0 — зеркало _elasticity_coef). Среди прошедших
    берём с НАИБОЛЕЕ НЕГАТИВНЫМ slope (сильнейшее падение продаж). Оба ряда должны
    быть одной длины (общая месячная сетка PR1/PR2). PURE, без БД.

    Args:
        deltas_sales: Δln(units) по месяцам (из log_diff, PR1), None-дыры ок.
        monthly_macro_rate_deltas: Δ(key_rate) по тем же месяцам (None-дыры ок).
        lags: лаги-кандидаты (по умолчанию _LAGS).
        min_obs: GATE — минимум парных точек.
        min_r2: GATE — минимум R².

    Returns:
        {'lag','slope','r2','n'} лучшего прошедшего лага, либо None если ни один не
        прошёл gate (тонко / неправильный знак / слабый фит).
    """
    best: dict[str, Any] | None = None
    n_months = len(deltas_sales)
    for lag in lags:
        # X лагируется: y[t] ← x[t-lag]. Сдвигаем X вправо на lag (первые lag = None).
        shifted_x: list[float | None] = [None] * lag + list(monthly_macro_rate_deltas)
        shifted_x = shifted_x[:n_months]
        slope, r2, n = ols_slope_r2(shifted_x, deltas_sales)
        if slope is None or r2 is None:
            continue
        if n < min_obs or r2 < min_r2 or slope >= 0:
            continue
        if best is None or slope < best["slope"]:
            best = {"lag": lag, "slope": slope, "r2": r2, "n": n}
    return best


def shrink(
    beta_seg: float, n_seg: int, beta_ekb: float, *, k: float = _SHRINK_K
) -> tuple[float, float]:
    """Шринк сегментного β к EKB-prior'у: w=n/(n+k); β=w·β_seg+(1−w)·β_ekb.

    Тонкий сегмент (мало n) шумен → тянем к более стабильному EKB-широкому
    prior'у. Много n → w→1 → ≈β_seg. PURE, без БД.

    Args:
        beta_seg: сырой slope сегмента (Δln).
        n_seg: число Δln-точек сегмента (вес доверия).
        beta_ekb: EKB-широкий prior-slope.
        k: сила шринка (по умолчанию _SHRINK_K).

    Returns:
        (beta_shrunk, weight): шринкнутый slope и вес w∈[0,1] сегмента.
    """
    n = max(0, n_seg)
    denom = n + k
    w = (n / denom) if denom > 0 else 0.0
    shrunk = w * beta_seg + (1.0 - w) * beta_ekb
    return shrunk, w


def _x_pct_from_beta(beta: float) -> float:
    """β на Δln → % изменения продаж на +1 п.п. ставки: 100·(exp(β)−1).

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


def _confidence(
    *, n_obs: int, r2: float | None, weight: float, confounded: bool, gated: bool
) -> Confidence:
    """Confidence §9.6 (см. _CONF_HIGH_* / gate). PURE.

    'high'   — длинный ряд И сильный фит И доминирование сегмента И не шок-окно.
    'medium' — прошёл gate (есть валидный негативный лаг), но не дотянул до high.
    'low'    — gate не прошёл (тонко / неправильный знак / слабо).
    """
    if not gated:
        return "low"
    if (
        n_obs >= _CONF_HIGH_MIN_OBS
        and r2 is not None
        and r2 >= _CONF_HIGH_MIN_R2
        and weight >= _CONF_HIGH_MIN_WEIGHT
        and not confounded
    ):
        return "high"
    return "medium"


def _build_phrase(
    *,
    beta: float | None,
    x_pct: float | None,
    y_lag_months: int | None,
    weight: float,
    most_sensitive_bucket: str | None,
    z_area_floor: float | None,
) -> str:
    """Собрать фразу §9.6 по шаблону (НЕ LLM) с правилами деградации. PURE.

    Деградация:
      • нет gate-лага (beta/x_pct/lag = None) ИЛИ выживший неправильный знак
        (beta≥0 → продажи якобы РАСТУТ при ↑ставки — не доверяем) → «недостаточно
        данных…».
      • есть валидный негативный β, но per-segment слаб (weight < порога) ИЛИ
        нет адресного бакета/Z → EKB-широкая форма (1-е предложение).
      • иначе → полная форма с «наиболее чувствительны {bucket} … {z} м²».
    X в фразе — положительная МАГНИТУДА («снижались на X%»).
    """
    if beta is None or x_pct is None or y_lag_months is None or beta >= 0:
        return _PHRASE_INSUFFICIENT
    x_mag = round(abs(x_pct), 1)
    if weight < _PHRASE_SEGMENT_WEIGHT_MIN or most_sensitive_bucket is None or z_area_floor is None:
        return _PHRASE_EKB_WIDE.format(x=x_mag, y=y_lag_months)
    return _PHRASE_FULL.format(
        x=x_mag,
        y=y_lag_months,
        bucket=most_sensitive_bucket,
        z=round(z_area_floor, 1),
    )


def _insufficient(
    *, segment: dict[str, str | None], n_obs: int, weight: float, confounded: bool
) -> RateSensitivity:
    """Граничный результат «недостаточно данных» (low, фраза-заглушка). 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=n_obs,
        shrinkage_weight=weight,
        confounded=confounded,
        confidence="low",
        phrase=_PHRASE_INSUFFICIENT,
    )


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


def compute_rate_sensitivity(
    db: Session,
    *,
    spec: SegmentSpec,
    months_back: int = _DEFAULT_MONTHS_BACK,
) -> RateSensitivity:
    """Вычислить §9.6 чувствительность сегмента к ключевой ставке (X/Y/Z + фраза).

    ADVISORY до валидации бэктестом PR6 — НЕ подключать в production-эндпоинт.

    Шаги:
      1. EKB-широкий prior: ряд продаж spec-all-None (Source A) + макро → β_EKB
         через best_lag (bootstrap-prior для шринка; если EKB-фит сам не прошёл
         gate → prior=0.0, т.е. шринк тянет тонкий сегмент к «нет эффекта»).
      2. Целевой сегмент: ряд продаж по spec (Source A) → β_seg → шринк к β_EKB.
      3. Z: перебираем Source-B room×area-бакеты, берём самый чувствительный
         (самый негативный gate-прошедший β); Z = нижняя граница его площади.
      4. x_pct=100·(exp(β_shrunk)−1); y_lag=выигравший лаг; confounded по окну ряда.
      5. confidence + фраза (с деградацией: неправильный знак / тонко → insufficient;
         слабый per-segment → EKB-широкая форма).

    Graceful: тонко/пусто/ошибка → confidence='low', фраза «недостаточно данных…»,
    НЕ crash. Детерминированно.

    Args:
        db: SQLAlchemy sync Session.
        spec: целевой сегмент (любой subset осей).
        months_back: глубина ряда (по умолчанию _DEFAULT_MONTHS_BACK).

    Returns:
        RateSensitivity (всегда; фраза заполнена даже при нехватке данных).
    """
    segment = spec.as_dict()

    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]

    # ── 1. EKB-широкий prior (bootstrap для шринка) ────────────────────────────
    ekb_spec = SegmentSpec()  # all-None → агрегат по всему ЕКБ
    ekb_sales = build_sales_series(db, spec=ekb_spec, source=_SOURCE_A, months_back=months_back)
    ekb_deltas = _align_sales_deltas(ekb_sales.months, ekb_sales.units, macro_months)
    ekb_fit = best_lag(ekb_deltas, rate_deltas)
    # Если EKB-фит сам не прошёл gate — prior = нейтральный 0.0 (нет эффекта): тонкий
    # сегмент тогда шринкуется к «ставка на продажи не влияет», а не к шуму.
    beta_ekb = float(ekb_fit["slope"]) if ekb_fit is not None else 0.0

    # ── 2. Целевой сегмент → β_seg → шринк ─────────────────────────────────────
    seg_sales = build_sales_series(db, spec=spec, source=_SOURCE_A, months_back=months_back)
    seg_deltas = _align_sales_deltas(seg_sales.months, seg_sales.units, macro_months)
    seg_fit = best_lag(seg_deltas, rate_deltas)

    confounded = _series_confounded(seg_sales.months)

    if seg_fit is None:
        # Сегмент сам не дал валидного негативного лага → недостаточно данных.
        _, w0 = shrink(0.0, 0, beta_ekb)
        logger.info(
            "rate_sensitivity: no gated lag (segment=%s n_months=%d) → insufficient",
            segment,
            seg_sales.n_months,
        )
        return _insufficient(segment=segment, n_obs=0, weight=w0, confounded=confounded)

    beta_seg = float(seg_fit["slope"])
    n_seg = int(seg_fit["n"])
    r2_seg = float(seg_fit["r2"])
    y_lag = int(seg_fit["lag"])
    beta_shrunk, weight = shrink(beta_seg, n_seg, beta_ekb)

    # Шринк к prior'у может перевернуть знак (β_seg<0, но β_ekb>0 и доминирует) →
    # эффект уже не «продажи падают». Тогда деградируем в insufficient (см. _build_phrase).
    if beta_shrunk >= 0:
        logger.info(
            "rate_sensitivity: shrunk beta non-negative (segment=%s beta_seg=%.4f "
            "beta_ekb=%.4f w=%.3f) → insufficient",
            segment,
            beta_seg,
            beta_ekb,
            weight,
        )
        return _insufficient(segment=segment, n_obs=n_seg, weight=weight, confounded=confounded)

    # ── 3. Z: самый чувствительный Source-B room×area-бакет ────────────────────
    most_sensitive_bucket, z_area_floor = _most_sensitive_bucket(
        db, spec=spec, rate_deltas=rate_deltas, macro_months=macro_months, months_back=months_back
    )

    # ── 4. X / lag / confounded ────────────────────────────────────────────────
    x_pct = _x_pct_from_beta(beta_shrunk)

    # ── 5. confidence + фраза ──────────────────────────────────────────────────
    confidence = _confidence(
        n_obs=n_seg, r2=r2_seg, weight=weight, confounded=confounded, gated=True
    )
    phrase = _build_phrase(
        beta=beta_shrunk,
        x_pct=x_pct,
        y_lag_months=y_lag,
        weight=weight,
        most_sensitive_bucket=most_sensitive_bucket,
        z_area_floor=z_area_floor,
    )

    logger.info(
        "rate_sensitivity: segment=%s beta_shrunk=%.4f x_pct=%.1f lag=%d w=%.3f "
        "bucket=%s z=%s confounded=%s confidence=%s",
        segment,
        beta_shrunk,
        x_pct,
        y_lag,
        weight,
        most_sensitive_bucket,
        z_area_floor,
        confounded,
        confidence,
    )

    return RateSensitivity(
        segment=segment,
        x_pct=x_pct,
        y_lag_months=y_lag,
        z_area_floor=z_area_floor,
        most_sensitive_bucket=most_sensitive_bucket,
        beta=beta_shrunk,
        r2=r2_seg,
        n_obs=n_seg,
        shrinkage_weight=weight,
        confounded=confounded,
        confidence=confidence,
        phrase=phrase,
    )


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

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

    TODO(#979): здесь — ЕДИНСТВЕННАЯ чистая точка интеграции month-of-year
    дессзонивания (normalize.normalize_demand / seasonal_factors+deseasonalize_values)
    ПЕРЕД log_diff. Интеграция ОТЛОЖЕНА: на коротком ряде (≤3 полных лет = ≤3 точки
    на календарный месяц) ratio-to-mean фактор может перетянуть часть режимного
    отклика на ставку в «сезонность» и сместить восстановленный β/лаг (вскрылось на
    синтетике test_rate_sensitivity). Нужно сначала: (а) гейт «дессзонивать только
    при ≥N точках/месяц или high-confidence ряде», либо (б) совместная оценка
    seasonal+rate, чтобы не вычитать сигнал ставки. Модуль + тест готовы (#979 DoD);
    подключение — отдельной задачей с бэктестом.
    """
    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 _series_confounded(months: list[date]) -> bool:
    """True, если окно ряда [min..max] пересекает шок-дату (reuse PR2). Пустой → False."""
    if not months:
        return False
    return is_confounded_window(min(months), max(months))


def _most_sensitive_bucket(
    db: Session,
    *,
    spec: SegmentSpec,
    rate_deltas: list[float | None],
    macro_months: list[date],
    months_back: int,
) -> tuple[str | None, float | None]:
    """Найти самый чувствительный Source-B room×area-бакет → (метка, area_floor).

    Перебираем area-меченые бакеты (Source B несёт area/price, Source A — нет),
    для каждого строим ряд (тот же class/district, room_bucket=метка), фитим best_lag
    и берём бакет с самым НЕГАТИВНЫМ gate-прошедшим slope. Z = нижняя граница его
    площади (_BUCKET_AREA_FLOOR_M2). Ни один бакет не прошёл gate → (None, None) —
    фраза деградирует к EKB-широкой форме.

    Graceful: build_sales_series сам не бросает на тонких данных; здесь не крашим.
    """
    best_bucket: str | None = None
    best_slope: float | None = None
    for bucket in _SOURCE_B_ROOM_BUCKETS:
        bucket_spec = SegmentSpec(
            obj_class=spec.obj_class,
            room_bucket=bucket,
            district=spec.district,
            price_bucket=spec.price_bucket,
        )
        bucket_sales = build_sales_series(
            db, spec=bucket_spec, source=_SOURCE_B, months_back=months_back
        )
        bucket_deltas = _align_sales_deltas(bucket_sales.months, bucket_sales.units, macro_months)
        fit = best_lag(bucket_deltas, rate_deltas)
        if fit is None:
            continue
        slope = float(fit["slope"])
        if best_slope is None or slope < best_slope:
            best_slope = slope
            best_bucket = bucket
    if best_bucket is None:
        return None, None
    return best_bucket, _BUCKET_AREA_FLOOR_M2.get(best_bucket)
