"""§9.4×§9.5 спрос vs §9.3 предложение — центральный прогнозный движок (ТЗ §9.8).

#952 (Site Finder v2 / «GG-форсайт» ТЗ §9.8), EPIC 8 «Центральный прогнозный
движок». Это **СБОРОЧНЫЙ слой**: он НЕ пересобирает §9.x-математику, а синтезирует
уже-смерженные сервисы в per-(сегмент × горизонт) баланс СПРОС↔ПРЕДЛОЖЕНИЕ:

  • СПРОС  = наблюдаемый темп (§9.2 unit_velocity) × нормализация под режим ставки
            (§9.4 compute_demand_normalization) × макро-режим (§9.5
            compute_macro_coefficient), спроектированный линейно на горизонт.
  • ПРЕДЛОЖЕНИЕ = открытый сток + фазированный скрытый запас + будущий слой в
            горизонте − поглощённое спросом (§9.3 compute_future_supply_pressure).
  • БАЛАНС / индекс дефицита = знаковое насыщающее преобразование отношения
            спрос/предложение в [−1,+1] (+1 = сильный дефицит «хорошо» / −1 =
            сильная затоварка «риск»). Это АБСОЛЮТНЫЙ сигнал: −1 = рынок
            действительно затоварен (ЕКБ-новостройка широко затоварена при текущих
            ставках, поэтому −1 встречается часто и это ЧЕСТНО). Дефицит-индекс
            насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ
            между сильно-затоваренными сегментами (все липнут к −1).
  • MONTHS-OF-INVENTORY (MOI) = ДИСКРИМИНИРУЮЩИЙ companion-метрик к дефициту
            (стандарт рынка недвижимости): сколько МЕСЯЦЕВ нужно, чтобы поглотить
            конкурирующее предложение при текущем нормализованном темпе спроса
            (= competing_supply / demand_per_mo). Не насыщается → различает «30 мес»
            от «100 мес» там, где дефицит-индекс у обоих = −1.
  • БУДУЩИЕ КОНКУРЕНТЫ (§9.7) — top-N по relevance_weight на горизонт.

Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистая арифметика + reuse §9.x; своего SQL НЕТ).

ADVISORY-СТАТУС: движок СОВЕТУЮЩИЙ и НЕ подключён ни к одному production-эндпоинту
(как §9.4/§9.5/§9.6, все advisory до бэктеста #951 / валидации #951). `advisory`
поле ВСЕГДА True; итоговый confidence ЖЁСТКО ограничен сверху 'medium' (синтез не
надёжнее непровалидированных компонентов). Цифры — для explainability/прототипа.

──────────────────────────────────────────────────────────────────────────────
⚠️  ГЛАВНОЕ ПРАВИЛО КОРРЕКТНОСТИ — β УЧТЁН РОВНО ОДИН РАЗ:
  • §9.4 `compute_demand_normalization` УЖЕ внутри вызывает §9.6
    `compute_rate_sensitivity` и применяет β как exp(β·Δrate). Поэтому СПРОС
    проходит через `compute_demand_normalization` ТОЛЬКО — мы НИГДЕ не домножаем
    `rate_sensitivity.beta`/`x_pct` в число спроса (это было бы двойным учётом β).
  • `compute_rate_sensitivity` здесь вызывается ИСКЛЮЧИТЕЛЬНО ради explainability —
    чтобы вынести `.phrase` (и связанные `x_pct`/`y_lag_months`) в вывод. НИКАКОЙ
    арифметики над её β/x_pct в расчёте спроса.
  • §9.5 `macro_coefficient` ОРТОГОНАЛЕН β: он про каналы issuance/overdue/
    mortgage-rate (макро-режим), а НЕ про эластичность к Δ ключевой ставки.
    Поэтому `§9.4 × §9.5` — это два РАЗНЫХ множителя, НЕ двойной учёт.

ГОРИЗОНТ-ПРОЕКЦИЯ ЛИНЕЙНА (документируем): projected_demand = demand_per_mo × h,
БЕЗ компаундинга. Это сознательно: помесячный темп уже нормализован под будущий
режим (§9.4) и макро (§9.5); накручивать сверху сложный процент роста на
непровалидированном движке = ложная точность. Линейная проекция интерпретируема
(«столько ед. поглотит рынок за h мес при текущем нормализованном темпе») и
зеркалит дисциплину линейного clamp future_supply._saturating_index.

Graceful-on-thin-data (дух future_supply / market_metrics): любой тонкий вход →
соответствующее поле None (НИКОГДА 0-как-заглушка), НИКОГДА не crash, НИКОГДА
деления на ноль. supply ≤ 0 / None → balance_ratio=None (∞-кратность не выдумываем);
deficit_index при этом = +1.0 ТОЛЬКО если спрос > 0 (предложение исчерпано под спрос
= ПИК дефицита по семантике [−1,+1], #980), иначе None (нет сигнала: и спрос ≤0).
confidence занижается выше по стеку. Детерминированно.
"""

from __future__ import annotations

import logging
import math
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal

from sqlalchemy.orm import Session

from app.schemas.parcel import CompetitorsRequest
from app.services.forecasting.demand_normalization import compute_demand_normalization
from app.services.forecasting.macro_coefficient import compute_macro_coefficient
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
from app.services.forecasting.regression import compute_rate_regime_sensitivity
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,
)
from app.services.site_finder.competitors import get_competitors
from app.services.site_finder.future_supply import compute_future_supply_pressure
from app.services.site_finder.market_metrics import compute_market_metrics

logger = logging.getLogger(__name__)

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

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

# Горизонты прогноза по умолчанию (мес). 6/12/18/24 — зеркало §9.x горизонт-сетки
# (competitors.horizon_months / future_supply.horizon_months): полгода…2 года —
# окно, в котором перспективный объект реально выходит на рынок и конкурирует.
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)

# Фазированный выход скрытого запаса (Layer2 ПД) — за сколько месяцев он полностью
# «созревает» в конкурирующее предложение. 18 мес ≈ типовой цикл от запаса ПД до
# вывода корпуса на продажу в ЕКБ. Доля выхода = clamp(h / _HIDDEN_RELEASE_MONTHS,
# 0, 1): на горизонте 6 мес вышла ~⅓ скрытого, 12 мес ~⅔, ≥18 мес — весь. Так
# скрытый слой давит ПОСТЕПЕННО (не весь сразу на ближнем горизонте и не игнор —
# в духе future_supply._horizon_weight, но непрерывно по доле, а не по дате).
_HIDDEN_RELEASE_MONTHS: float = 18.0

# Насыщение индекса дефицита: при balance_ratio = _DEFICIT_RATIO_SATURATION (спрос
# вдвое выше предложения) индекс достигает +1 (сильный дефицит); при обратном
# (предложение вдвое выше спроса, ratio = 1/2) → −1 (сильная затоварка). Знаковое
# насыщающее преобразование log-отношения (симметрично вокруг ratio=1 → 0): берём
# log(ratio)/log(saturation), clamp в [−1,+1]. 2.0 = «двукратный перекос — уже
# экстремум» (зеркало духа future_supply._PRESSURE_SATURATION линейного clamp, но
# здесь лог-шкала, т.к. отношение мультипликативно: ×2 и ÷2 симметричны). Tunable.
_DEFICIT_RATIO_SATURATION: float = 2.0

# Сколько будущих конкурентов (§9.7) выносим в вывод (top-N по relevance_weight).
_TOP_COMPETITORS: int = 5

# Радиус поиска конкурентов (км) для §9.7 — дефолт CompetitorsRequest (1 км =
# «прямые соседи по локации»). Держим явной константой для детерминизма вызова.
_COMPETITORS_RADIUS_KM: float = 1.0

# Жёсткий потолок итогового confidence: движок advisory до бэктеста #951, поэтому
# даже при всех 'high'-компонентах синтез не объявляем надёжнее 'medium'.
_CONFIDENCE_CAP: Confidence = "medium"

# Порядок уверенности для MIN-агрегации (хуже = ниже). Зеркало future_supply.
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}

# #1959 VOCAB-мост (load-bearing): SegmentSpec.room_bucket приходит из what_to_build
# в Source-B room_area-вокабуляре ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/
# "3-к 60-80"/"80+ м²"). compute_market_metrics (после #1959) принимает ИМЕННО этот
# вокабуляр напрямую (его room CASE = зеркало sales_series.room_area_bucket_of). Эта
# white-list страхует от чужих/неизвестных меток: распознанные пробрасываем как есть,
# неизвестные (или None) → None = «без room-фильтра» (district-wide темп, но НЕ тихий
# 0-rows из-за опечатки в вокабуляре). Меняешь сетку room_buckets — обнови white-list.
_FORECAST_ROOM_BUCKETS: frozenset[str] = frozenset(
    {
        ROOM_AREA_BUCKET_STUDIO,
        ROOM_AREA_BUCKET_1K,
        ROOM_AREA_BUCKET_2K,
        ROOM_AREA_BUCKET_3K,
        ROOM_AREA_BUCKET_LARGE,
    }
)


def _market_room_bucket(room_bucket: str | None) -> str | None:
    """Перевести SegmentSpec.room_bucket в room-фильтр compute_market_metrics. PURE.

    Вокабуляр уже выровнен (#1959): market_metrics принимает Source-B room_area-метки
    напрямую. Эта функция — VALIDATING pass-through: распознанная Source-B метка
    возвращается КАК ЕСТЬ; None / неизвестная метка → None (= «без room-фильтра»,
    district-wide темп). Без этой проверки опечатка/чужой вокабуляр дали бы тихий
    0-rows фильтр → метрики None → ячейка молча выпадала бы из ранкинга. PURE.

    Args:
        room_bucket: room_bucket сегмента (ожидается Source-B вокабуляр) или None.

    Returns:
        Та же Source-B метка (если в белом списке) либо None.
    """
    if room_bucket is None:
        return None
    if room_bucket in _FORECAST_ROOM_BUCKETS:
        return room_bucket
    logger.warning(
        "demand_supply_forecast: unrecognized room_bucket %r — dropping room filter "
        "(district-wide pace). Expected Source-B vocab %s.",
        room_bucket,
        sorted(_FORECAST_ROOM_BUCKETS),
    )
    return None


@dataclass(frozen=True)
class DemandSupplyForecast:
    """Per-(сегмент × горизонт) баланс СПРОС↔ПРЕДЛОЖЕНИЕ (ТЗ §9.8, считается на лету).

    Все величины детерминированы. Любое числовое поле = None при недостатке данных
    (НИКОГДА 0-как-заглушка). `advisory` ВСЕГДА True (движок не для production-решений
    до бэктеста #951). `deficit_index` ∈ [−1,+1] когда задан: +1 = сильный дефицит
    (мало предложения под спрос — «хорошо» для девелопера), −1 = сильная затоварка
    (предложения больше спроса — «риск»). Предложение исчерпано под спрос (supply≤0,
    demand>0) → +1.0 (пик дефицита, #980, хотя balance_ratio тогда None); None только
    при отсутствии сигнала (supply≤0 И demand≤0/None).

    `deficit_index` — АБСОЛЮТНЫЙ сигнал (−1 = реально затоварено; ЕКБ-новостройка
    широко затоварена при текущих ставках → −1 встречается часто и это честно). Он
    насыщается уже при двукратном перекосе, поэтому слабо ДИСКРИМИНИРУЕТ среди
    сильно-затоваренных сегментов. `months_of_inventory` (MOI) — ДИСКРИМИНИРУЮЩИЙ
    companion: сколько месяцев нужно поглотить ВАЛОВОЕ конкурирующее предложение при
    текущем нормализованном темпе спроса (= gross_supply / demand_per_mo). Не
    насыщается → различает «30 мес» от «100 мес» там, где дефицит у обоих = −1.

    `rate_sensitivity_phrase` — ТОЛЬКО explainability (β НЕ участвует в арифметике
    спроса дважды — он уже учтён внутри demand_norm_coefficient §9.4).
    """

    # ── Контекст ──────────────────────────────────────────────────────────────
    segment: dict[str, str | None]
    horizon_months: int

    # ── Спрос (§9.2 темп × §9.4 норм × §9.5 макро, линейно на горизонт) ────────
    base_pace_units_per_mo: float | None  # §9.2 observed unit_velocity (ед./мес)
    demand_norm_coefficient: float | None  # §9.4 множитель (β внутри — учтён 1 раз)
    macro_coefficient: float | None  # §9.5 множитель (ортогонален β)
    projected_demand_units: float | None  # base_pace × §9.4 × §9.5 × h (линейно)

    # ── Предложение (§9.3 слои, фазированный hidden, за вычетом поглощённого) ───
    open_units: int  # Σ Layer1 (в продаже) — контекст
    hidden_release_units: float  # Σ Layer2 × фаза выхода на горизонте
    future_online_units: float  # Σ Layer3, взвешенный по попаданию в горизонт
    projected_supply_units: float  # open + hidden_release + future − absorbed, ≥0

    # ── Баланс / индекс дефицита / months-of-inventory ─────────────────────────
    balance_units: float | None  # demand − supply (>0 дефицит / <0 затоварка)
    balance_ratio: float | None  # demand / supply (None если supply ≤ 0)
    deficit_index: float | None  # знаковое насыщение balance_ratio → [−1,+1] (АБСОЛЮТ.)
    months_of_inventory: float | None  # ВАЛОВОЕ предложение / demand_per_mo (ДИСКРИМИН.)

    # ── Explainability ─────────────────────────────────────────────────────────
    rate_future: float | None  # ставка сценария на горизонте (вход §9.4)
    rate_sensitivity_phrase: str | None  # §9.6 фраза (НЕ арифметика — explain-only)
    future_competitors: list[dict[str, Any]]  # §9.7 top-N по relevance_weight

    advisory: bool  # ВСЕГДА True (движок не для production-решений)
    confidence: Confidence  # MIN(компоненты), жёстко ≤ _CONFIDENCE_CAP

    # Шок-окно (PR2): True, если §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity
    # сообщили `confounded` (ряд пересекает структурный разрыв 2024-07-01). Прокидываем
    # в `as_dict()` → `report_assembler._confounded` → `compute_report_confidence` (#990)
    # confounded factor: НИКОГДА не позволяет §15 объявить 'high'. Дефолт False (чистое
    # окно). Без этого поля §15-фактор шок-окна (#1222) перманентно мёртв.
    confounded: bool = False

    def as_dict(self) -> dict[str, Any]:
        return {
            "segment": dict(self.segment),
            "horizon_months": self.horizon_months,
            "base_pace_units_per_mo": _round_or_none(self.base_pace_units_per_mo, 2),
            "demand_norm_coefficient": _round_or_none(self.demand_norm_coefficient, 4),
            "macro_coefficient": _round_or_none(self.macro_coefficient, 4),
            "projected_demand_units": _round_or_none(self.projected_demand_units, 1),
            "open_units": self.open_units,
            "hidden_release_units": _round_or_none(self.hidden_release_units, 1),
            "future_online_units": _round_or_none(self.future_online_units, 1),
            "projected_supply_units": _round_or_none(self.projected_supply_units, 1),
            "balance_units": _round_or_none(self.balance_units, 1),
            "balance_ratio": _round_or_none(self.balance_ratio, 3),
            "deficit_index": _round_or_none(self.deficit_index, 3),
            "months_of_inventory": _round_or_none(self.months_of_inventory, 1),
            "rate_future": _round_or_none(self.rate_future, 2),
            "rate_sensitivity_phrase": self.rate_sensitivity_phrase,
            "future_competitors": list(self.future_competitors),
            "advisory": self.advisory,
            "confidence": self.confidence,
            "confounded": self.confounded,
        }


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


# ──────────────────────────────────────────────────────────────────────────────
# Pure-арифметика — без БД, полностью юнит-тестируемо.
# Каждая функция graceful: тонкий/нулевой вход → None/нейтраль (не crash, не /0).
# ──────────────────────────────────────────────────────────────────────────────


def _hidden_release_fraction(
    horizon_months: int, *, release_months: float = _HIDDEN_RELEASE_MONTHS
) -> float:
    """Доля скрытого запаса (Layer2), вышедшая в предложение к горизонту ∈ [0,1].

    clamp(h / release_months, 0, 1): фазированный выход скрытого ПД в конкурирующее
    предложение. На горизонте 6 мес из 18-месячного цикла вышла треть, 12 → две
    трети, ≥18 → весь. Так скрытый слой давит ПОСТЕПЕННО (не весь сразу). PURE.

    Args:
        horizon_months: горизонт прогноза (мес); ≤0 → 0.0 (ничего не вышло).
        release_months: полный цикл созревания скрытого запаса (по умолчанию
            _HIDDEN_RELEASE_MONTHS); ≤0 → деградация: любой h>0 → 1.0 (весь сразу).

    Returns:
        Доля в [0,1].
    """
    if horizon_months <= 0:
        return 0.0
    if release_months <= 0:
        return 1.0
    return max(0.0, min(1.0, horizon_months / release_months))


def _project_demand(
    base_pace: float | None,
    norm_coefficient: float | None,
    macro_coefficient: float | None,
    horizon_months: int,
) -> float | None:
    """Спроектированный спрос = base_pace × §9.4 × §9.5 × h (ЛИНЕЙНО, без компаунда).

    base_pace None → None (НЕ 0: «нет данных о рынке» ≠ «продажи 0»). Коэффициенты
    None трактуем как 1.0 (нейтраль — компонент не скорректировал темп, но темп
    известен). β учтён РОВНО ОДИН РАЗ внутри norm_coefficient (§9.4) — здесь его
    больше НЕ домножаем. Линейность сознательна (см. module docstring). PURE.

    Args:
        base_pace: §9.2 наблюдаемый темп (ед./мес); None = нет выборки.
        norm_coefficient: §9.4 множитель нормализации (β уже внутри); None → 1.0.
        macro_coefficient: §9.5 макро-множитель (ортогонален β); None → 1.0.
        horizon_months: горизонт проекции (мес); ≤0 → 0.0 спроса.

    Returns:
        Спроектированные ед. спроса или None (нет base_pace).
    """
    if base_pace is None:
        return None
    if horizon_months <= 0:
        return 0.0
    norm = norm_coefficient if norm_coefficient is not None else 1.0
    macro = macro_coefficient if macro_coefficient is not None else 1.0
    demand_per_mo = base_pace * norm * macro
    return demand_per_mo * float(horizon_months)


def _gross_supply(
    open_units: float, hidden_release_units: float, future_online_units: float
) -> float:
    """Валовое конкурирующее предложение = open + hidden_release + future_online. PURE.

    Всё, что выйдет/доступно на горизонте, ДО вычета поглощённого спросом. Это
    «total inventory» для учебной MOI (total-inventory / sales-rate) — берём
    ИМЕННО валовое (а не net после absorption), т.к. MOI отвечает «сколько месяцев
    рынок переваривает ВЕСЬ доступный сток при текущем темпе», что и есть стандарт.
    """
    return open_units + hidden_release_units + future_online_units


def _project_supply(
    open_units: float,
    hidden_release_units: float,
    future_online_units: float,
    projected_demand_units: float | None,
) -> float:
    """Чистое конкурирующее предложение = валовое − поглощённое спросом, clamp ≥0.

    Валовое (см. `_gross_supply`) = open + hidden_release + future_online (всё, что
    выйдет/доступно на горизонте). Поглощённое (absorbed) = min(валовое,
    projected_demand) — рынок «съест» столько, сколько есть спроса, но не больше
    доступного объёма. Чистое = валовое − absorbed (остаток, который РЕАЛЬНО
    конкурирует с нашим объектом). Спрос None трактуем как 0 поглощения (нет данных
    о спросе → ничего не вычитаем → консервативно показываем всё валовое как
    конкурирующее). clamp ≥0. PURE.

    Args:
        open_units: Σ Layer1 (в продаже).
        hidden_release_units: Σ Layer2 × фаза выхода на горизонте.
        future_online_units: Σ Layer3, взвешенный по горизонту.
        projected_demand_units: спроектированный спрос (None → 0 поглощения).

    Returns:
        Чистое конкурирующее предложение (≥0).
    """
    gross = _gross_supply(open_units, hidden_release_units, future_online_units)
    demand = projected_demand_units if projected_demand_units is not None else 0.0
    absorbed = min(gross, max(0.0, demand))
    return max(0.0, gross - absorbed)


def _balance(
    projected_demand_units: float | None, projected_supply_units: float | None
) -> float | None:
    """balance_units = спрос − предложение (>0 дефицит / <0 затоварка). PURE.

    Любой вход None → None (нечего сравнивать). >0 = спроса больше, чем
    конкурирующего предложения (дефицит — «хорошо» для девелопера); <0 = затоварка.
    """
    if projected_demand_units is None or projected_supply_units is None:
        return None
    return projected_demand_units - projected_supply_units


def _balance_ratio(
    projected_demand_units: float | None, projected_supply_units: float | None
) -> float | None:
    """balance_ratio = спрос / предложение. PURE.

    supply ≤ 0 / None ИЛИ demand None → None (НЕ ∞: «предложение исчерпано» не
    отличить от «нет данных», поэтому честно None, а не выдуманный бесконечный
    дефицит — индекс тогда тоже None, confidence занижается выше по стеку).
    """
    if projected_demand_units is None or projected_supply_units is None:
        return None
    if projected_supply_units <= 0:
        return None
    return projected_demand_units / projected_supply_units


def _deficit_index(
    balance_ratio: float | None, *, saturation: float = _DEFICIT_RATIO_SATURATION
) -> float | None:
    """Знаковое насыщение balance_ratio → индекс дефицита ∈ [−1,+1]. PURE.

    АБСОЛЮТНЫЙ сигнал, НЕ относительный рейтинг: −1 = рынок реально затоварен
    (ЕКБ-новостройка широко затоварена при текущих ставках → −1 встречается часто и
    это честно). Насыщается уже при ДВУКРАТНОМ перекосе, поэтому слабо
    ДИСКРИМИНИРУЕТ среди сильно-затоваренных сегментов (все липнут к −1) — для
    различения пользуйтесь companion-метрикой months_of_inventory (gross-предложение
    / помесячный темп спроса), которая не насыщается. Намеренно НЕ перекалибруем
    шкалу под ЕКБ: индекс честен как абсолют.

    Лог-шкала (отношение мультипликативно — ×2 и ÷2 симметричны вокруг 1.0):
        index = clamp(log(ratio) / log(saturation), −1, +1).
      • ratio = 1.0 (спрос = предложение) → log(1)=0 → index 0 (баланс).
      • ratio = saturation (спрос вдвое > предложения) → +1 (сильный дефицит).
      • ratio = 1/saturation (предложение вдвое > спроса) → −1 (сильная затоварка).
    None / непозитивный ratio → None (неизмеримо). saturation ≤1 → деградация:
    знак ratio−1 (без насыщающей шкалы). Монотонно неубывающее по ratio. PURE.

    Args:
        balance_ratio: спрос/предложение (None → None).
        saturation: ratio, при котором индекс достигает ±1 (по умолч.
            _DEFICIT_RATIO_SATURATION).

    Returns:
        Индекс в [−1,+1] или None.
    """
    if balance_ratio is None or balance_ratio <= 0:
        return None
    if saturation <= 1.0:
        # Деградация без падения: нет осмысленной шкалы → только знак перекоса.
        if balance_ratio > 1.0:
            return 1.0
        if balance_ratio < 1.0:
            return -1.0
        return 0.0
    raw = math.log(balance_ratio) / math.log(saturation)
    return max(-1.0, min(1.0, raw))


def _deficit_index_from_balance(
    projected_demand_units: float | None,
    projected_supply_units: float | None,
    balance_ratio: float | None,
    *,
    saturation: float = _DEFICIT_RATIO_SATURATION,
) -> float | None:
    """deficit_index с явной обработкой исчерпанного предложения (#980). PURE.

    Нормальный путь: supply > 0 → `_deficit_index(balance_ratio)` (лог-шкала).

    Граничный случай СИЛЬНЕЙШЕГО дефицита (#980): спрос поглотил ВСЁ предложение
    (effective `projected_supply_units` ≤ 0) ПРИ положительном спросе. balance_ratio
    тогда честно None (спрос/0 неотличим от «нет данных» — оставляем None как есть),
    НО индекс по документированной семантике (∈ [−1,+1], +1 = сильный дефицит)
    обязан быть +1.0, а НЕ None: исчерпанное под спрос предложение — это пик шкалы
    дефицита, а не «нет сигнала». Раньше тут возвращался None — самый сильный
    дефицит читался downstream как тонкие данные (market_fit падал к 0.5, ячейка
    выпадала из what_to_build). Кап = +1.0 (саму ∞-кратность не выдумываем — клампим
    к пределу шкалы, зеркало `_deficit_index` clamp-а).

    «Нет сигнала вообще» (supply ≤ 0 И спрос ≤ 0 / None) → None: ни дефицита, ни
    затоварки измерить нельзя (нечему конкурировать и нечему поглощать).

    Args:
        projected_demand_units: спроектированный спрос (None / ≤0 → нет дефицит-сигнала).
        projected_supply_units: чистое конкурирующее предложение (≤0 → исчерпано).
        balance_ratio: спрос/предложение (вход нормального пути; None при supply ≤0).
        saturation: ratio, при котором индекс достигает ±1 (proxy в _deficit_index).

    Returns:
        Индекс в [−1,+1] или None (нет измеримого сигнала).
    """
    if projected_supply_units is not None and projected_supply_units <= 0:
        # Предложение исчерпано: дефицит = +1 ТОЛЬКО при положительном спросе,
        # иначе сигнала нет (None) — не путаем «пик дефицита» с «пустым рынком».
        if projected_demand_units is not None and projected_demand_units > 0:
            return 1.0
        return None
    return _deficit_index(balance_ratio, saturation=saturation)


def _months_of_inventory(
    competing_supply: float | None, demand_per_mo: float | None
) -> float | None:
    """Months-of-inventory = валовое конкурирующее предложение / темп спроса (мес). PURE.

    Стандартная метрика рынка недвижимости (total-inventory / monthly-sales-rate):
    сколько МЕСЯЦЕВ нужно, чтобы поглотить competing_supply при текущем
    нормализованном помесячном темпе спроса. ДИСКРИМИНИРУЕТ там, где deficit_index
    насыщается (см. module docstring): два затоваренных сегмента с deficit −1 у обоих
    различаются по MOI (30 мес vs 100 мес).

    ВАЖНО — берём ВАЛОВОЕ (gross) предложение, НЕ net-после-absorption: MOI отвечает
    «сколько рынок переваривает ВЕСЬ доступный сток», поэтому net тут занизил бы.
    Gross-MOI читается чуть выше net-based прикидок — это ожидаемо и корректно.

    Graceful (НИКОГДА не crash, НИКОГДА /0):
      • demand_per_mo None ИЛИ ≤0 → None («нет данных о спросе» ≠ «∞ запас»;
        бесконечную кратность не выдумываем — зеркало _balance_ratio).
      • competing_supply None → None (нет данных о предложении).
      • competing_supply 0 (и темп>0) → 0.0 (пусто = поглощается мгновенно — это
        ОПРЕДЕЛЁННЫЙ сигнал «нет конкурирующего стока», не «нет данных»).

    Args:
        competing_supply: валовое конкурирующее предложение (ед.; None → None).
        demand_per_mo: нормализованный помесячный темп спроса (ед./мес; None/≤0 → None).

    Returns:
        Месяцы инвентаря (≥0) или None.
    """
    if competing_supply is None:
        return None
    if demand_per_mo is None or demand_per_mo <= 0:
        return None
    if competing_supply <= 0:
        return 0.0
    return competing_supply / demand_per_mo


def _demand_per_mo(projected_demand_units: float | None, horizon_months: int) -> float | None:
    """Восстановить помесячный темп спроса из спроектированного на горизонт. PURE.

    projected_demand = demand_per_mo × h (линейно, см. _project_demand), поэтому
    demand_per_mo = projected_demand / h. Это вход MOI (НЕ ×h). None / непозитивный
    горизонт → None (нет осмысленного темпа). PURE.

    Args:
        projected_demand_units: спрос на горизонт (None → None).
        horizon_months: горизонт проекции (мес; ≤0 → None).

    Returns:
        Помесячный темп спроса (ед./мес) или None.
    """
    if projected_demand_units is None or horizon_months <= 0:
        return None
    return projected_demand_units / float(horizon_months)


def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
    """Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.

    Тонкий любой компонент (market_metrics / §9.4 / §9.5 / future_supply) честно
    роняет общий confidence. None в списке игнорируем (компонент без сигнала).
    Пустой/весь-None вход → 'low'. Только whitelisted 'high|medium|low'. PURE.
    """
    ranks = [_CONFIDENCE_RANK[v] for v in values if v is not None]
    if not ranks:
        return "low"
    return _RANK_TO_CONFIDENCE[min(ranks)]


def _cap_confidence(confidence: Confidence, *, cap: Confidence = _CONFIDENCE_CAP) -> Confidence:
    """Ограничить confidence сверху (advisory-движок не надёжнее cap). PURE.

    Берём ранг-минимум(confidence, cap): high+cap-medium → medium; low → low.
    """
    capped_rank = min(_CONFIDENCE_RANK[confidence], _CONFIDENCE_RANK[cap])
    return _RANK_TO_CONFIDENCE[capped_rank]


def hold_last_rate(macro: list[MonthlyMacro], horizons: Sequence[int]) -> dict[int, float | None]:
    """Дефолтный rate-path: последняя известная key_rate, удержанная плоско. PURE.

    Сценарий «ставка не меняется»: берём самую свежую НЕпустую key_rate из макро-
    ряда и присваиваем её КАЖДОМУ горизонту. Нет ни одной точки key_rate → None на
    всех горизонтах (graceful: §9.4 тогда деградирует к нейтрали внутри себя).

    Args:
        macro: monthly макро-ряд (§9.5/§9.6 PR2 get_monthly_macro).
        horizons: горизонты (мес), под которые строим path.

    Returns:
        {horizon: rate}; rate = последняя key_rate (или None) на каждом горизонте.
    """
    last_rate: float | None = None
    for m in macro:  # ряд ASC по month → последняя непустая = самая свежая
        if m.key_rate is not None:
            last_rate = m.key_rate
    return {h: last_rate for h in horizons}


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


def compute_demand_supply_forecast(
    db: Session,
    *,
    spec: SegmentSpec,
    district: str | None,
    cad_num: str,
    horizons: Sequence[int] = _DEFAULT_HORIZONS,
    rate_path: dict[int, float] | None = None,
    premise_kind: str = "квартира",
) -> list[DemandSupplyForecast]:
    """Собрать per-горизонт прогноз баланса СПРОС↔ПРЕДЛОЖЕНИЕ (ТЗ §9.8) для сегмента.

    ADVISORY (все §9.x-компоненты advisory до бэктеста #951) — НЕ подключать в
    production-эндпоинт. СБОРКА, НЕ пересчёт §9.x: вызывает уже-смерженные сервисы.

    ⚠️ β УЧТЁН РОВНО ОДИН РАЗ: спрос идёт через §9.4 compute_demand_normalization
    (она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО
    ради explainability-фразы — её β/x_pct в арифметику спроса НЕ входят.

    #1959 ПОСЕГМЕНТНЫЙ баланс (фикс «−1.00 везде»): и СПРОС, и ПРЕДЛОЖЕНИЕ теперь
    сужаются под spec (obj_class/room_bucket), а не берутся district-wide:
      • base_pace = compute_market_metrics(obj_class, room_bucket).unit_velocity —
        дедуплицированный темп ИМЕННО сегмента (раньше один темп на все ячейки →
        каждая what_to_build-ячейка делила одно и то же → дефицит = −1.0 везде).
      • open-сток = market_metrics.n_available того же сегмента (дедуп). При заданном
        сегменте L2/L3 (hidden/future) ИСКЛЮЧАЮТСЯ из баланса — они класс/формат-
        агностичны, иначе один объём двоился бы по всем ячейкам (снова −1.0).

    Один раз на вызов:
      • macro = get_monthly_macro(db) — для дефолтного rate-path (hold_last_rate).
      • base_pace = compute_market_metrics(...).unit_velocity (§9.2 ед./мес).
      • §9.5 compute_macro_coefficient (макро-режим, ортогонален β).
      • §9.6 compute_rate_sensitivity (ТОЛЬКО фраза для explain).

    На каждый горизонт h:
      • rate_future = rate_path[h] (или hold_last_rate).
      • §9.4 compute_demand_normalization(rate_future=rate_future) → norm (β внутри).
      • projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда).
      • §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future
        (для НЕзаданного сегмента); при заданном сегменте open = n_available,
        hidden/future = 0. Чистое предложение = open + hidden_release + future −
        поглощённое спросом (clamp ≥0).
      • balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
        (ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
      • §9.7 future_competitors = top-N get_competitors(horizon_months=h).
      • confidence = MIN(market, §9.4, §9.5, future_supply) ≤ 'medium'.

    Graceful: любой тонкий вход → поле None, НЕ crash. supply ≤0/None → ratio None;
    index = +1.0 если спрос > 0 (пик дефицита, #980), иначе None. Конкуренты
    недоступны (нет геометрии cad_num) → []. Детерминированно.

    Args:
        db: SQLAlchemy sync Session.
        spec: целевой сегмент рынка (любой subset осей).
        district: район для §9.3 supply + §9.2 metrics (None → ЕКБ-wide).
        cad_num: кадастровый номер участка — вход для §9.7 конкурентов.
        horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS).
        rate_path: сценарный {horizon: key_rate}; None → hold_last_rate (плоско).
        premise_kind: тип помещения (по умолчанию 'квартира').

    Returns:
        Список DemandSupplyForecast — по одному на горизонт (всегда; тонко → None-поля).
    """
    horizon_list = list(horizons)
    segment = spec.as_dict()
    profile = _segment_profile(spec)

    # ── Один раз на вызов: макро-ряд + дефолтный rate-path ─────────────────────
    macro = get_monthly_macro(db)
    effective_rate_path = (
        dict(rate_path) if rate_path is not None else hold_last_rate(macro, horizon_list)
    )

    # ── Один раз: §9.2 наблюдаемый темп (base_pace) ────────────────────────────
    # #1959: ПОСЕГМЕНТНЫЙ темп — фильтруем market_metrics по obj_class + room_bucket
    # сегмента. Раньше брался district-wide unit_velocity (один темп на ВСЕ ячейки
    # what_to_build → дефицит вырождался в −1.0 везде). room_bucket идёт из Source-B
    # вокабуляра what_to_build ("Студии 15-30"/…); market_metrics теперь принимает
    # его напрямую (room CASE = зеркало room_area_bucket_of) → переводчик-валидатор
    # (см. _market_room_bucket) только страхует от неизвестных меток.
    metrics = compute_market_metrics(
        db,
        district=district,
        premise_kind=premise_kind,
        obj_class=spec.obj_class,
        room_bucket=_market_room_bucket(spec.room_bucket),
    )
    base_pace = metrics.unit_velocity

    # ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
    macro_coef = compute_macro_coefficient(db, segment_profile=profile)

    # ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика)
    sensitivity = compute_rate_regime_sensitivity(db, spec=spec)

    # Шок-окно (PR2): берём ИЗ §9.5/§9.6 ровно один раз и прокидываем во все per-
    # горизонт DemandSupplyForecast — иначе #990 confounded-factor мёртв (#1222).
    # MagicMock-стабы в тестах могут не задавать атрибут → `getattr(..., False)` +
    # `bool(...)` для безопасности (Mock-attribute truthy исказил бы фактическое
    # значение).
    confounded = _series_confounded(macro_coef, sensitivity)

    # #1959: сегмент «задан», если сужена хотя бы одна предметная ось (класс/формат).
    # Тогда ПРЕДЛОЖЕНИЕ берём ПОСЕГМЕНТНОЕ — open = market_metrics.n_available
    # (дедуплицированный сток ИМЕННО этого сегмента), а L2/L3 (hidden/future)
    # ИСКЛЮЧАЕМ из баланса: они класс/формат-агностичны (нет room-оси) → один и тот
    # же объём прибавился бы КАЖДОЙ ячейке → двойной счёт и снова вырождение к −1.0.
    # Незаданный сегмент (оба None) → прежний district-wide путь (open из §9.3 view +
    # hidden + future), обратная совместимость для агрегатных вызовов.
    segmented = spec.obj_class is not None or spec.room_bucket is not None

    out: list[DemandSupplyForecast] = []
    for h in horizon_list:
        out.append(
            _forecast_for_horizon(
                db,
                spec=spec,
                segment=segment,
                district=district,
                cad_num=cad_num,
                horizon=h,
                rate_future=effective_rate_path.get(h),
                base_pace=base_pace,
                market_confidence=metrics.confidence,
                segment_open_units=metrics.n_available,
                segmented=segmented,
                macro_coef=macro_coef,
                sensitivity_phrase=sensitivity.phrase,
                confounded=confounded,
                premise_kind=premise_kind,
            )
        )
    return out


def _series_confounded(macro_coef: Any, sensitivity: Any) -> bool:
    """Окно §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity пересекает шок-период.

    PR2-флаг приходит ИЗ под-сервисов готовым `.confounded: bool` (см.
    `MacroCoefficient` / `RateSensitivity`). Берём ИЛИ-агрегат: достаточно одного
    True, чтобы окно считалось confounded. `getattr` с дефолтом False для
    устойчивости к стабам/будущим под-вариантам без поля. Используется в `as_dict()`
    → `report_assembler._confounded` → `compute_report_confidence` (#990, #1222).
    """
    macro_flag = bool(getattr(macro_coef, "confounded", False) or False)
    sens_flag = bool(getattr(sensitivity, "confounded", False) or False)
    return macro_flag or sens_flag


def _forecast_for_horizon(
    db: Session,
    *,
    spec: SegmentSpec,
    segment: dict[str, str | None],
    district: str | None,
    cad_num: str,
    horizon: int,
    rate_future: float | None,
    base_pace: float | None,
    market_confidence: Confidence,
    segment_open_units: int,
    segmented: bool,
    macro_coef: Any,
    sensitivity_phrase: str | None,
    confounded: bool,
    premise_kind: str,
) -> DemandSupplyForecast:
    """Собрать прогноз для ОДНОГО горизонта (тонкий — pure-логика выше). Graceful."""
    # ── СПРОС: §9.4 (β внутри — ОДИН раз) → линейная проекция × §9.5 ───────────
    # rate_future None → §9.4 деградирует к нейтрали внутри себя (передаём 0.0 как
    # placeholder ставки, но при норм-coef из low-conf β результат всё равно 1.0).
    norm = compute_demand_normalization(
        db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0
    )
    norm_coefficient = norm.coefficient if rate_future is not None else None
    macro_coefficient = macro_coef.coefficient
    projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon)

    # ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое (− поглощённое) ───
    # fsp читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного
    # сегмента — ещё и open/hidden/future как раньше).
    fsp = compute_future_supply_pressure(
        db, district=district, horizon_months=horizon, premise_kind=premise_kind
    )
    if segmented:
        # #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента
        # (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future)
        # класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке,
        # один и тот же объём двоится по всем сегментам → дефицит снова липнет к −1.0.
        # Поэтому в посегментном балансе hidden/future = 0 (в dataclass их тоже не
        # показываем, чтобы explain не вводил в заблуждение «этот сегмент имеет L2/L3»).
        open_units = segment_open_units
        hidden_release = 0.0
        future_online = 0.0
    else:
        open_units = fsp.open_units
        hidden_fraction = _hidden_release_fraction(horizon)
        hidden_release = fsp.hidden_units * hidden_fraction
        future_online = fsp.future_units_by_horizon
    gross_supply = _gross_supply(open_units, hidden_release, future_online)
    projected_supply = _project_supply(open_units, hidden_release, future_online, projected_demand)

    # ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
    # balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
    # «нет данных»), НО deficit_index в этом случае = +1.0 — сильнейший дефицит, не
    # «нет сигнала» (#980; см. _deficit_index_from_balance). deficit_index —
    # АБСОЛЮТНЫЙ сигнал (часто −1 на затоваренном ЕКБ); months_of_inventory —
    # ДИСКРИМИНИРУЮЩИЙ companion (gross-предложение / помесячный темп спроса).
    balance_units = _balance(projected_demand, projected_supply)
    balance_ratio = _balance_ratio(projected_demand, projected_supply)
    deficit_index = _deficit_index_from_balance(projected_demand, projected_supply, balance_ratio)
    demand_per_mo = _demand_per_mo(projected_demand, horizon)
    months_of_inventory = _months_of_inventory(gross_supply, demand_per_mo)

    # ── §9.7 будущие конкуренты (top-N по relevance_weight) ────────────────────
    future_competitors = _future_competitors(db, cad_num=cad_num, horizon=horizon)

    # ── confidence = MIN(компоненты) ≤ cap ─────────────────────────────────────
    confidence = _cap_confidence(
        _min_confidence([market_confidence, norm.confidence, macro_coef.confidence, fsp.confidence])
    )

    logger.info(
        "demand_supply_forecast: segment=%s h=%d base_pace=%s norm=%s macro=%s "
        "demand=%s supply=%.1f balance=%s ratio=%s deficit_index=%s moi=%s "
        "confidence=%s confounded=%s",
        segment,
        horizon,
        _round_or_none(base_pace, 2),
        _round_or_none(norm_coefficient, 4),
        _round_or_none(macro_coefficient, 4),
        _round_or_none(projected_demand, 1),
        projected_supply,
        _round_or_none(balance_units, 1),
        _round_or_none(balance_ratio, 3),
        _round_or_none(deficit_index, 3),
        _round_or_none(months_of_inventory, 1),
        confidence,
        confounded,
    )

    return DemandSupplyForecast(
        segment=segment,
        horizon_months=horizon,
        base_pace_units_per_mo=base_pace,
        demand_norm_coefficient=norm_coefficient,
        macro_coefficient=macro_coefficient,
        projected_demand_units=projected_demand,
        open_units=open_units,
        hidden_release_units=hidden_release,
        future_online_units=future_online,
        projected_supply_units=projected_supply,
        balance_units=balance_units,
        balance_ratio=balance_ratio,
        deficit_index=deficit_index,
        months_of_inventory=months_of_inventory,
        rate_future=rate_future,
        rate_sensitivity_phrase=sensitivity_phrase,
        future_competitors=future_competitors,
        advisory=True,
        confidence=confidence,
        confounded=confounded,
    )


def _segment_profile(spec: SegmentSpec) -> dict[str, Any]:
    """Спроецировать SegmentSpec на профиль для §9.5 segment_steepness. PURE.

    §9.5 segment_steepness читает obj_class / price_tier / room_bucket. Маппим
    оси spec на ожидаемые ключи (price_bucket → price_tier). None-оси не кладём
    (segment_steepness терпит отсутствие — нейтральная крутизна).
    """
    profile: dict[str, Any] = {}
    if spec.obj_class is not None:
        profile["obj_class"] = spec.obj_class
    if spec.room_bucket is not None:
        profile["room_bucket"] = spec.room_bucket
    if spec.price_bucket is not None:
        profile["price_tier"] = spec.price_bucket
    return profile


def _future_competitors(db: Session, *, cad_num: str, horizon: int) -> list[dict[str, Any]]:
    """§9.7 top-N будущих конкурентов по relevance_weight на горизонт. Graceful → [].

    get_competitors сам horizon-aware (stage_at_horizon). Сортирует по
    relevance_weight DESC, поэтому берём первые _TOP_COMPETITORS. Нет геометрии
    участка (ValueError) / сбой → [] (не валим прогноз — конкуренты опциональны).
    """
    try:
        response = get_competitors(
            db,
            cad_num,
            CompetitorsRequest(horizon_months=horizon, radius_km=_COMPETITORS_RADIUS_KM),
        )
    except Exception:
        logger.exception(
            "demand_supply_forecast: competitors lookup failed (cad_num=%s horizon=%d)",
            cad_num,
            horizon,
        )
        return []
    return [_competitor_to_dict(c) for c in response.competitors[:_TOP_COMPETITORS]]


def _competitor_to_dict(competitor: Any) -> dict[str, Any]:
    """Свести Competitor в компактный explain-словарь (релевантные §9.7 поля). PURE-ish."""
    return {
        "obj_id": competitor.obj_id,
        "comm_name": competitor.comm_name,
        "obj_class": competitor.obj_class,
        "distance_m": competitor.distance_m,
        "flats_total": competitor.flats_total,
        "velocity_per_month": competitor.velocity_per_month,
        "relevance_weight": competitor.relevance_weight,
    }
