"""Unit-тесты §9.8 центрального движка СПРОС↔ПРЕДЛОЖЕНИЕ (#952a, ADVISORY).

Чистые тесты — БЕЗ живой БД (чистая математика + мок всех §9.x compute_*):
  • pure-арифметика: _hidden_release_fraction (6→⅓ / 12→⅔ / 18→1.0 / clamp),
    _project_demand (pace×§9.4×§9.5×h ЛИНЕЙНО; None pace → None; None-коэф → 1.0),
    _project_supply (absorbed-clamp ≥0; None спрос → 0 поглощения),
    _balance / _balance_ratio (знак + None при supply≤0),
    _deficit_index (знаковое насыщение + clamp [−1,+1] + None), _min_confidence,
    _cap_confidence (≤ medium), hold_last_rate (последняя key_rate плоско).
  • compute_demand_supply_forecast через MagicMock-сессию + @patch всех reused
    сервисов (зеркало test_future_supply): спрос = pace×§9.4×§9.5×h ТОЧНО; β НЕ
    учтён дважды (rate_sensitivity.beta/x_pct не влияет на projected_demand);
    confidence = MIN ≤ medium; advisory ВСЕГДА True; один результат на горизонт;
    graceful empty → None-поля; конкуренты-сбой → [].

Детерминированно, без LLM. Мокаем все §9.x compute_* + db (нет живой БД).
"""

from __future__ import annotations

import math
import os
from unittest.mock import MagicMock, patch

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

import datetime as dt
from typing import Any, ClassVar

import pytest

from app.services.forecasting.demand_supply_forecast import (
    _CONFIDENCE_CAP,
    _CONFIDENCE_RANK,
    _DEFICIT_RATIO_SATURATION,
    _HIDDEN_RELEASE_MONTHS,
    _TOP_COMPETITORS,
    DemandSupplyForecast,
    _balance,
    _balance_ratio,
    _cap_confidence,
    _deficit_index,
    _deficit_index_from_balance,
    _demand_per_mo,
    _gross_supply,
    _hidden_release_fraction,
    _market_room_bucket,
    _min_confidence,
    _months_of_inventory,
    _project_demand,
    _project_supply,
    _round_or_none,
    _segment_profile,
    compute_demand_supply_forecast,
    hold_last_rate,
)
from app.services.forecasting.macro_series import MonthlyMacro
from app.services.forecasting.regression import DistributedLagFit
from app.services.forecasting.sales_series import SegmentSpec

# Пути патча reused-сервисов (импортированы в модуль demand_supply_forecast).
_MACRO = "app.services.forecasting.demand_supply_forecast.get_monthly_macro"
_METRICS = "app.services.forecasting.demand_supply_forecast.compute_market_metrics"
_NORM = "app.services.forecasting.demand_supply_forecast.compute_demand_normalization"
_MACRO_COEF = "app.services.forecasting.demand_supply_forecast.compute_macro_coefficient"
# §9.6 для explain-фразы идёт через адаптер compute_rate_regime_sensitivity (в
# namespace demand_supply_forecast), который зовёт compute_district_rate_regression
# (Almon-ADL #978) в namespace regression. Патчим РЕАЛЬНЫЙ Almon-вход настоящим
# DistributedLagFit и прогоняем адаптер целиком → фраза проходит НОВЫЙ путь (а не
# мокаем уже-неиспользуемый single-lag compute_rate_sensitivity — был бы false-green).
_SENS = "app.services.forecasting.regression.compute_district_rate_regression"
_SUPPLY = "app.services.forecasting.demand_supply_forecast.compute_future_supply_pressure"
_COMPETITORS = "app.services.forecasting.demand_supply_forecast.get_competitors"

_ALLOWED_CONFIDENCE = {"high", "medium", "low"}


# ── pure: _hidden_release_fraction ────────────────────────────────────────────


class TestHiddenReleaseFraction:
    def test_six_months_is_third(self) -> None:
        # 6 / 18 = 0.333… (≈⅓ скрытого запаса вышло к 6 мес).
        assert _hidden_release_fraction(6) == pytest.approx(1.0 / 3.0)

    def test_twelve_months_is_two_thirds(self) -> None:
        assert _hidden_release_fraction(12) == pytest.approx(2.0 / 3.0)

    def test_eighteen_months_is_full(self) -> None:
        # ровно цикл созревания → весь скрытый запас вышел (1.0).
        assert _hidden_release_fraction(18) == pytest.approx(1.0)

    def test_beyond_cycle_clamps_to_one(self) -> None:
        assert _hidden_release_fraction(24) == 1.0
        assert _hidden_release_fraction(36) == 1.0

    def test_zero_or_negative_horizon_is_zero(self) -> None:
        assert _hidden_release_fraction(0) == 0.0
        assert _hidden_release_fraction(-5) == 0.0

    def test_uses_named_constant(self) -> None:
        # На горизонте = _HIDDEN_RELEASE_MONTHS → ровно 1.0 (привязка к константе).
        assert _hidden_release_fraction(int(_HIDDEN_RELEASE_MONTHS)) == pytest.approx(1.0)

    def test_degraded_zero_release_months(self) -> None:
        # release_months ≤0 → деградация: любой h>0 → весь сразу (1.0), не /0.
        assert _hidden_release_fraction(6, release_months=0.0) == 1.0
        assert _hidden_release_fraction(0, release_months=0.0) == 0.0


# ── pure: _project_demand (ЛИНЕЙНО, β внутри §9.4 — один раз) ──────────────────


class TestProjectDemand:
    def test_linear_product(self) -> None:
        # 10 ед/мес × 0.8 (§9.4) × 1.1 (§9.5) × 12 мес = 105.6 (ЛИНЕЙНО, без компаунда).
        assert _project_demand(10.0, 0.8, 1.1, 12) == pytest.approx(10.0 * 0.8 * 1.1 * 12)

    def test_linear_not_compounded(self) -> None:
        # Двойной горизонт → ровно двойной спрос (линейность, НЕ (1+r)^h).
        d12 = _project_demand(5.0, 0.9, 1.0, 12)
        d24 = _project_demand(5.0, 0.9, 1.0, 24)
        assert d12 is not None and d24 is not None
        assert d24 == pytest.approx(2.0 * d12)

    def test_none_pace_returns_none(self) -> None:
        # нет наблюдаемого темпа → спрос None (НЕ 0: «нет данных» ≠ «продажи 0»).
        assert _project_demand(None, 0.8, 1.1, 12) is None

    def test_none_coefficients_treated_as_neutral(self) -> None:
        # коэф None → 1.0 (компонент не скорректировал, но темп известен).
        assert _project_demand(10.0, None, None, 12) == pytest.approx(120.0)
        assert _project_demand(10.0, 0.5, None, 12) == pytest.approx(60.0)

    def test_zero_horizon_zero_demand(self) -> None:
        assert _project_demand(10.0, 0.8, 1.1, 0) == 0.0

    def test_zero_pace_is_zero_not_none(self) -> None:
        # темп измерим и равен 0 → спрос 0 (валидно, НЕ None).
        assert _project_demand(0.0, 0.8, 1.1, 12) == 0.0


# ── pure: _project_supply (absorbed-clamp ≥0) ─────────────────────────────────


class TestProjectSupply:
    def test_net_supply_after_absorption(self) -> None:
        # валовое = 100+50+30 = 180; спрос 80 → absorbed 80 → чистое 100.
        assert _project_supply(100.0, 50.0, 30.0, 80.0) == pytest.approx(100.0)

    def test_demand_exceeds_supply_clamps_to_zero(self) -> None:
        # спрос 500 > валовое 180 → absorbed = всё 180 → чистое 0 (clamp ≥0, не отриц).
        assert _project_supply(100.0, 50.0, 30.0, 500.0) == 0.0

    def test_none_demand_no_absorption(self) -> None:
        # спрос None → 0 поглощения → всё валовое конкурирует (консервативно).
        assert _project_supply(100.0, 50.0, 30.0, None) == pytest.approx(180.0)

    def test_zero_demand_no_absorption(self) -> None:
        assert _project_supply(100.0, 50.0, 30.0, 0.0) == pytest.approx(180.0)

    def test_negative_demand_treated_as_zero(self) -> None:
        # артефактно-отрицательный спрос не «добавляет» предложение (max(0, demand)).
        assert _project_supply(100.0, 0.0, 0.0, -50.0) == pytest.approx(100.0)

    def test_empty_supply_is_zero(self) -> None:
        assert _project_supply(0.0, 0.0, 0.0, 80.0) == 0.0

    def test_result_never_negative(self) -> None:
        for demand in [0.0, 10.0, 50.0, 100.0, 250.0, 1000.0]:
            assert _project_supply(40.0, 30.0, 20.0, demand) >= 0.0


# ── pure: _gross_supply (валовое, ДО absorption) ──────────────────────────────


class TestGrossSupply:
    def test_sum_of_layers(self) -> None:
        # валовое = open + hidden_release + future (без вычета спроса).
        assert _gross_supply(100.0, 50.0, 30.0) == pytest.approx(180.0)

    def test_gross_independent_of_demand(self) -> None:
        # gross НЕ зависит от спроса (в отличие от _project_supply net) — это «total
        # inventory» для MOI; net = _project_supply ≤ gross.
        gross = _gross_supply(100.0, 50.0, 30.0)
        net = _project_supply(100.0, 50.0, 30.0, 120.0)
        assert gross == pytest.approx(180.0)
        assert net == pytest.approx(60.0)  # 180 − 120 absorbed
        assert net <= gross

    def test_empty_is_zero(self) -> None:
        assert _gross_supply(0.0, 0.0, 0.0) == 0.0


# ── pure: _demand_per_mo (восстановить помесячный темп из проекции на h) ───────


class TestDemandPerMo:
    def test_divides_by_horizon(self) -> None:
        # projected = demand_per_mo × h → demand_per_mo = projected / h.
        assert _demand_per_mo(120.0, 12) == pytest.approx(10.0)

    def test_none_demand_is_none(self) -> None:
        assert _demand_per_mo(None, 12) is None

    def test_nonpositive_horizon_is_none(self) -> None:
        assert _demand_per_mo(120.0, 0) is None
        assert _demand_per_mo(120.0, -6) is None

    def test_zero_demand_is_zero(self) -> None:
        # спрос 0 (но известен) → темп 0 (НЕ None — это определённый сигнал).
        assert _demand_per_mo(0.0, 12) == pytest.approx(0.0)


# ── pure: _months_of_inventory (gross / темп; ДИСКРИМИНИРУЮЩИЙ companion) ───────


class TestMonthsOfInventory:
    def test_known_supply_and_pace(self) -> None:
        # supply 12000, темп 1000 ед./мес → 12.0 мес.
        assert _months_of_inventory(12000.0, 1000.0) == pytest.approx(12.0)

    def test_supply_over_pace_basic(self) -> None:
        assert _months_of_inventory(49078.0, 1138.0) == pytest.approx(49078.0 / 1138.0)

    def test_none_demand_is_none(self) -> None:
        # «нет данных о спросе» ≠ «∞ запас» → None (не выдумываем бесконечность).
        assert _months_of_inventory(12000.0, None) is None

    def test_zero_pace_is_none(self) -> None:
        # темп 0 → деление невозможно → None (graceful, НЕ crash/∞).
        assert _months_of_inventory(12000.0, 0.0) is None

    def test_negative_pace_is_none(self) -> None:
        assert _months_of_inventory(12000.0, -5.0) is None

    def test_zero_supply_is_zero(self) -> None:
        # пусто = поглощается мгновенно → 0.0 (ОПРЕДЕЛЁННЫЙ сигнал, НЕ None).
        assert _months_of_inventory(0.0, 1000.0) == 0.0

    def test_none_supply_is_none(self) -> None:
        assert _months_of_inventory(None, 1000.0) is None

    def test_in_as_dict_rounded(self) -> None:
        # MOI присутствует в as_dict() и округлён до 1 знака.
        d = _make_forecast(months_of_inventory=43.27).as_dict()
        assert d["months_of_inventory"] == 43.3


# ── pure: _balance ────────────────────────────────────────────────────────────


class TestBalance:
    def test_deficit_positive(self) -> None:
        # спрос 200 > предложение 120 → +80 (дефицит — «хорошо»).
        assert _balance(200.0, 120.0) == pytest.approx(80.0)

    def test_oversupply_negative(self) -> None:
        # спрос 80 < предложение 120 → −40 (затоварка — «риск»).
        assert _balance(80.0, 120.0) == pytest.approx(-40.0)

    def test_none_demand_returns_none(self) -> None:
        assert _balance(None, 120.0) is None

    def test_none_supply_returns_none(self) -> None:
        assert _balance(200.0, None) is None


# ── pure: _balance_ratio (None при supply ≤0) ─────────────────────────────────


class TestBalanceRatio:
    def test_basic_ratio(self) -> None:
        assert _balance_ratio(200.0, 100.0) == pytest.approx(2.0)

    def test_zero_supply_returns_none(self) -> None:
        # предложение исчерпано → None (НЕ ∞: не отличить от «нет данных»).
        assert _balance_ratio(200.0, 0.0) is None

    def test_negative_supply_returns_none(self) -> None:
        assert _balance_ratio(200.0, -5.0) is None

    def test_none_demand_returns_none(self) -> None:
        assert _balance_ratio(None, 100.0) is None

    def test_none_supply_returns_none(self) -> None:
        assert _balance_ratio(200.0, None) is None


# ── pure: _deficit_index (знаковое насыщение [−1,+1]) ─────────────────────────


class TestDeficitIndex:
    def test_balance_is_zero(self) -> None:
        # спрос = предложение (ratio 1.0) → индекс 0 (баланс).
        assert _deficit_index(1.0) == pytest.approx(0.0)

    def test_saturation_ratio_is_plus_one(self) -> None:
        # ratio = saturation (спрос вдвое > предложения) → +1 (сильный дефицит).
        assert _deficit_index(_DEFICIT_RATIO_SATURATION) == pytest.approx(1.0)

    def test_inverse_saturation_is_minus_one(self) -> None:
        # ratio = 1/saturation (предложение вдвое > спроса) → −1 (сильная затоварка).
        assert _deficit_index(1.0 / _DEFICIT_RATIO_SATURATION) == pytest.approx(-1.0)

    def test_beyond_saturation_clamps_to_plus_one(self) -> None:
        assert _deficit_index(_DEFICIT_RATIO_SATURATION * 5.0) == 1.0

    def test_far_below_clamps_to_minus_one(self) -> None:
        assert _deficit_index(1.0 / (_DEFICIT_RATIO_SATURATION * 5.0)) == -1.0

    def test_symmetric_around_balance(self) -> None:
        # ×r и ÷r симметричны вокруг 0 (лог-шкала отношения).
        r = 1.5
        assert _deficit_index(r) == pytest.approx(-_deficit_index(1.0 / r))

    def test_none_ratio_returns_none(self) -> None:
        assert _deficit_index(None) is None

    def test_nonpositive_ratio_returns_none(self) -> None:
        assert _deficit_index(0.0) is None
        assert _deficit_index(-1.0) is None

    def test_monotonic_non_decreasing(self) -> None:
        prev = -2.0
        for ratio in [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 4.0, 10.0]:
            idx = _deficit_index(ratio)
            assert idx is not None
            assert -1.0 <= idx <= 1.0
            assert idx >= prev
            prev = idx

    def test_degraded_saturation_le_one(self) -> None:
        # saturation ≤1 → деградация: только знак перекоса (не /0 в log).
        assert _deficit_index(2.0, saturation=1.0) == 1.0
        assert _deficit_index(0.5, saturation=1.0) == -1.0
        assert _deficit_index(1.0, saturation=1.0) == 0.0


# ── pure: _deficit_index_from_balance (#980 — исчерпанное предложение = +1) ────


class TestDeficitIndexFromBalance:
    def test_supply_exhausted_with_positive_demand_is_plus_one(self) -> None:
        # #980 ЯДРО: спрос > 0 поглотил ВСЁ предложение (supply 0) → +1.0 (был None).
        # balance_ratio честно None (спрос/0), но индекс = пик дефицита.
        assert _deficit_index_from_balance(120.0, 0.0, None) == 1.0

    def test_negative_supply_with_positive_demand_is_plus_one(self) -> None:
        # Артефактно-отрицательное предложение трактуем как исчерпанное → +1.0.
        assert _deficit_index_from_balance(50.0, -3.0, None) == 1.0

    def test_no_signal_supply_and_demand_both_zero_is_none(self) -> None:
        # Ни дефицита, ни затоварки измерить нельзя (пустой рынок) → None.
        assert _deficit_index_from_balance(0.0, 0.0, None) is None

    def test_supply_zero_demand_none_is_none(self) -> None:
        # Спрос неизвестен + предложение исчерпано → нет сигнала (None, не +1).
        assert _deficit_index_from_balance(None, 0.0, None) is None

    def test_normal_path_delegates_to_deficit_index(self) -> None:
        # supply > 0 → обычный лог-путь (ровно _deficit_index(balance_ratio)).
        ratio = _DEFICIT_RATIO_SATURATION  # +1 на лог-шкале
        assert _deficit_index_from_balance(200.0, 100.0, ratio) == pytest.approx(
            _deficit_index(ratio)
        )

    def test_normal_oversupply_negative(self) -> None:
        # supply > 0, спрос << предложения → отрицательный индекс (как раньше).
        ratio = 1.0 / _DEFICIT_RATIO_SATURATION
        idx = _deficit_index_from_balance(50.0, 100.0, ratio)
        assert idx is not None and idx < 0.0

    def test_normal_balance_zero(self) -> None:
        assert _deficit_index_from_balance(100.0, 100.0, 1.0) == pytest.approx(0.0)


# ── pure: _min_confidence / _cap_confidence ───────────────────────────────────


class TestMinConfidence:
    def test_all_high(self) -> None:
        assert _min_confidence(["high", "high"]) == "high"

    def test_low_drags_down(self) -> None:
        assert _min_confidence(["high", "medium", "low"]) == "low"

    def test_medium_floor(self) -> None:
        assert _min_confidence(["high", "medium"]) == "medium"

    def test_none_ignored(self) -> None:
        assert _min_confidence(["high", None, "medium"]) == "medium"

    def test_all_none_low(self) -> None:
        assert _min_confidence([None, None]) == "low"

    def test_empty_low(self) -> None:
        assert _min_confidence([]) == "low"


class TestCapConfidence:
    def test_high_capped_to_medium(self) -> None:
        # advisory-движок: даже all-high → не выше medium.
        assert _cap_confidence("high") == "medium"
        assert _CONFIDENCE_CAP == "medium"

    def test_medium_stays_medium(self) -> None:
        assert _cap_confidence("medium") == "medium"

    def test_low_stays_low(self) -> None:
        assert _cap_confidence("low") == "low"

    def test_custom_cap(self) -> None:
        assert _cap_confidence("high", cap="low") == "low"


# ── pure: hold_last_rate ──────────────────────────────────────────────────────


def _macro_pt(month: dt.date, key_rate: float | None) -> MonthlyMacro:
    return MonthlyMacro(
        month=month,
        key_rate=key_rate,
        mortgage_rate_weighted=None,
        mortgage_issued_count=None,
        mortgage_issued_volume=None,
        mortgage_debt=None,
        mortgage_overdue=None,
    )


class TestHoldLastRate:
    def test_holds_latest_known_rate_flat(self) -> None:
        macro = [
            _macro_pt(dt.date(2025, 1, 1), 16.0),
            _macro_pt(dt.date(2025, 2, 1), 18.0),
            _macro_pt(dt.date(2025, 3, 1), 21.0),
        ]
        path = hold_last_rate(macro, [6, 12, 24])
        assert path == {6: 21.0, 12: 21.0, 24: 21.0}

    def test_ignores_trailing_none(self) -> None:
        # последняя НЕпустая key_rate (трейлинг None игнорируем).
        macro = [
            _macro_pt(dt.date(2025, 1, 1), 16.0),
            _macro_pt(dt.date(2025, 2, 1), None),
        ]
        assert hold_last_rate(macro, [12]) == {12: 16.0}

    def test_all_none_yields_none(self) -> None:
        macro = [_macro_pt(dt.date(2025, 1, 1), None)]
        assert hold_last_rate(macro, [6, 12]) == {6: None, 12: None}

    def test_empty_macro_yields_none(self) -> None:
        assert hold_last_rate([], [6, 12]) == {6: None, 12: None}


# ── pure: _segment_profile ────────────────────────────────────────────────────


class TestSegmentProfile:
    def test_maps_axes(self) -> None:
        spec = SegmentSpec(obj_class="бизнес", room_bucket="3", price_bucket="премиум")
        prof = _segment_profile(spec)
        assert prof["obj_class"] == "бизнес"
        assert prof["room_bucket"] == "3"
        assert prof["price_tier"] == "премиум"  # price_bucket → price_tier

    def test_omits_none_axes(self) -> None:
        prof = _segment_profile(SegmentSpec())
        assert prof == {}


# ── pure: _round_or_none ──────────────────────────────────────────────────────


class TestRoundOrNone:
    def test_rounds(self) -> None:
        assert _round_or_none(1.23456, 3) == 1.235

    def test_none_passthrough(self) -> None:
        assert _round_or_none(None, 3) is None


# ── DemandSupplyForecast.as_dict ──────────────────────────────────────────────


def _make_forecast(**over: object) -> DemandSupplyForecast:
    base: dict[str, object] = {
        "segment": {
            "obj_class": "комфорт",
            "room_bucket": None,
            "district": "X",
            "price_bucket": None,
        },
        "horizon_months": 12,
        "base_pace_units_per_mo": 10.0,
        "demand_norm_coefficient": 0.8,
        "macro_coefficient": 1.1,
        "projected_demand_units": 105.6,
        "open_units": 300,
        "hidden_release_units": 133.33,
        "future_online_units": 40.0,
        "projected_supply_units": 367.73,
        "balance_units": -262.13,
        "balance_ratio": 0.287,
        "deficit_index": -0.9,
        "months_of_inventory": 43.27,
        "rate_future": 21.0,
        "rate_sensitivity_phrase": "при росте ставки …",
        "future_competitors": [{"obj_id": 1, "relevance_weight": 0.7}],
        "advisory": True,
        "confidence": "medium",
        "confounded": False,
    }
    base.update(over)
    return DemandSupplyForecast(**base)  # type: ignore[arg-type]


class TestAsDict:
    def test_all_fields_rounded(self) -> None:
        d = _make_forecast().as_dict()
        assert d["base_pace_units_per_mo"] == 10.0
        assert d["demand_norm_coefficient"] == 0.8
        assert d["macro_coefficient"] == 1.1
        assert d["projected_demand_units"] == 105.6
        assert d["projected_supply_units"] == 367.7
        assert d["deficit_index"] == -0.9
        assert d["months_of_inventory"] == 43.3  # round(43.27, 1)
        assert d["advisory"] is True
        assert d["confidence"] in _ALLOWED_CONFIDENCE

    def test_none_fields_survive(self) -> None:
        d = _make_forecast(
            base_pace_units_per_mo=None,
            projected_demand_units=None,
            balance_units=None,
            balance_ratio=None,
            deficit_index=None,
            months_of_inventory=None,
        ).as_dict()
        assert d["base_pace_units_per_mo"] is None
        assert d["projected_demand_units"] is None
        assert d["balance_ratio"] is None
        assert d["deficit_index"] is None
        assert d["months_of_inventory"] is None

    def test_competitors_passthrough(self) -> None:
        d = _make_forecast().as_dict()
        assert d["future_competitors"] == [{"obj_id": 1, "relevance_weight": 0.7}]


# ── orchestrator helpers (стабы §9.x compute_*) ───────────────────────────────


def _metrics_stub(
    *,
    unit_velocity: float | None = 10.0,
    confidence: str = "high",
    n_available: int = 0,
) -> MagicMock:
    m = MagicMock()
    m.unit_velocity = unit_velocity
    m.confidence = confidence
    # #1959: посегментный путь читает n_available как open-сток сегмента. Дефолт 0
    # (НЕзаданный сегмент его не использует — supply берётся из fsp-стаба).
    m.n_available = n_available
    return m


def _norm_stub(*, coefficient: float = 0.8, confidence: str = "high") -> MagicMock:
    m = MagicMock()
    m.coefficient = coefficient
    m.confidence = confidence
    return m


def _macro_coef_stub(
    *, coefficient: float = 1.1, confidence: str = "high", confounded: bool = False
) -> MagicMock:
    m = MagicMock()
    m.coefficient = coefficient
    m.confidence = confidence
    m.confounded = confounded
    return m


def _sens_stub(
    *, beta: float = -0.5, x_pct: float = -40.0, phrase: str = "при росте ставки …"
) -> DistributedLagFit:
    """РЕАЛЬНЫЙ Almon DistributedLagFit-вход §9.6 (#978) для адаптера.

    Несёт DISTINCTIVE coef(=beta)/x_pct — адаптер прокинет их в RateSensitivity, но
    demand_supply_forecast берёт из §9.6 ТОЛЬКО .phrase (β/x_pct НЕ должны влиять на
    спрос — он идёт через §9.4). 'regression'-source → адаптер выдаёт phrase=fit.phrase.
    """
    return DistributedLagFit(
        segment={"district": "Академический", "obj_class": "комфорт"},
        best_lag_months=3,
        coef=beta,
        x_pct=x_pct,
        r2=0.5,
        n=40,
        per_lag_coef=None,
        hac_se=None,
        hac_bandwidth=None,
        almon_degree=2,
        source="regression",
        phrase=phrase,
    )


def _supply_stub(
    *,
    open_units: int = 300,
    hidden_units: int = 200,
    future_units_by_horizon: float = 40.0,
    confidence: str = "high",
) -> MagicMock:
    m = MagicMock()
    m.open_units = open_units
    m.hidden_units = hidden_units
    m.future_units_by_horizon = future_units_by_horizon
    m.confidence = confidence
    return m


def _competitor_stub(obj_id: int, relevance_weight: float) -> MagicMock:
    c = MagicMock()
    c.obj_id = obj_id
    c.comm_name = f"ЖК-{obj_id}"
    c.obj_class = "комфорт"
    c.distance_m = 500.0
    c.flats_total = 100
    c.velocity_per_month = 5.0
    c.relevance_weight = relevance_weight
    return c


def _competitors_stub(n: int = 3) -> MagicMock:
    resp = MagicMock()
    resp.competitors = [_competitor_stub(i, 0.9 - 0.1 * i) for i in range(n)]
    return resp


class _Patches:
    """Контекст-менеджер: патчит ВСЕ reused §9.x сервисы разом (зеркало духа
    test_future_supply @patch, но 7 зависимостей — держим в одном месте).

    Стабы передаются по коротким алиасам (_ALIAS) → dotted-path константа цели.
    Возвращает dict {dotted_path: MagicMock} для проверки call_args.
    """

    # Короткий алиас (kwarg-имя) → реальная dotted-path цель патча.
    _ALIAS: ClassVar[dict[str, str]] = {
        "_MACRO": _MACRO,
        "_METRICS": _METRICS,
        "_NORM": _NORM,
        "_MACRO_COEF": _MACRO_COEF,
        "_SENS": _SENS,
        "_SUPPLY": _SUPPLY,
        "_COMPETITORS": _COMPETITORS,
    }

    def __init__(self, **stubs: object) -> None:
        self._stubs = stubs
        self._ctxs: list[Any] = []

    def __enter__(self) -> dict[str, MagicMock]:
        defaults: dict[str, object] = {
            _MACRO: [_macro_pt(dt.date(2025, 3, 1), 21.0)],
            _METRICS: _metrics_stub(),
            _NORM: _norm_stub(),
            _MACRO_COEF: _macro_coef_stub(),
            _SENS: _sens_stub(),
            _SUPPLY: _supply_stub(),
            _COMPETITORS: _competitors_stub(),
        }
        for alias, value in self._stubs.items():
            target = self._ALIAS.get(alias, alias)
            defaults[target] = value
        mocks: dict[str, MagicMock] = {}
        for target, value in defaults.items():
            p = patch(target, return_value=value)
            mocks[target] = p.start()
            self._ctxs.append(p)
        return mocks

    def __exit__(self, *exc: object) -> None:
        for p in self._ctxs:
            p.stop()


def _run(**over: object) -> list[DemandSupplyForecast]:
    # #1959: НЕзаданный сегмент (только district) → предложение берётся из §9.3 слоёв
    # (fsp-стаб), как и тестировалось исторически. Посегментный путь (obj_class/
    # room_bucket заданы → open=n_available, L2/L3 исключены) проверяется отдельно в
    # TestSegmentedSupply ниже.
    spec = SegmentSpec(district="Академический")
    return compute_demand_supply_forecast(
        MagicMock(),
        spec=spec,
        district="Академический",
        cad_num="66:41:0303161:123",
        **over,  # type: ignore[arg-type]
    )


def _run_segmented(**over: object) -> list[DemandSupplyForecast]:
    """#1959 посегментный прогон: spec с obj_class+room_bucket → open=n_available,
    L2/L3 (hidden/future) исключены из баланса."""
    spec = SegmentSpec(obj_class="комфорт", room_bucket="Студии 15-30", district="Академический")
    return compute_demand_supply_forecast(
        MagicMock(),
        spec=spec,
        district="Академический",
        cad_num="66:41:0303161:123",
        **over,  # type: ignore[arg-type]
    )


# ── orchestrator: спрос = pace × §9.4 × §9.5 × h ТОЧНО ─────────────────────────


class TestComputeDemand:
    def test_demand_is_pace_times_norm_times_macro_times_h(self) -> None:
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10.0),
            _NORM=_norm_stub(coefficient=0.8),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.1),
        ):
            res = _run(horizons=[12])
        f = res[0]
        assert f.base_pace_units_per_mo == 10.0
        assert f.demand_norm_coefficient == 0.8
        assert f.macro_coefficient == 1.1
        # ровно 10 × 0.8 × 1.1 × 12 (ЛИНЕЙНО, без компаунда).
        assert f.projected_demand_units == pytest.approx(10.0 * 0.8 * 1.1 * 12)

    def test_demand_linear_across_horizons(self) -> None:
        with _Patches():
            res = _run(horizons=[6, 12, 24])
        d6, d12, d24 = (f.projected_demand_units for f in res)
        assert d6 is not None and d12 is not None and d24 is not None
        # линейность: 12-мес = 2×6-мес, 24-мес = 4×6-мес.
        assert d12 == pytest.approx(2.0 * d6)
        assert d24 == pytest.approx(4.0 * d6)

    def test_none_pace_yields_none_demand(self) -> None:
        with _Patches(_METRICS=_metrics_stub(unit_velocity=None)):
            res = _run(horizons=[12])
        assert res[0].base_pace_units_per_mo is None
        assert res[0].projected_demand_units is None


# ── ⚠️ orchestrator: β УЧТЁН РОВНО ОДИН РАЗ (главный тест корректности) ────────


class TestBetaNotDoubleApplied:
    """§9.4 уже применяет β внутри; §9.6 здесь — только для explain-фразы.
    projected_demand НЕ должен зависеть от rate_sensitivity.beta/x_pct."""

    def _demand_with_sens(self, *, beta: float, x_pct: float) -> float:
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10.0),
            _NORM=_norm_stub(coefficient=0.8),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.1),
            _SENS=_sens_stub(beta=beta, x_pct=x_pct),
        ):
            res = _run(horizons=[12])
        demand = res[0].projected_demand_units
        assert demand is not None
        return demand

    def test_demand_independent_of_sensitivity_beta(self) -> None:
        # Меняем β/x_pct §9.6 РАДИКАЛЬНО — спрос обязан остаться идентичным
        # (β применяется ТОЛЬКО внутри §9.4 demand_norm, не повторно здесь).
        mild = self._demand_with_sens(beta=-0.1, x_pct=-5.0)
        wild = self._demand_with_sens(beta=-5.0, x_pct=-99.0)
        positive = self._demand_with_sens(beta=2.0, x_pct=50.0)
        assert mild == pytest.approx(wild)
        assert mild == pytest.approx(positive)

    def test_demand_equals_pace_norm_macro_h_exactly(self) -> None:
        # Спрос = ровно pace×§9.4×§9.5×h — НЕТ скрытого ×(1+x_pct) или ×exp(β).
        demand = self._demand_with_sens(beta=-3.0, x_pct=-90.0)
        assert demand == pytest.approx(10.0 * 0.8 * 1.1 * 12)

    def test_sensitivity_phrase_surfaced_for_explain(self) -> None:
        # §9.6 ВСЁ ЖЕ используется — но только .phrase в выводе (explainability).
        with _Patches(_SENS=_sens_stub(phrase="ставка ↑ → продажи ↓ на 40%")):
            res = _run(horizons=[12])
        assert res[0].rate_sensitivity_phrase == "ставка ↑ → продажи ↓ на 40%"

    def test_norm_coefficient_is_the_only_beta_channel(self) -> None:
        # Меняем §9.4 coefficient → спрос ДОЛЖЕН измениться (это единственный β-канал).
        with _Patches(_NORM=_norm_stub(coefficient=0.5)):
            low = _run(horizons=[12])[0].projected_demand_units
        with _Patches(_NORM=_norm_stub(coefficient=1.0)):
            high = _run(horizons=[12])[0].projected_demand_units
        assert low is not None and high is not None
        assert high == pytest.approx(2.0 * low)


# ── orchestrator: предложение (фаза hidden + absorbed-clamp) ───────────────────


class TestComputeSupply:
    def test_hidden_release_phased_by_horizon(self) -> None:
        # hidden=200; на 6 мес фаза ⅓ → 66.67; future=40; open=300.
        with _Patches(
            _SUPPLY=_supply_stub(open_units=300, hidden_units=200, future_units_by_horizon=40.0),
            _METRICS=_metrics_stub(unit_velocity=0.0),  # спрос 0 → absorbed 0 → валовое всё
        ):
            res = _run(horizons=[6])
        f = res[0]
        assert f.open_units == 300
        assert f.hidden_release_units == pytest.approx(200.0 * (1.0 / 3.0))
        assert f.future_online_units == pytest.approx(40.0)
        # спрос 0 → поглощения нет → чистое = валовое = 300 + 66.67 + 40.
        assert f.projected_supply_units == pytest.approx(300.0 + 200.0 / 3.0 + 40.0)

    def test_absorbed_reduces_supply_clamped(self) -> None:
        # спрос огромный → поглощает всё валовое → чистое предложение 0 (clamp ≥0).
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10_000.0),
            _SUPPLY=_supply_stub(open_units=100, hidden_units=100, future_units_by_horizon=50.0),
        ):
            res = _run(horizons=[18])
        assert res[0].projected_supply_units == 0.0

    def test_supply_never_negative(self) -> None:
        with _Patches(_METRICS=_metrics_stub(unit_velocity=500.0)):
            for f in _run(horizons=[6, 12, 18, 24]):
                assert f.projected_supply_units >= 0.0


# ── orchestrator: баланс / индекс дефицита ─────────────────────────────────────


class TestComputeBalance:
    def test_demand_with_zero_supply_is_strongest_deficit(self) -> None:
        # #980: спрос 10×1.0×1.0×12 = 120; supply open0+hidden0+future0 → 0 валовое.
        # balance_ratio честно None (спрос/0), НО deficit_index = +1.0 (пик дефицита,
        # demand>0 при исчерпанном предложении) — РАНЬШЕ тут был None (баг #980).
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10.0),
            _NORM=_norm_stub(coefficient=1.0),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            _SUPPLY=_supply_stub(open_units=0, hidden_units=0, future_units_by_horizon=0.0),
        ):
            res = _run(horizons=[12])
        f = res[0]
        assert f.projected_demand_units == pytest.approx(120.0)
        assert f.projected_supply_units == 0.0
        # balance_ratio None (∞-кратность не выдумываем), но индекс = +1.0.
        assert f.balance_ratio is None
        assert f.deficit_index == 1.0
        # balance_units всё равно считается (120 − 0 = 120).
        assert f.balance_units == pytest.approx(120.0)

    def test_gross_supply_fully_absorbed_is_plus_one(self) -> None:
        # #980 ЦЕЛЕВОЙ КЕЙС: валовое предложение > 0, но спрос огромный поглощает
        # ВСЁ → effective projected_supply = 0 → deficit_index = +1.0 (был None).
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10_000.0),  # спрос >> валового
            _NORM=_norm_stub(coefficient=1.0),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            _SUPPLY=_supply_stub(open_units=100, hidden_units=100, future_units_by_horizon=50.0),
        ):
            res = _run(horizons=[18])
        f = res[0]
        # валовое было положительным, но поглощено в 0.
        assert f.projected_supply_units == 0.0
        assert f.projected_demand_units is not None and f.projected_demand_units > 0
        assert f.balance_ratio is None
        assert f.deficit_index == 1.0  # сильнейший дефицит, НЕ None

    def test_no_demand_no_supply_index_none(self) -> None:
        # Нет сигнала вообще (спрос 0 И предложение 0) → deficit_index None.
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=0.0),
            _NORM=_norm_stub(coefficient=1.0),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            _SUPPLY=_supply_stub(open_units=0, hidden_units=0, future_units_by_horizon=0.0),
        ):
            res = _run(horizons=[12])
        f = res[0]
        assert f.projected_demand_units == pytest.approx(0.0)
        assert f.projected_supply_units == 0.0
        assert f.balance_ratio is None
        assert f.deficit_index is None  # ни дефицита, ни затоварки — нет сигнала

    def test_oversupply_negative_index(self) -> None:
        # Малый спрос, большое предложение (спрос не поглощает всё) → index < 0.
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=1.0),
            _NORM=_norm_stub(coefficient=1.0),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            _SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
        ):
            res = _run(horizons=[12])
        f = res[0]
        # спрос 12; supply = 1000 − 12 = 988 → ratio 12/988 << 1 → index −1 (затоварка).
        assert f.projected_supply_units == pytest.approx(988.0)
        assert f.balance_ratio is not None and f.balance_ratio < 1.0
        assert f.deficit_index is not None and f.deficit_index < 0.0


# ── orchestrator: months_of_inventory (ДИСКРИМИНИРУЮЩИЙ companion) ─────────────


class TestMonthsOfInventoryOrchestrator:
    def test_moi_is_gross_supply_over_demand_per_mo(self) -> None:
        # h=12; norm=1.0, macro=1.0, velocity=10 → demand_per_mo=10, demand_h12=120.
        # gross = open 1000 + hidden 0×fraction + future 0 = 1000 → MOI = 1000/10 = 100.
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10.0),
            _NORM=_norm_stub(coefficient=1.0),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            _SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
        ):
            res = _run(horizons=[12])
        f = res[0]
        assert f.projected_demand_units == pytest.approx(120.0)
        assert f.months_of_inventory == pytest.approx(100.0)

    def test_moi_uses_gross_not_net_supply(self) -> None:
        # MOI должен делить ВАЛОВОЕ (gross), НЕ net-после-absorption. h=12, velocity=10
        # → demand_h12=120 поглощает 120 из gross. net = 1000−120 = 880, gross = 1000.
        # MOI(gross) = 1000/10 = 100 ≠ MOI(net) = 880/10 = 88 → проверяем именно 100.
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10.0),
            _NORM=_norm_stub(coefficient=1.0),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            _SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
        ):
            res = _run(horizons=[12])
        f = res[0]
        assert f.projected_supply_units == pytest.approx(880.0)  # net (после absorption)
        assert f.months_of_inventory == pytest.approx(100.0)  # gross/темп, НЕ net/темп (88)

    def test_moi_none_when_no_demand(self) -> None:
        # velocity None → projected_demand None → demand_per_mo None → MOI None
        # («нет данных о спросе» ≠ «∞ запас»).
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=None),
            _SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
        ):
            res = _run(horizons=[12])
        f = res[0]
        assert f.projected_demand_units is None
        assert f.months_of_inventory is None

    def test_moi_none_when_zero_demand_pace(self) -> None:
        # velocity 0 → demand_per_mo 0 → MOI None (деление невозможно, graceful).
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=0.0),
            _NORM=_norm_stub(coefficient=1.0),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            _SUPPLY=_supply_stub(open_units=1000, hidden_units=0, future_units_by_horizon=0.0),
        ):
            res = _run(horizons=[12])
        assert res[0].months_of_inventory is None

    def test_moi_zero_when_no_competing_supply(self) -> None:
        # gross 0 (open/hidden/future все 0) при положительном спросе → MOI 0.0.
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10.0),
            _NORM=_norm_stub(coefficient=1.0),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            _SUPPLY=_supply_stub(open_units=0, hidden_units=0, future_units_by_horizon=0.0),
        ):
            res = _run(horizons=[12])
        assert res[0].months_of_inventory == 0.0

    def test_moi_discriminates_where_deficit_does_not(self) -> None:
        """ГЛАВНЫЙ тест: два сегмента с ОДИНАКОВЫМ deficit_index (оба −1, ratio в
        зоне clamp) но РАЗНЫМ supply/velocity → РАЗНЫЙ MOI. В этом весь смысл MOI —
        дискриминировать там, где дефицит-индекс насыщается (зеркало прод-факта ЕКБ).
        """

        def run_segment(*, velocity: float, open_units: int) -> DemandSupplyForecast:
            with _Patches(
                _METRICS=_metrics_stub(unit_velocity=velocity),
                _NORM=_norm_stub(coefficient=1.0),
                _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
                _SUPPLY=_supply_stub(
                    open_units=open_units, hidden_units=0, future_units_by_horizon=0.0
                ),
            ):
                return _run(horizons=[12])[0]

        # Сегмент A: темп 10, gross 2400 → demand_h12 120, net 2280, ratio 0.053 → −1.
        seg_a = run_segment(velocity=10.0, open_units=2400)
        # Сегмент B: темп 20, gross 2400 → demand_h12 240, net 2160, ratio 0.111 → −1.
        seg_b = run_segment(velocity=20.0, open_units=2400)

        # Дефицит-индекс НЕ дискриминирует: оба намертво −1.0 (как на проде ЕКБ).
        assert seg_a.deficit_index == pytest.approx(-1.0)
        assert seg_b.deficit_index == pytest.approx(-1.0)
        # MOI ДИСКРИМИНИРУЕТ: 2400/10 = 240 мес vs 2400/20 = 120 мес.
        assert seg_a.months_of_inventory == pytest.approx(240.0)
        assert seg_b.months_of_inventory == pytest.approx(120.0)
        assert seg_a.months_of_inventory != seg_b.months_of_inventory


# ── orchestrator: confidence = MIN ≤ medium ────────────────────────────────────


class TestComputeConfidence:
    def test_all_high_capped_to_medium(self) -> None:
        # все компоненты high → MIN high, но advisory-cap роняет до medium.
        with _Patches(
            _METRICS=_metrics_stub(confidence="high"),
            _NORM=_norm_stub(confidence="high"),
            _MACRO_COEF=_macro_coef_stub(confidence="high"),
            _SUPPLY=_supply_stub(confidence="high"),
        ):
            res = _run(horizons=[12])
        assert res[0].confidence == "medium"

    def test_low_component_drags_to_low(self) -> None:
        # один low (тонкий §9.3) → MIN low (cap не поднимает).
        with _Patches(
            _METRICS=_metrics_stub(confidence="high"),
            _NORM=_norm_stub(confidence="high"),
            _MACRO_COEF=_macro_coef_stub(confidence="high"),
            _SUPPLY=_supply_stub(confidence="low"),
        ):
            res = _run(horizons=[12])
        assert res[0].confidence == "low"

    def test_never_exceeds_medium(self) -> None:
        for sup_conf in ("high", "medium", "low"):
            with _Patches(_SUPPLY=_supply_stub(confidence=sup_conf)):
                res = _run(horizons=[12])
            assert res[0].confidence in {"low", "medium"}
            assert res[0].confidence != "high"


# ── orchestrator: advisory ВСЕГДА True ─────────────────────────────────────────


class TestAdvisoryFlag:
    def test_advisory_always_true(self) -> None:
        with _Patches():
            res = _run(horizons=[6, 12, 18, 24])
        assert all(f.advisory is True for f in res)

    def test_advisory_true_even_on_thin_data(self) -> None:
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=None, confidence="low"),
            _SUPPLY=_supply_stub(
                open_units=0, hidden_units=0, future_units_by_horizon=0.0, confidence="low"
            ),
        ):
            res = _run(horizons=[12])
        assert res[0].advisory is True


# ── orchestrator: one result per horizon + rate_path ──────────────────────────


class TestHorizonsAndRatePath:
    def test_one_result_per_horizon(self) -> None:
        with _Patches():
            res = _run(horizons=[6, 12, 18, 24])
        assert [f.horizon_months for f in res] == [6, 12, 18, 24]

    def test_default_horizons(self) -> None:
        with _Patches():
            res = _run()  # без horizons → дефолт (6,12,18,24)
        assert [f.horizon_months for f in res] == [6, 12, 18, 24]

    def test_explicit_rate_path_used(self) -> None:
        # caller-сценарий: разные ставки на разных горизонтах → попадают в rate_future.
        with _Patches():
            res = _run(horizons=[6, 12], rate_path={6: 15.0, 12: 20.0})
        assert res[0].rate_future == 15.0
        assert res[1].rate_future == 20.0

    def test_default_rate_path_holds_last_rate(self) -> None:
        # без rate_path → hold_last_rate из макро-ряда (21.0 в стабе).
        with _Patches(_MACRO=[_macro_pt(dt.date(2025, 3, 1), 21.0)]):
            res = _run(horizons=[6, 12])
        assert res[0].rate_future == 21.0
        assert res[1].rate_future == 21.0

    def test_norm_called_with_horizon_rate(self) -> None:
        # §9.4 получает rate_future именно из rate_path (сценарная ставка).
        with _Patches() as mocks:
            _run(horizons=[12], rate_path={12: 19.5})
        norm_calls = mocks[_NORM].call_args_list
        assert any(call.kwargs.get("rate_future") == 19.5 for call in norm_calls)

    def test_horizon_24_meaningful_not_degenerate(self) -> None:
        """ТЗ §12.1 (Q1): на 24 мес прогноз ОСМЫСЛЕННЫЙ, не пустой/деградировавший.

        Не просто «принят» — проверяем что КАЖДОЕ числовое поле посчитано (не None),
        spрос линейно вырос (24 = 4×6-мес, 2×12-мес), скрытый ПД полностью созрел
        (_hidden_release_fraction(24)=1.0 → весь hidden в gross-предложении),
        deficit_index в валидном [−1,+1], confidence ≤ cap. С тем же стабом данных
        24 НЕ ломается и НЕ обрезается на 18.
        """
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10.0),
            _NORM=_norm_stub(coefficient=0.8),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.1),
            _SUPPLY=_supply_stub(open_units=300, hidden_units=200, future_units_by_horizon=40.0),
        ):
            res = _run(horizons=[24])
        f = res[0]
        assert f.horizon_months == 24
        # Спрос посчитан и ЛИНЕЙНО масштабирован на 24 (а не обрезан/занулён).
        assert f.projected_demand_units == pytest.approx(10.0 * 0.8 * 1.1 * 24)
        # Скрытый ПД на 24 ПОЛНОСТЬЮ созрел (фаза=1.0): весь hidden=200 в предложении.
        assert f.hidden_release_units == pytest.approx(200.0)
        # Предложение/баланс/индексы посчитаны (не None) — секция не деградировала.
        assert f.projected_supply_units is not None
        assert f.balance_units is not None
        assert f.deficit_index is not None and -1.0 <= f.deficit_index <= 1.0
        assert f.months_of_inventory is not None and f.months_of_inventory >= 0.0
        # advisory-движок: confidence не выше cap даже при всех 'high'-компонентах.
        assert f.confidence in _ALLOWED_CONFIDENCE
        assert _CONFIDENCE_RANK[f.confidence] <= _CONFIDENCE_RANK[_CONFIDENCE_CAP]

    def test_horizon_24_differs_from_18(self) -> None:
        """24 — отдельный горизонт, а не «то же что 18»: спрос/MOI растут с горизонтом.

        Guard от скрытого clamp'а горизонта на 18 (если бы такой был — поля на 18 и 24
        совпали бы). Спрос строго линеен, поэтому demand(24) > demand(18); валовое
        предложение на 24 ≥ 18 (окно шире → больше future-юнитов попадает), значит MOI
        тоже не вырождается в равенство при ненулевом спросе.
        """
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=10.0),
            _NORM=_norm_stub(coefficient=0.8),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.1),
        ):
            res = _run(horizons=[18, 24])
        f18, f24 = res
        assert f18.horizon_months == 18 and f24.horizon_months == 24
        assert f18.projected_demand_units is not None and f24.projected_demand_units is not None
        # Спрос строго растёт с горизонтом (линейность) — 24 ≠ 18.
        assert f24.projected_demand_units > f18.projected_demand_units


# ── orchestrator: §9.7 competitors (top-N, graceful) ──────────────────────────


class TestFutureCompetitors:
    def test_top_n_competitors_surfaced(self) -> None:
        with _Patches(_COMPETITORS=_competitors_stub(n=10)):
            res = _run(horizons=[12])
        comps = res[0].future_competitors
        assert len(comps) == _TOP_COMPETITORS  # top-N среза
        assert comps[0]["relevance_weight"] == pytest.approx(0.9)
        assert "obj_id" in comps[0] and "distance_m" in comps[0]

    def test_competitors_request_carries_horizon(self) -> None:
        with _Patches() as mocks:
            _run(horizons=[24])
        # CompetitorsRequest собран с horizon_months=24 (horizon-aware §9.7).
        call = mocks[_COMPETITORS].call_args
        request = call.args[2]
        assert request.horizon_months == 24

    def test_competitor_lookup_failure_graceful(self) -> None:
        # нет геометрии участка (ValueError) → [] (НЕ валим прогноз).
        with _Patches():
            with patch(_COMPETITORS, side_effect=ValueError("геометрия не найдена")):
                res = _run(horizons=[12])
        assert res[0].future_competitors == []
        # остальной прогноз всё равно собран.
        assert res[0].projected_demand_units is not None


# ── orchestrator: graceful empty → None-поля, никогда не crash ─────────────────


class TestGraceful:
    def test_empty_everything_yields_none_fields(self) -> None:
        with _Patches(
            _MACRO=[],
            _METRICS=_metrics_stub(unit_velocity=None, confidence="low"),
            _NORM=_norm_stub(coefficient=1.0, confidence="low"),
            _MACRO_COEF=_macro_coef_stub(coefficient=1.0, confidence="low"),
            _SUPPLY=_supply_stub(
                open_units=0, hidden_units=0, future_units_by_horizon=0.0, confidence="low"
            ),
            _COMPETITORS=MagicMock(competitors=[]),
        ):
            res = _run(horizons=[12])
        f = res[0]
        assert f.base_pace_units_per_mo is None
        assert f.projected_demand_units is None
        assert f.projected_supply_units == 0.0
        assert f.balance_units is None  # demand None → balance None
        assert f.balance_ratio is None
        assert f.deficit_index is None
        assert f.months_of_inventory is None  # demand None → demand_per_mo None → MOI None
        assert f.future_competitors == []
        assert f.confidence == "low"
        assert f.advisory is True

    def test_rate_future_none_when_no_macro(self) -> None:
        # пустой макро-ряд → hold_last_rate даёт None → rate_future None (§9.4 нейтраль).
        with _Patches(_MACRO=[]):
            res = _run(horizons=[12])
        assert res[0].rate_future is None
        # demand_norm_coefficient None (rate_future None → §9.4 не применяли как канал).
        assert res[0].demand_norm_coefficient is None

    def test_returns_list_always(self) -> None:
        with _Patches():
            res = _run(horizons=[6, 12])
        assert isinstance(res, list)
        assert all(isinstance(f, DemandSupplyForecast) for f in res)


# ── #1959: VOCAB-мост room_bucket (load-bearing) ──────────────────────────────


class TestMarketRoomBucketTranslator:
    """_market_room_bucket: Source-B метки what_to_build → room-фильтр market_metrics.

    Вокабуляр выровнен (#1959) → распознанная Source-B метка проходит КАК ЕСТЬ;
    None / неизвестная → None (= без room-фильтра, district-wide темп) вместо тихого
    0-rows фильтра, который выбросил бы ячейку из ранкинга.
    """

    @pytest.mark.parametrize(
        "bucket",
        ["Студии 15-30", "1-к 30-45", "2-к 45-60", "3-к 60-80", "80+ м²"],
    )
    def test_recognized_source_b_passes_through(self, bucket: str) -> None:
        assert _market_room_bucket(bucket) == bucket

    def test_none_to_none(self) -> None:
        assert _market_room_bucket(None) is None

    def test_unknown_vocab_to_none(self) -> None:
        # Source-A метка ("студия"/"1"/…) или опечатка → None (НЕ тихий 0-rows).
        assert _market_room_bucket("студия") is None
        assert _market_room_bucket("1") is None
        assert _market_room_bucket("totally-bogus") is None


# ── #1959: ПОСЕГМЕНТНОЕ предложение (open=n_available, L2/L3 исключены) ─────────


class TestSegmentedSupply:
    """#1959: при заданном сегменте (obj_class/room_bucket) предложение = открытый
    сток ИМЕННО сегмента (market_metrics.n_available), а L2/L3 (hidden/future)
    ИСКЛЮЧАЮТСЯ из баланса (они класс/формат-агностичны → иначе двоились бы по всем
    ячейкам → дефицит снова вырождается в −1.0).
    """

    def test_open_units_from_n_available_not_fsp(self) -> None:
        # fsp отдаёт open=300/hidden=200/future=40, НО сегмент задан → open = 555
        # (n_available сегмента), hidden/future = 0.
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=0.0, n_available=555),
            _SUPPLY=_supply_stub(open_units=300, hidden_units=200, future_units_by_horizon=40.0),
        ):
            res = _run_segmented(horizons=[12])
        f = res[0]
        assert f.open_units == 555  # из n_available, НЕ fsp.open_units (300)
        assert f.hidden_release_units == 0.0  # L2 исключён
        assert f.future_online_units == 0.0  # L3 исключён
        # спрос 0 → нет поглощения → чистое = open сегмента.
        assert f.projected_supply_units == pytest.approx(555.0)

    def test_segments_differentiate_not_all_minus_one(self) -> None:
        """Регрессия #1959: ДВА сегмента с разным спросом/стоком → РАЗНЫЙ
        deficit_index (НЕ оба −1.0). Корень бага: один district-wide темп + один
        district-wide сток → каждая ячейка делила одно и то же → все −1.0.
        """

        def seg(*, velocity: float, n_available: int) -> DemandSupplyForecast:
            with _Patches(
                _METRICS=_metrics_stub(unit_velocity=velocity, n_available=n_available),
                _NORM=_norm_stub(coefficient=1.0),
                _MACRO_COEF=_macro_coef_stub(coefficient=1.0),
            ):
                return _run_segmented(horizons=[24])[0]

        # Сегмент с сильным спросом + малым стоком → дефицит (index > 0).
        hot = seg(velocity=50.0, n_available=10)
        # Сегмент со слабым спросом + большим стоком → затоварка (index < 0).
        cold = seg(velocity=1.0, n_available=5000)
        assert hot.deficit_index is not None and cold.deficit_index is not None
        # КЛЮЧЕВОЕ: ячейки ДИСКРИМИНИРУЮТ (не липнут к одному значению).
        assert hot.deficit_index != cold.deficit_index
        assert hot.deficit_index > cold.deficit_index

    def test_unsegmented_still_uses_fsp_layers(self) -> None:
        # Контроль обратной совместимости: spec без class/room → supply из fsp слоёв
        # (open 300 + hidden-phased + future), n_available игнорируется.
        with _Patches(
            _METRICS=_metrics_stub(unit_velocity=0.0, n_available=999),
            _SUPPLY=_supply_stub(open_units=300, hidden_units=0, future_units_by_horizon=40.0),
        ):
            res = _run(horizons=[12])  # _run = НЕзаданный сегмент
        f = res[0]
        assert f.open_units == 300  # из fsp, НЕ n_available (999)
        assert f.future_online_units == pytest.approx(40.0)


# ── sanity: math import used (deficit lib) ────────────────────────────────────


def test_math_log_used_in_deficit() -> None:
    # _deficit_index использует math.log — sanity на знаковую лог-шкалу.
    assert _deficit_index(math.e ** math.log(_DEFICIT_RATIO_SATURATION)) == pytest.approx(1.0)
