"""Unit-тесты future-supply-pressure service (#950 EPIC 6 Step6, ТЗ §9.3).

Чистые тесты (без живой БД):
  • pure-арифметика: _horizon_weight (вкл. NULL-дату → нейтраль, вне/в окне),
    _months_between, _months_of_pressure (guard div-by-zero → None), _saturating_index
    (монотонность + clamp [0,1] + named-saturation), _monthly_absorption_units (оба
    пути из market_metrics), _min_confidence (худшая тянет вниз, whitelisted).
  • compute_future_supply_pressure через MagicMock-сессию + mock compute_market_metrics
    + mock resolve_objective_districts: форма SQL (читает VIEW v_supply_layers_latest,
    НЕ базовую таблицу / recompute; district-фильтр через ANY(CAST(:names AS text[])),
    НЕ скаляр :district::type), resolver-wiring (админ → микро + сам админ, дедуп;
    None → EKB-wide без фильтра), агрегация по слоям, горизонт-взвешивание L3,
    graceful empty/thin → index None + confidence 'low', confidence=MIN.

Детерминированно, без LLM. Мокаем compute_market_metrics + resolve_objective_districts
+ db (нет живой БД).
"""

from __future__ import annotations

import os
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date
from unittest.mock import MagicMock, patch

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

import pytest

from app.services.site_finder.future_supply import (
    _NULL_HORIZON_WEIGHT,
    _PRESSURE_SATURATION_MONTHS,
    FutureSupplyPressure,
    _horizon_weight,
    _min_confidence,
    _monthly_absorption_units,
    _months_between,
    _months_of_pressure,
    _query_supply_rows,
    _round_or_none,
    _saturating_index,
    compute_future_supply_pressure,
)

# Путь патча reused-сервиса (импортирован в модуль future_supply).
_MARKET = "app.services.site_finder.future_supply.compute_market_metrics"

# Путь патча district-резолвера (импортирован в модуль future_supply). Резолвер бьёт
# живую БД (ekb_districts / ekb_district_alias) — в unit-тестах всегда мокаем.
_RESOLVE = "app.services.site_finder.future_supply.resolve_objective_districts"


def _resolve_passthrough(_db: object, district: str | None) -> list[str] | None:
    """Дефолтная заглушка резолвера: raw-микро pass-through (зеркало legacy-ветки).

    district — raw-микро («X»/«Автовокзал» в большинстве тестов) → `[district]` (фильтр
    по самому имени, как было до фикса); None → None (EKB-wide). Тесты, проверяющие
    admin→micro разворот, передают свой side_effect через `_patched(resolve=…)`.
    """
    return [district] if district is not None else None


# Разрешённый vocab confidence (зеркало CHECK м.125 / market_metrics).
_ALLOWED_CONFIDENCE = {"high", "medium", "low"}


# ── pure: _months_between ─────────────────────────────────────────────────────


class TestMonthsBetween:
    def test_same_month_zero(self) -> None:
        assert _months_between(date(2026, 6, 1), date(2026, 6, 30)) == 0

    def test_forward_months(self) -> None:
        assert _months_between(date(2026, 6, 1), date(2026, 12, 1)) == 6

    def test_across_year(self) -> None:
        assert _months_between(date(2026, 6, 1), date(2027, 6, 1)) == 12

    def test_past_is_negative(self) -> None:
        assert _months_between(date(2026, 6, 1), date(2026, 1, 1)) == -5


# ── pure: _horizon_weight (инверсия stage_at_horizon) ─────────────────────────


class TestHorizonWeight:
    _TODAY = date(2026, 6, 1)

    def test_within_horizon_full_weight(self) -> None:
        # выход через 6 мес, горизонт 12 → в окне → полностью давит (1.0).
        assert _horizon_weight(date(2026, 12, 1), self._TODAY, 12) == 1.0

    def test_exactly_at_horizon_edge_included(self) -> None:
        # ровно на границе горизонта (12 мес) → включаем (≤).
        assert _horizon_weight(date(2027, 6, 1), self._TODAY, 12) == 1.0

    def test_beyond_horizon_zero_weight(self) -> None:
        # выход через 24 мес, горизонт 12 → вне окна → 0.0.
        assert _horizon_weight(date(2028, 6, 1), self._TODAY, 12) == 0.0

    def test_already_online_full_weight(self) -> None:
        # дата в прошлом (уже онлайн) → давит полностью.
        assert _horizon_weight(date(2025, 1, 1), self._TODAY, 12) == 1.0

    def test_null_date_neutral_weight(self) -> None:
        # тайминг неизвестен → нейтральный вес (не игнор, не полный счёт).
        assert _horizon_weight(None, self._TODAY, 12) == _NULL_HORIZON_WEIGHT
        assert _NULL_HORIZON_WEIGHT == 0.5

    def test_shorter_horizon_excludes_more(self) -> None:
        # тот же объект (выход через 9 мес): горизонт 12 включает, горизонт 6 — нет.
        d = date(2027, 3, 1)  # +9 мес
        assert _horizon_weight(d, self._TODAY, 12) == 1.0
        assert _horizon_weight(d, self._TODAY, 6) == 0.0


# ── pure: _months_of_pressure ─────────────────────────────────────────────────


class TestMonthsOfPressure:
    def test_basic(self) -> None:
        # (200 hidden + 100 future) / 50 ед/мес = 6 мес давления.
        assert _months_of_pressure(200, 100.0, 50.0) == pytest.approx(6.0)

    def test_zero_absorption_returns_none(self) -> None:
        # нет поглощения → давление неизмеримо (None, не ∞ и не 0).
        assert _months_of_pressure(200, 100.0, 0.0) is None

    def test_none_absorption_returns_none(self) -> None:
        assert _months_of_pressure(200, 100.0, None) is None

    def test_negative_absorption_returns_none(self) -> None:
        assert _months_of_pressure(200, 100.0, -5.0) is None

    def test_zero_queue_is_zero_not_none(self) -> None:
        # рынок измерим, очереди нет → 0.0 мес давления (валидно, НЕ None).
        assert _months_of_pressure(0, 0.0, 50.0) == 0.0

    def test_none_layers_treated_as_zero(self) -> None:
        # отсутствие слоя ≠ нет данных о рынке: None-объёмы → 0 в сумме.
        assert _months_of_pressure(None, None, 50.0) == 0.0
        assert _months_of_pressure(None, 100.0, 50.0) == pytest.approx(2.0)


# ── pure: _saturating_index (нормировка [0,1]) ────────────────────────────────


class TestSaturatingIndex:
    def test_zero_pressure_zero_index(self) -> None:
        assert _saturating_index(0.0) == 0.0

    def test_at_saturation_is_one(self) -> None:
        assert _saturating_index(_PRESSURE_SATURATION_MONTHS) == 1.0

    def test_half_saturation_is_half(self) -> None:
        assert _saturating_index(_PRESSURE_SATURATION_MONTHS / 2.0) == pytest.approx(0.5)

    def test_beyond_saturation_clamps_to_one(self) -> None:
        # давление выше насыщения → clamp 1.0 (не >1).
        assert _saturating_index(_PRESSURE_SATURATION_MONTHS * 3.0) == 1.0

    def test_negative_clamps_to_zero(self) -> None:
        # артефактно-отрицательное давление → clamp 0.0 (не negative).
        assert _saturating_index(-5.0) == 0.0

    def test_none_passthrough(self) -> None:
        assert _saturating_index(None) is None

    def test_monotonic_non_decreasing(self) -> None:
        # ключевое свойство: больше месяцев давления → не меньший индекс, всё в [0,1].
        prev = -1.0
        for months in [0.0, 1.0, 3.0, 6.0, 9.0, 12.0, 18.0, 30.0, 100.0]:
            idx = _saturating_index(months)
            assert idx is not None
            assert 0.0 <= idx <= 1.0
            assert idx >= prev
            prev = idx


# ── pure: _monthly_absorption_units (вывод из market_metrics) ─────────────────


class TestMonthlyAbsorptionUnits:
    def test_from_months_of_supply(self) -> None:
        # available 120, months_of_supply 6 → 20 ед/мес.
        assert _monthly_absorption_units(None, 6.0, 120) == pytest.approx(20.0)

    def test_prefers_months_of_supply_over_rate(self) -> None:
        # оба заданы → берём прямой путь available/mos (детерминированно).
        # available 120, mos 6 → 20 (а не absorption_rate*available).
        assert _monthly_absorption_units(0.5, 6.0, 120) == pytest.approx(20.0)

    def test_falls_back_to_absorption_rate(self) -> None:
        # mos нет → absorption_rate(0.1) * available(200) = 20 ед/мес.
        assert _monthly_absorption_units(0.1, None, 200) == pytest.approx(20.0)

    def test_no_available_returns_none(self) -> None:
        assert _monthly_absorption_units(0.1, 6.0, 0) is None
        assert _monthly_absorption_units(0.1, 6.0, None) is None

    def test_no_metrics_returns_none(self) -> None:
        # есть сток, но ни mos, ни rate → поглощение неизмеримо (None).
        assert _monthly_absorption_units(None, None, 120) is None

    def test_zero_metrics_returns_none(self) -> None:
        assert _monthly_absorption_units(0.0, 0.0, 120) is None


# ── pure: _min_confidence ─────────────────────────────────────────────────────


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

    def test_low_drags_down(self) -> None:
        # тонкий L3 (low) тянет общий в low даже при high остальных.
        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:
        # None-компонент (нет сигнала) не учитывается — берём min из остальных.
        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"

    def test_only_whitelisted(self) -> None:
        for vals in (["high"], ["medium"], ["low"], ["high", "low"], []):
            assert _min_confidence(vals) in _ALLOWED_CONFIDENCE  # type: ignore[arg-type]


# ── 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


# ── FutureSupplyPressure.as_dict / breakdown ──────────────────────────────────


def _make_pressure(**over: object) -> FutureSupplyPressure:
    base: dict[str, object] = {
        "district": "Автовокзал",
        "horizon_months": 12,
        "premise_kind": "квартира",
        "confidence": "medium",
        "open_units": 300,
        "hidden_units": 200,
        "future_units_by_horizon": 100.0,
        "monthly_absorption_units": 50.0,
        "months_of_pressure": 6.0,
        "index": 0.3333,
    }
    base.update(over)
    return FutureSupplyPressure(**base)  # type: ignore[arg-type]


class TestAsDictBreakdown:
    def test_breakdown_has_all_components_rounded(self) -> None:
        d = _make_pressure().as_dict()
        assert d["index"] == 0.333
        bd = d["breakdown"]
        assert bd["open_units"] == 300
        assert bd["hidden_units"] == 200
        assert bd["future_units_by_horizon"] == 100.0
        assert bd["monthly_absorption_units"] == 50.0
        assert bd["months_of_pressure"] == 6.0
        assert bd["index"] == 0.333

    def test_none_index_survives(self) -> None:
        d = _make_pressure(index=None, months_of_pressure=None).as_dict()
        assert d["index"] is None
        assert d["breakdown"]["months_of_pressure"] is None

    def test_confidence_whitelisted(self) -> None:
        assert _make_pressure().as_dict()["confidence"] in _ALLOWED_CONFIDENCE


# ── MagicMock-сессия helpers (зеркало test_supply_layers) ─────────────────────


def _mock_db(rows: list[dict]) -> MagicMock:
    """Сессия с одним execute → .mappings().all() == rows."""
    db = MagicMock()
    result = MagicMock()
    result.mappings.return_value.all.return_value = rows
    db.execute.return_value = result
    return db


def _executed_sql(db: MagicMock, call_index: int = 0) -> str:
    args, _kwargs = db.execute.call_args_list[call_index]
    return str(args[0])


def _executed_params(db: MagicMock, call_index: int = 0) -> dict:
    args, _kwargs = db.execute.call_args_list[call_index]
    return args[1]


def _assert_no_double_colon_cast(sql: str) -> None:
    """psycopg v3: запрещён :name::type (SQLAlchemy его молча роняет)."""
    import re

    assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}"


def _metrics_stub(
    *,
    absorption_rate: float | None = 0.1,
    months_of_supply: float | None = 6.0,
    n_available: int = 120,
    confidence: str = "high",
) -> MagicMock:
    """Заглушка результата compute_market_metrics (нужные поля для absorption)."""
    m = MagicMock()
    m.absorption_rate = absorption_rate
    m.months_of_supply = months_of_supply
    m.n_available = n_available
    m.confidence = confidence
    return m


@contextmanager
def _patched(
    *, metrics: MagicMock | None = None, resolve: object = _resolve_passthrough
) -> Iterator[tuple[MagicMock, MagicMock]]:
    """Патчит compute_market_metrics + resolve_objective_districts разом.

    `resolve`: либо callable (side_effect — резолвит вход в набор микро), либо значение
    (return_value). По умолчанию pass-through `[district]` — legacy фильтр-по-микро, под
    который написано большинство compute-тестов (district там — raw-микро «X»/«Автовокзал»).
    """
    m = metrics if metrics is not None else _metrics_stub()
    with patch(_MARKET, return_value=m) as mm, patch(_RESOLVE) as rs:
        if callable(resolve):
            rs.side_effect = resolve
        else:
            rs.return_value = resolve
        yield mm, rs


# ── compute_future_supply_pressure: SQL форма (читает VIEW) ────────────────────


class TestComputeReadsView:
    def test_reads_latest_view_not_base_table(self) -> None:
        # КРИТИЧНО (ТЗ §9.3 / м.125): читаем VIEW v_supply_layers_latest, которая
        # включает невыводимые L3 graddoc_stub(#956)/insider_manual. НЕ базовую
        # таблицу supply_layers напрямую и НЕ пересчёт compute_all_layers.
        db = _mock_db([])
        with _patched():
            compute_future_supply_pressure(db, district="Автовокзал")
        sql = _executed_sql(db).lower()
        assert "v_supply_layers_latest" in sql
        # НЕ читаем источники recompute (objective_lots/domrf_kn_objects) — это была бы
        # пересборка слоёв, теряющая stub/insider строки.
        assert "objective_lots" not in sql
        assert "domrf_kn_objects" not in sql

    def test_sql_uses_array_bind_not_double_colon(self) -> None:
        # psycopg v3: district-фильтр через ANY(CAST(:names AS text[])) — массивный bind,
        # НЕ :x::type (SQLAlchemy его молча роняет → syntax error).
        db = _mock_db([])
        with _patched():
            compute_future_supply_pressure(db, district="Автовокзал")
        sql = _executed_sql(db)
        assert "ANY(CAST(:names AS text[]))" in sql
        assert "CAST(:has_district AS boolean)" in sql
        _assert_no_double_colon_cast(sql)

    def test_district_not_a_bare_scalar_filter(self) -> None:
        # Regression guard: МИКС-вокабулярный view нельзя фильтровать скаляром по одному
        # имени (промахивается мимо L1 микро ИЛИ L2/3 админ строк → ложный дефицит, баг
        # парцеля 66:41:0702048:27 / Кировский). Фильтр обязан идти по НАБОРУ имён.
        db = _mock_db([])
        with _patched():
            compute_future_supply_pressure(db, district="Автовокзал")
        sql = _executed_sql(db)
        # ни один из старых скалярных вариантов фильтра по district не должен вернуться.
        assert "district_name = CAST(:district AS text)" not in sql
        assert "district_name = :district" not in sql

    def test_params_pass_district_to_market_metrics(self) -> None:
        # district (админ-имя) пробрасывается как есть в reused market_metrics — там свой
        # резолвер развернёт его в микро (единый район для absorption).
        db = _mock_db([])
        with _patched() as (mm, _rs):
            compute_future_supply_pressure(
                db, district="Академический", horizon_months=24, premise_kind="квартира"
            )
        assert mm.call_args.kwargs["district"] == "Академический"


# ── compute_future_supply_pressure: resolver-wiring (admin→micro+admin) ────────


class TestComputeResolverWiring:
    def test_admin_resolves_to_micros_plus_admin(self) -> None:
        # Баг-репро: «Кировский» (АДМИН) → резолвер даёт микро [Втузгородок, ЖБИ]. Фильтр
        # :names обязан быть микро + сам админ (для L2/3 admin-named строк) и дедупнут.
        db = _mock_db([])
        with _patched(resolve=lambda _db, _d: ["Втузгородок", "ЖБИ"]) as (_mm, rs):
            compute_future_supply_pressure(db, district="Кировский")
        params = _executed_params(db)
        assert params["names"] == ["Втузгородок", "ЖБИ", "Кировский"]
        assert params["has_district"] is True
        # резолвер вызван именно с входным district.
        assert rs.call_args.args[1] == "Кировский"

    def test_admin_micros_already_containing_admin_deduped(self) -> None:
        # Если резолвер вернул набор, уже содержащий сам админ, — не дублируем имя.
        db = _mock_db([])
        with _patched(resolve=lambda _db, _d: ["Кировский", "Втузгородок"]):
            compute_future_supply_pressure(db, district="Кировский")
        assert _executed_params(db)["names"] == ["Кировский", "Втузгородок"]

    def test_raw_micro_input_filters_by_itself(self) -> None:
        # raw-микро вход (legacy внутренний вызов): резолвер вернул [micro] → names=[micro]
        # (сам админ == вход → дедуп оставляет одно имя). Legacy фильтр-по-микро сохранён.
        db = _mock_db([])
        with _patched(resolve=lambda _db, _d: ["Втузгородок"]):
            compute_future_supply_pressure(db, district="Втузгородок")
        assert _executed_params(db)["names"] == ["Втузгородок"]
        assert _executed_params(db)["has_district"] is True

    def test_none_district_no_filter(self) -> None:
        # district None → резолвер None → has_district False, names=[] (EKB-wide, без
        # district-фильтра). Зеркало market_metrics EKB-wide ветки.
        db = _mock_db([])
        with _patched(resolve=lambda _db, _d: None):
            compute_future_supply_pressure(db, district=None)
        params = _executed_params(db)
        assert params["has_district"] is False
        assert params["names"] == []

    def test_admin_without_aliases_ekb_wide(self) -> None:
        # Админ-имя, но резолвер вернул None (дыра в словаре) → EKB-wide fallback (НЕ
        # пустой фильтр-«0 строк гарантированно»). has_district False.
        db = _mock_db([])
        with _patched(resolve=lambda _db, _d: None):
            compute_future_supply_pressure(db, district="Кировский")
        params = _executed_params(db)
        assert params["has_district"] is False
        assert params["names"] == []


# ── compute_future_supply_pressure: агрегация + композит ───────────────────────


class TestComputeAggregation:
    def test_aggregates_layers_and_weights_future(self) -> None:
        # L1=300 open, L2=200 hidden, L3 две строки: 100 в горизонте (вес 1.0) +
        # 80 за горизонтом (вес 0.0) + 40 с NULL-датой (вес 0.5) = 100+0+20 = 120.
        rows = [
            {"layer": 1, "units_estimate": 300, "expected_online_date": None, "confidence": "high"},
            {
                "layer": 2,
                "units_estimate": 200,
                "expected_online_date": None,
                "confidence": "medium",
            },
            {
                "layer": 3,
                "units_estimate": 100,
                "expected_online_date": date(date.today().year + 1, 1, 1),
                "confidence": "low",
            },
            {
                "layer": 3,
                "units_estimate": 80,
                "expected_online_date": date(date.today().year + 5, 1, 1),
                "confidence": "low",
            },
            {"layer": 3, "units_estimate": 40, "expected_online_date": None, "confidence": "low"},
        ]
        db = _mock_db(rows)
        # absorption: available 120, mos 6 → 20 ед/мес.
        with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)):
            res = compute_future_supply_pressure(db, district="X", horizon_months=12)
        assert res.open_units == 300
        assert res.hidden_units == 200
        # 100*1.0 + 80*0.0 + 40*0.5 = 120.0
        assert res.future_units_by_horizon == pytest.approx(120.0)
        assert res.monthly_absorption_units == pytest.approx(20.0)
        # (200 + 120) / 20 = 16 мес давления.
        assert res.months_of_pressure == pytest.approx(16.0)
        # index = 16 / 18 saturation.
        assert res.index == pytest.approx(16.0 / _PRESSURE_SATURATION_MONTHS)
        assert 0.0 <= res.index <= 1.0
        # confidence = MIN(high L1, medium L2, low L3×3, high market) = low.
        assert res.confidence == "low"

    def test_open_units_excluded_from_pressure(self) -> None:
        # Только hidden+future формируют давление; open (L1) — лишь контекст.
        rows = [
            {
                "layer": 1,
                "units_estimate": 9999,
                "expected_online_date": None,
                "confidence": "high",
            },
            {"layer": 2, "units_estimate": 100, "expected_online_date": None, "confidence": "high"},
        ]
        db = _mock_db(rows)
        with _patched(metrics=_metrics_stub(months_of_supply=5.0, n_available=100)):
            res = compute_future_supply_pressure(db, district="X")
        # absorption 100/5=20; давление только от hidden 100 → 100/20=5 мес.
        assert res.monthly_absorption_units == pytest.approx(20.0)
        assert res.months_of_pressure == pytest.approx(5.0)
        assert res.open_units == 9999  # учтён в контексте, но не в давлении

    def test_confidence_is_min_of_components(self) -> None:
        # все supply high, но market_metrics low → итог low.
        rows = [
            {"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"},
        ]
        db = _mock_db(rows)
        with _patched(metrics=_metrics_stub(confidence="low")):
            res = compute_future_supply_pressure(db, district="X")
        assert res.confidence == "low"

    def test_all_high_yields_high(self) -> None:
        rows = [
            {"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"},
        ]
        db = _mock_db(rows)
        with _patched(metrics=_metrics_stub(confidence="high")):
            res = compute_future_supply_pressure(db, district="X")
        assert res.confidence == "high"
        assert res.confidence in _ALLOWED_CONFIDENCE


# ── compute_future_supply_pressure: graceful (empty / thin / div-by-zero) ──────


class TestComputeGraceful:
    def test_empty_supply_and_no_absorption_none_low(self) -> None:
        # Пустой склад (worker ещё не наполнил м.125 на prod) + нет поглощения →
        # index None, months_of_pressure None, confidence low. НЕ crash.
        db = _mock_db([])
        with _patched(
            metrics=_metrics_stub(
                absorption_rate=None, months_of_supply=None, n_available=0, confidence="low"
            )
        ):
            res = compute_future_supply_pressure(db, district="Пусто")
        assert res.index is None
        assert res.months_of_pressure is None
        assert res.monthly_absorption_units is None
        assert res.confidence == "low"
        assert res.open_units == 0
        assert res.hidden_units == 0
        assert res.future_units_by_horizon == 0.0

    def test_empty_supply_but_market_measurable_zero_pressure(self) -> None:
        # Склад пуст, НО рынок измерим → очереди нет → давление 0.0, index 0.0
        # (валидное «нет будущего давления», НЕ None).
        db = _mock_db([])
        with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)):
            res = compute_future_supply_pressure(db, district="X")
        assert res.months_of_pressure == 0.0
        assert res.index == 0.0

    def test_supply_present_but_no_absorption_index_none(self) -> None:
        # Есть hidden/future, но рынок неизмерим (нет поглощения) → div-by-zero
        # guarded → months_of_pressure None → index None (НЕ crash, НЕ ∞).
        rows = [
            {
                "layer": 2,
                "units_estimate": 500,
                "expected_online_date": None,
                "confidence": "medium",
            },
        ]
        db = _mock_db(rows)
        with _patched(
            metrics=_metrics_stub(
                absorption_rate=None, months_of_supply=None, n_available=0, confidence="medium"
            )
        ):
            res = compute_future_supply_pressure(db, district="X")
        assert res.hidden_units == 500
        assert res.monthly_absorption_units is None
        assert res.months_of_pressure is None
        assert res.index is None
        # confidence всё равно whitelisted (MIN medium supply + medium market).
        assert res.confidence in _ALLOWED_CONFIDENCE

    def test_view_query_exception_graceful(self) -> None:
        # Сбой чтения view → [] (graceful), index по absorption (нет очереди → 0.0).
        # Резолвер мокаем отдельно (иначе RuntimeError-side_effect db.execute пробил бы
        # и резолвер) — изолируем именно сбой view-запроса.
        db = MagicMock()
        db.execute.side_effect = RuntimeError("view gone")
        with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)):
            res = compute_future_supply_pressure(db, district="Сбой")
        assert res.hidden_units == 0
        assert res.future_units_by_horizon == 0.0
        assert res.months_of_pressure == 0.0
        assert res.index == 0.0

    def test_null_units_estimate_treated_as_zero(self) -> None:
        # L3-строка только с датой (units_estimate None — частый случай supply_layers) →
        # 0 в объёме, но строка не валит расчёт.
        rows = [
            {
                "layer": 3,
                "units_estimate": None,
                "expected_online_date": date(date.today().year + 1, 1, 1),
                "confidence": "low",
            },
        ]
        db = _mock_db(rows)
        with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)):
            res = compute_future_supply_pressure(db, district="X")
        assert res.future_units_by_horizon == 0.0
        assert res.months_of_pressure == 0.0

    def test_returns_dataclass_always(self) -> None:
        db = _mock_db([])
        with _patched(resolve=lambda _db, _d: None):
            res = compute_future_supply_pressure(db, district=None)
        assert isinstance(res, FutureSupplyPressure)
        assert res.district is None


# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# db shared с caller (compute_future_supply_pressure в цепочке §22-форсайта /
# analyze). Раньше _query_supply_rows ловил db.execute-сбой в bare
# ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это отравляет
# db shared с caller: следующий db.execute на этой же сессии (напр.
# compute_market_metrics, вызываемый следом в compute_future_supply_pressure)
# падает "current transaction is aborted". Фикс — db.execute обёрнут в
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
# на той же mock-сессии.


class TestQuerySupplyRowsSavepoint:
    def test_uses_begin_nested_savepoint(self) -> None:
        db = MagicMock()
        db.execute.return_value.mappings.return_value.all.return_value = []
        _query_supply_rows(db, {"district": "X", "names": ["X"]})
        assert db.begin_nested.call_count == 1

    def test_query_failure_degrades_to_empty_list(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("simulated DB failure")
        assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []

    def test_session_usable_after_failure(self) -> None:
        """db.execute сбоит один раз (supply-layers view), но db остаётся usable
        для следующего вызова на этой же сессии (имитирует
        _query_supply_rows → compute_market_metrics в compute_future_supply_pressure)."""
        db = MagicMock()
        db.execute.side_effect = RuntimeError("boom")
        assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []

        db.execute.side_effect = None
        next_result = MagicMock()
        next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
        db.execute.return_value = next_result
        assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]


class TestComputeFutureSupplyPressureSessionUsableAfterFailure:
    """End-to-end: view-запрос сбоит → SAVEPOINT (не bare rollback) → reused
    compute_market_metrics (отдельный db.execute на той же сессии) не задет."""

    def test_view_failure_does_not_poison_market_metrics_call(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("view gone")
        with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)) as (mm, _rs):
            res = compute_future_supply_pressure(db, district="Сбой")
        assert db.begin_nested.call_count >= 1
        # compute_market_metrics (reused) вызван несмотря на сбой view-запроса —
        # сессия не отравлена (в реальном Postgres это была бы "aborted transaction").
        assert mm.called
        assert res.hidden_units == 0
        assert res.index == 0.0
