"""Tests для §9.7 forecast-overlay моста (#982, 953-A; #983, 953-B) recommendation.py.

Покрывает:
  • PURE bridge-таблицы map_room_bucket / map_room_bucket_inverse — обе стороны +
    unknown→None + DRIFT-GUARD (сверка _FORECAST_TO_LIVE_BUCKET с инверсом
    analytics_queries._BUCKET_PRETTY — таблицы НЕ должны разъехаться).
  • map_class — сворачивание классов + None/unknown→None.
  • build_forecast_overlay через @patch:
      - demand_supply (cad_num задан): rank_segments замокан → маппинг RankedSegment →
        segment-dict, advisory True, DESC-порядок сохранён, unknown room-bucket отброшен.
      - demand_only (cad_num=None): market_metrics/demand_norm/macro замоканы → mode
        flips, обязательный warning, balance_units None, supply НЕ фабрикуется.
      - graceful: rank_segments бросает ValueError → пустой ranked_segments + warning,
        НЕ исключение.
  • #983 (953-B) PURE-билдеры §10/§16:
      - _build_reason — why/drivers/rejected(из runner-up'ов)/what_would_change shape,
        confidence inherited, advisory True.
      - _recommend_class — сильнейший агрегатный дефицит; None на пустом.
      - _usp_from_deficits — top-K по deficit, RU usp_text.
      - _commercial_signal — degraded → available False + caveat, НИКОГДА не crash.
      - build_forecast_overlay теперь несёт class_reco/usp/commercial + per-segment reason,
        а #982-поведение (ключи/порядок/режимы) НЕ сломано.

Mock-based — живой БД не требуют (db = MagicMock; §9.x вызовы патчатся по месту
ЛОКАЛЬНОГО импорта внутри функций recommendation.py).
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from app.services.forecasting.recommendation import (
    _FORECAST_TO_LIVE_BUCKET,
    _USP_TOP_K,
    _build_reason,
    _commercial_signal,
    _recommend_class,
    _usp_from_deficits,
    build_forecast_overlay,
    map_class,
    map_room_bucket,
    map_room_bucket_inverse,
)
from app.services.forecasting.what_to_build import RankedSegment, WhatToBuildRanking

# Точки ЛОКАЛЬНОГО импорта внутри функций recommendation.py — патчим источник,
# чтобы перехватить вызов независимо от того, где импорт исполнится.
_RANK = "app.services.forecasting.what_to_build.rank_segments"
_METRICS = "app.services.site_finder.market_metrics.compute_market_metrics"
_NORM = "app.services.forecasting.demand_normalization.compute_demand_normalization"
_MACRO = "app.services.forecasting.macro_coefficient.compute_macro_coefficient"
_GET_MACRO = "app.services.forecasting.macro_series.get_monthly_macro"
_HOLD = "app.services.forecasting.demand_supply_forecast.hold_last_rate"


# ── PURE: map_room_bucket / map_room_bucket_inverse ───────────────────────────


class TestMapRoomBucket:
    def test_forward_all_five(self) -> None:
        assert map_room_bucket("Студии 15-30") == "1-Студия"
        assert map_room_bucket("1-к 30-45") == "2-1-к"
        assert map_room_bucket("2-к 45-60") == "3-2-к"
        assert map_room_bucket("3-к 60-80") == "4-3-к"
        assert map_room_bucket("80+ м²") == "5-80+ м²"

    def test_inverse_all_five(self) -> None:
        assert map_room_bucket_inverse("1-Студия") == "Студии 15-30"
        assert map_room_bucket_inverse("2-1-к") == "1-к 30-45"
        assert map_room_bucket_inverse("3-2-к") == "2-к 45-60"
        assert map_room_bucket_inverse("4-3-к") == "3-к 60-80"
        assert map_room_bucket_inverse("5-80+ м²") == "80+ м²"

    def test_round_trip_forward_then_inverse(self) -> None:
        for forecast in _FORECAST_TO_LIVE_BUCKET:
            assert map_room_bucket_inverse(map_room_bucket(forecast)) == forecast

    def test_unknown_forward_none(self) -> None:
        assert map_room_bucket("чердак") is None

    def test_unknown_inverse_none(self) -> None:
        assert map_room_bucket_inverse("9-чердак") is None

    def test_none_passthrough(self) -> None:
        assert map_room_bucket(None) is None
        assert map_room_bucket_inverse(None) is None


class TestBucketTableDriftGuard:
    """_FORECAST_TO_LIVE_BUCKET ДОЛЖНА быть точным инверсом analytics_queries._BUCKET_PRETTY.

    Если кто-то поменяет один словарь и забудет другой (дублируем 5 литералов ради
    разрыва import-цикла) — этот тест падает первым.
    """

    def test_is_exact_inverse_of_bucket_pretty(self) -> None:
        from app.services.analytics_queries import _BUCKET_PRETTY

        expected_inverse = {pretty: bid for bid, pretty in _BUCKET_PRETTY.items()}
        assert _FORECAST_TO_LIVE_BUCKET == expected_inverse

    def test_same_cardinality(self) -> None:
        from app.services.analytics_queries import _BUCKET_PRETTY

        assert len(_FORECAST_TO_LIVE_BUCKET) == len(_BUCKET_PRETTY) == 5


# ── PURE: map_class ───────────────────────────────────────────────────────────


class TestMapClass:
    @pytest.mark.parametrize(
        ("live", "expected"),
        [
            ("Comfort", "комфорт"),
            ("Comfort+", "комфорт"),
            ("Business", "бизнес"),
            ("Elite", "бизнес"),
            # #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
            ("Economy", "стандарт"),
        ],
    )
    def test_folding(self, live: str, expected: str) -> None:
        assert map_class(live) == expected

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

    def test_unknown_to_none(self) -> None:
        assert map_class("Luxury++") is None


# ── Helpers для мок-сегментов demand_supply ───────────────────────────────────


def _ranked(
    *,
    room_bucket: str | None,
    deficit_index: float,
    obj_class: str = "комфорт",
    balance_units: float | None = 12.0,
    confidence: str = "medium",
) -> RankedSegment:
    return RankedSegment(
        segment={
            "obj_class": obj_class,
            "room_bucket": room_bucket,
            "district": "Ленинский",
            "price_bucket": None,
        },
        deficit_index=deficit_index,
        balance_units=balance_units,
        confidence=confidence,  # type: ignore[arg-type]
    )


def _ranking(ranked: list[RankedSegment]) -> WhatToBuildRanking:
    return WhatToBuildRanking(
        district="Ленинский",
        cad_num="66:41:0000000:1",
        horizon_months=12,
        ranked=ranked,
        n_cells_scanned=len(ranked),
        n_cells_ranked=len(ranked),
        generated_advisory=True,
    )


# ── build_forecast_overlay: demand_supply (cad_num задан) ──────────────────────


class TestDemandSupplyOverlay:
    def test_mode_and_advisory(self) -> None:
        ranking = _ranking([_ranked(room_bucket="2-к 45-60", deficit_index=0.5)])
        with patch(_RANK, return_value=ranking):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class="Comfort",
            )
        assert out["mode"] == "demand_supply"
        assert out["advisory"] is True
        assert out["horizon_months"] == 12

    def test_maps_segment_buckets_to_live(self) -> None:
        ranking = _ranking(
            [
                _ranked(room_bucket="Студии 15-30", deficit_index=0.9),
                _ranked(room_bucket="2-к 45-60", deficit_index=0.3),
            ]
        )
        with patch(_RANK, return_value=ranking):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        buckets = [s["bucket"] for s in out["ranked_segments"]]
        assert buckets == ["1-Студия", "3-2-к"]

    def test_desc_order_preserved(self) -> None:
        # rank_segments уже отдаёт DESC; overlay не переупорядочивает.
        ranking = _ranking(
            [
                _ranked(room_bucket="80+ м²", deficit_index=0.8),
                _ranked(room_bucket="1-к 30-45", deficit_index=0.2),
                _ranked(room_bucket="3-к 60-80", deficit_index=-0.4),
            ]
        )
        with patch(_RANK, return_value=ranking):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        indices = [s["deficit_index"] for s in out["ranked_segments"]]
        assert indices == [0.8, 0.2, -0.4]

    def test_fields_passed_through(self) -> None:
        ranking = _ranking(
            [
                _ranked(
                    room_bucket="2-к 45-60",
                    deficit_index=0.55,
                    obj_class="бизнес",
                    balance_units=33.0,
                    confidence="medium",
                )
            ]
        )
        with patch(_RANK, return_value=ranking):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=9,
                target_class="Business",
            )
        seg = out["ranked_segments"][0]
        assert seg["obj_class"] == "бизнес"
        assert seg["deficit_index"] == 0.55
        assert seg["balance_units"] == 33.0
        assert seg["confidence"] == "medium"

    def test_unknown_room_bucket_dropped(self) -> None:
        ranking = _ranking(
            [
                _ranked(room_bucket="2-к 45-60", deficit_index=0.5),
                _ranked(room_bucket="мансарда", deficit_index=0.9),  # неизвестный → drop
            ]
        )
        with patch(_RANK, return_value=ranking):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        buckets = [s["bucket"] for s in out["ranked_segments"]]
        assert buckets == ["3-2-к"]

    def test_class_mapped_into_rank_kwargs(self) -> None:
        ranking = _ranking([_ranked(room_bucket="2-к 45-60", deficit_index=0.5)])
        with patch(_RANK, return_value=ranking) as mock_rank:
            build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class="Elite",
            )
        # Elite → бизнес, передаётся ранкеру как classes=["бизнес"].
        assert mock_rank.call_args.kwargs["classes"] == ["бизнес"]

    def test_no_class_omits_classes_kwarg(self) -> None:
        # target_class=None → classes НЕ передаётся (движковый дефолт _DEFAULT_CLASSES).
        ranking = _ranking([_ranked(room_bucket="2-к 45-60", deficit_index=0.5)])
        with patch(_RANK, return_value=ranking) as mock_rank:
            build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert "classes" not in mock_rank.call_args.kwargs

    def test_empty_ranking_yields_warning(self) -> None:
        with patch(_RANK, return_value=_ranking([])):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert out["ranked_segments"] == []
        assert out["warnings"]


# ── build_forecast_overlay: graceful (rank_segments бросает) ───────────────────


class TestDemandSupplyGraceful:
    def test_value_error_yields_empty_plus_warning_no_raise(self) -> None:
        with patch(_RANK, side_effect=ValueError("нет геометрии участка")):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert out["mode"] == "demand_supply"
        assert out["advisory"] is True
        assert out["ranked_segments"] == []
        assert out["warnings"]


# ── build_forecast_overlay: demand_only (cad_num=None) ─────────────────────────


def _mk_metrics(
    unit_velocity: float | None,
    velocity_by_room: dict[str, float] | None = None,
) -> MagicMock:
    m = MagicMock()
    m.unit_velocity = unit_velocity
    m.velocity_by_room = velocity_by_room
    return m


def _mk_coef(coefficient: float) -> MagicMock:
    c = MagicMock()
    c.coefficient = coefficient
    return c


class TestDemandOnlyOverlay:
    def test_mode_flips_when_no_cad_num(self) -> None:
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, return_value=_mk_coef(1.0)),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        assert out["mode"] == "demand_only"
        assert out["advisory"] is True

    def test_mandatory_supply_warning_present(self) -> None:
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, return_value=_mk_coef(1.0)),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        assert any("supply/конкуренты НЕ учтены" in w for w in out["warnings"])

    def test_balance_units_always_none_no_fabricated_supply(self) -> None:
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, return_value=_mk_coef(1.0)),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        assert out["ranked_segments"], "ожидали 5 ранжированных форматов"
        assert all(s["balance_units"] is None for s in out["ranked_segments"])

    def test_confidence_low_for_all_segments(self) -> None:
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, return_value=_mk_coef(1.0)),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        assert all(s["confidence"] == "low" for s in out["ranked_segments"])

    def test_deficit_index_is_pace_proxy_zero_to_one(self) -> None:
        # Разные §9.5-коэффициенты per вызов → разные pace → deficit_index = pace/max ∈ (0,1].
        macro_coeffs = iter([_mk_coef(c) for c in (0.5, 1.0, 1.5, 1.2, 0.8)])
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, side_effect=lambda *a, **k: next(macro_coeffs)),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        indices = [s["deficit_index"] for s in out["ranked_segments"]]
        assert max(indices) == pytest.approx(1.0)  # топ нормирован к 1.0
        assert all(0.0 < i <= 1.0 for i in indices)
        # DESC по pace.
        assert indices == sorted(indices, reverse=True)

    def test_no_velocity_yields_empty_plus_warning(self) -> None:
        # base_pace None (нет §9.2 темпа) → НЕ фабрикуем сигнал, пустой ранкинг + warning.
        with (
            patch(_METRICS, return_value=_mk_metrics(None)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        assert out["mode"] == "demand_only"
        assert out["ranked_segments"] == []
        assert out["warnings"]

    def test_per_bucket_velocity_drives_ranking(self) -> None:
        """#1593: velocity_by_room даёт реальные per-bucket §9.2-темпы.

        При одинаковом §9.5 macro_coef=1.0 ранкинг должен определяться
        исключительно per-bucket velocity_by_room, а не aggregate base_pace.
        Студии ("студия") → самый высокий темп → первые в ранкинге.
        """

        # velocity_by_room: студии продаются в 5× быстрее чем 1-к, остальные ≈ 0.
        vel_by_room = {
            "студия": 10.0,  # "Студии 15-30" → "студия"
            "1": 2.0,  # "1-к 30-45" → "1"
            "2": 1.0,  # "2-к 45-60" → "2"
            "3": 0.5,  # "3-к 60-80" → "3"
            "4": 0.1,  # "80+ м²" → "4" + "5+"
            "5+": 0.1,
        }
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=vel_by_room)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, return_value=_mk_coef(1.0)),  # нейтральный §9.5 (изолируем §9.2)
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        buckets = [s["bucket"] for s in out["ranked_segments"]]
        # При одинаковом macro_coef=1.0 ранкинг = velocity_by_room:
        # студии (10.0) > 1-к (2.0) > 2-к (1.0) > 3-к (0.5) > 80+ (0.1+0.1=0.2)
        assert buckets[0] == "1-Студия", f"ожидали студии первыми, получили {buckets}"
        assert buckets[1] == "2-1-к", f"ожидали 1-к вторыми, получили {buckets}"
        assert buckets[-1] == "5-80+ м²", f"ожидали 80+ м² последними, получили {buckets}"
        # deficit_index нормирован к 1.0 для топа
        assert out["ranked_segments"][0]["deficit_index"] == pytest.approx(1.0)

    def test_fallback_to_aggregate_when_no_velocity_by_room(self) -> None:
        """#1593: при velocity_by_room=None возвращаемся к aggregate base_pace.

        Ranking в этом случае определяется §9.5 macro_coef (старое поведение),
        но не крашит и не фабрикует данные.
        """
        macro_coeffs = iter([_mk_coef(c) for c in (0.5, 1.0, 1.5, 1.2, 0.8)])
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=None)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, side_effect=lambda *a, **k: next(macro_coeffs)),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        # Graceful: 5 сегментов, deficit_index ∈ (0, 1].
        assert len(out["ranked_segments"]) == 5
        assert max(s["deficit_index"] for s in out["ranked_segments"]) == pytest.approx(1.0)

    def test_five_default_room_buckets_ranked(self) -> None:
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, return_value=_mk_coef(1.0)),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        buckets = {s["bucket"] for s in out["ranked_segments"]}
        assert buckets == {"1-Студия", "2-1-к", "3-2-к", "4-3-к", "5-80+ м²"}

    def test_overlay_validates_against_schema(self) -> None:
        # Sanity: demand_only-выход проходит RecommendForecastOverlay-валидацию.
        from app.schemas.recommend import RecommendForecastOverlay

        with (
            patch(_METRICS, return_value=_mk_metrics(4.0)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, return_value=_mk_coef(1.0)),
        ):
            out: dict[str, Any] = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class="Comfort",
            )
        model = RecommendForecastOverlay.model_validate(out)
        assert model.mode == "demand_only"
        assert model.advisory is True


# ──────────────────────────────────────────────────────────────────────────────
# #983 (953-B) PURE-билдеры §10/§16
# ──────────────────────────────────────────────────────────────────────────────


def _seg(
    bucket: str,
    deficit_index: float,
    *,
    obj_class: str | None = "комфорт",
    balance_units: float | None = 12.0,
    confidence: str = "medium",
) -> dict[str, Any]:
    """Live segment-dict (shape _demand_*_overlay) для PURE-билдеров #983."""
    return {
        "bucket": bucket,
        "obj_class": obj_class,
        "deficit_index": deficit_index,
        "balance_units": balance_units,
        "confidence": confidence,
    }


# ── §16: _build_reason ─────────────────────────────────────────────────────────


class TestBuildReason:
    def _ranked(self) -> list[dict[str, Any]]:
        # Фиксированный ранкинг-фикстура (DESC): топ + runner-up + негатив-дефицит.
        return [
            _seg("1-Студия", 0.62, balance_units=62.0, confidence="medium"),
            _seg("3-2-к", 0.20, obj_class="бизнес", confidence="low"),
            _seg("4-3-к", -0.18, confidence="medium"),
        ]

    def test_why_mentions_segment_index_and_horizon(self) -> None:
        ranked = self._ranked()
        r = _build_reason(ranked[0], 0.62, ranked, horizon_months=12)
        assert "1-Студия" in r["why"]
        assert "+0.62" in r["why"]
        assert "12 мес" in r["why"]

    def test_drivers_deficit_and_balance_with_direction(self) -> None:
        ranked = self._ranked()
        r = _build_reason(ranked[0], 0.62, ranked, horizon_months=12)
        factors = {d["factor"]: d for d in r["drivers"]}
        assert factors["deficit_index"]["value"] == 0.62
        assert factors["deficit_index"]["direction"] == "+"
        assert factors["balance_units"]["value"] == 62.0
        assert factors["balance_units"]["direction"] == "+"

    def test_balance_units_omitted_when_none(self) -> None:
        # demand_only-ячейка (balance_units None) → драйвера balance_units нет.
        seg = _seg("1-Студия", 0.8, balance_units=None, confidence="low")
        r = _build_reason(seg, 0.8, [seg], horizon_months=12, demand_only=True)
        factors = {d["factor"] for d in r["drivers"]}
        assert "balance_units" not in factors
        assert "deficit_index" in factors

    def test_rejected_derived_from_runner_ups(self) -> None:
        # rejected = ВСЕ прочие ячейки ранкинга (runner-up + негатив-дефицит).
        ranked = self._ranked()
        r = _build_reason(ranked[0], 0.62, ranked, horizon_months=12)
        alts = {x["alternative"] for x in r["rejected"]}
        assert "3-2-к (бизнес)" in alts
        assert "4-3-к (комфорт)" in alts
        # Сама выбранная ячейка не попадает в отвергнутые.
        assert "1-Студия (комфорт)" not in alts

    def test_rejected_labels_overstock_vs_weaker(self) -> None:
        ranked = self._ranked()
        r = _build_reason(ranked[0], 0.62, ranked, horizon_months=12)
        by_alt = {x["alternative"]: x["reason"] for x in r["rejected"]}
        assert by_alt["3-2-к (бизнес)"] == "слабее сигнал"  # положительный, но ниже
        assert by_alt["4-3-к (комфорт)"] == "затоварка"  # негатив-дефицит

    def test_what_would_change_has_three_levers_with_horizon(self) -> None:
        ranked = self._ranked()
        r = _build_reason(ranked[0], 0.62, ranked, horizon_months=9)
        wwc = r["what_would_change"]
        assert len(wwc) == 3
        assert any("ключевой ставки" in s and "п.п." in s for s in wwc)
        assert any("Layer2" in s for s in wwc)
        assert any("9 до 6 мес" in s for s in wwc)  # horizon подставлен

    def test_confidence_inherited_from_segment(self) -> None:
        seg = _seg("1-Студия", 0.5, confidence="low")
        r = _build_reason(seg, 0.5, [seg], horizon_months=12)
        assert r["confidence"] == "low"

    def test_advisory_always_true(self) -> None:
        seg = _seg("1-Студия", 0.5)
        r = _build_reason(seg, 0.5, [seg], horizon_months=12)
        assert r["advisory"] is True

    def test_demand_only_why_notes_supply_excluded(self) -> None:
        seg = _seg("1-Студия", 0.9, balance_units=None, confidence="low")
        r = _build_reason(seg, 0.9, [seg], horizon_months=12, demand_only=True)
        assert "предложение участка НЕ учтено" in r["why"]


# ── §10.2: _recommend_class ────────────────────────────────────────────────────


class TestRecommendClass:
    def test_picks_strongest_aggregate(self) -> None:
        # комфорт: mean(0.6, -0.2)=0.2 ; бизнес: mean(0.5, 0.5)=0.5 → бизнес сильнее.
        ranked = [
            _seg("1-Студия", 0.6, obj_class="комфорт"),
            _seg("3-2-к", -0.2, obj_class="комфорт"),
            _seg("4-3-к", 0.5, obj_class="бизнес"),
            _seg("5-80+ м²", 0.5, obj_class="бизнес"),
        ]
        out = _recommend_class(ranked, horizon_months=12)
        assert out is not None
        assert out["obj_class"] == "бизнес"
        assert out["mean_deficit_index"] == pytest.approx(0.5)
        assert out["n_segments"] == 2
        assert out["reason"]["advisory"] is True

    def test_none_on_empty(self) -> None:
        assert _recommend_class([], horizon_months=12) is None

    def test_none_when_no_obj_class(self) -> None:
        # Ни у одной ячейки нет класса — агрегировать нечего → None (НЕ фабрикуем).
        ranked = [_seg("1-Студия", 0.6, obj_class=None)]
        assert _recommend_class(ranked, horizon_months=12) is None

    def test_reason_rejected_lists_other_classes(self) -> None:
        # комфорт (0.6) > бизнес (0.5) → выбран комфорт, отвергнут бизнес.
        ranked = [
            _seg("1-Студия", 0.6, obj_class="комфорт"),
            _seg("4-3-к", 0.5, obj_class="бизнес"),
        ]
        out = _recommend_class(ranked, horizon_months=12)
        assert out is not None
        assert out["obj_class"] == "комфорт"
        alts = {x["alternative"] for x in out["reason"]["rejected"]}
        # Отвергнут другой КЛАСС (агрегатная class-level ячейка), не выбранный.
        assert any("бизнес" in a for a in alts)
        assert not any("комфорт" in a for a in alts)


# ── §10.5: _usp_from_deficits ──────────────────────────────────────────────────


class TestUspFromDeficits:
    def test_top_k_by_deficit(self) -> None:
        ranked = [
            _seg("1-Студия", 0.9),
            _seg("2-1-к", 0.7),
            _seg("3-2-к", 0.5),
            _seg("4-3-к", 0.3),
            _seg("5-80+ м²", 0.1),
        ]
        usp = _usp_from_deficits(ranked, horizon_months=12, top_k=3)
        assert len(usp) == 3
        # Сохранён DESC-порядок верхушки.
        assert [u["segment"] for u in usp] == ["1-Студия", "2-1-к", "3-2-к"]

    def test_default_top_k_is_module_const(self) -> None:
        ranked = [_seg(f"b{i}", 1.0 - i * 0.1) for i in range(10)]
        usp = _usp_from_deficits(ranked, horizon_months=12)
        assert len(usp) == _USP_TOP_K

    def test_usp_text_is_russian_deficit_phrasing(self) -> None:
        usp = _usp_from_deficits([_seg("1-Студия", 0.9)], horizon_months=12)
        assert usp[0]["usp_text"] == "Дефицит формата «1-Студия (комфорт)» — стройте его."

    def test_each_usp_carries_reason(self) -> None:
        usp = _usp_from_deficits([_seg("1-Студия", 0.9)], horizon_months=12)
        assert usp[0]["reason"]["advisory"] is True
        assert "deficit_index" in {d["factor"] for d in usp[0]["reason"]["drivers"]}

    def test_empty_input_empty_list(self) -> None:
        assert _usp_from_deficits([], horizon_months=12) == []

    def test_all_negative_deficits_emit_no_usp(self) -> None:
        # Затоварка: ВСЕ сегменты в surplus (di < 0) → честное «нет белых пятен», не
        # «стройте его» для формата, которого и так избыток (BUG #1).
        ranked = [
            _seg("1-Студия", -0.1),
            _seg("2-1-к", -0.4),
            _seg("3-2-к", -0.9),
        ]
        assert _usp_from_deficits(ranked, horizon_months=12) == []

    def test_zero_deficit_not_emitted(self) -> None:
        # di == 0 (баланс) — не дефицит → не USP (gate строго di > 0, зеркало
        # product_scoring._count_positive_usp).
        assert _usp_from_deficits([_seg("2-1-к", 0.0)], horizon_months=12) == []

    def test_mixed_only_positive_deficits_returned(self) -> None:
        # Смесь дефицит/затоварка → только positive-deficit формат становится USP.
        ranked = [
            _seg("1-Студия", 0.6),
            _seg("2-1-к", -0.2),
            _seg("3-2-к", 0.3),
            _seg("4-3-к", -0.5),
        ]
        usp = _usp_from_deficits(ranked, horizon_months=12, top_k=4)
        assert [u["segment"] for u in usp] == ["1-Студия", "3-2-к"]
        assert all(u["deficit_index"] > 0 for u in usp)


# ── §10.4: _commercial_signal (degraded-honest, never crash) ──────────────────


def _mk_commercial_metrics(
    n_lots: int, sell_through_pct: float | None, confidence: str = "medium"
) -> MagicMock:
    m = MagicMock()
    m.n_lots = n_lots
    m.sell_through_pct = sell_through_pct
    m.confidence = confidence
    return m


class TestCommercialSignal:
    def test_thin_data_degraded_with_caveat(self) -> None:
        # Мало нежилых лотов → degraded-honest, БЕЗ фабрикации доли.
        with patch(_METRICS, return_value=_mk_commercial_metrics(5, None)):
            out = _commercial_signal(MagicMock(), "Ленинский", 12)
        assert out is not None
        assert out["available"] is False
        assert "нет достаточных данных" in out["caveat"]
        assert "нежилое" in out["caveat"]
        assert out["advisory"] is True

    def test_engine_exception_degraded_never_crash(self) -> None:
        # Движок бросает (не поддерживает commercial premise_kind) → честный degrade.
        with patch(_METRICS, side_effect=RuntimeError("no premise")):
            out = _commercial_signal(MagicMock(), "Ленинский", 12)
        assert out is not None
        assert out["available"] is False
        assert out["advisory"] is True

    def test_magicmock_metrics_does_not_crash(self) -> None:
        # Нечисловой n_lots (MagicMock-атрибут) НЕ должен бросать TypeError.
        with patch(_METRICS, return_value=MagicMock()):
            out = _commercial_signal(MagicMock(), "Ленинский", 12)
        assert out is not None
        assert out["available"] is False  # нечисловой → недостаток данных

    def test_sufficient_data_advisory_share(self) -> None:
        # Достаточно лотов + измеримая доля → советующая оценка + reason (НЕ crash).
        with patch(_METRICS, return_value=_mk_commercial_metrics(120, 42.5, "medium")):
            out = _commercial_signal(MagicMock(), "Ленинский", 12)
        assert out is not None
        assert out["available"] is True
        assert out["commercial_sell_through_pct"] == 42.5
        assert out["n_lots"] == 120
        assert out["confidence"] == "medium"
        assert out["reason"]["advisory"] is True
        assert out["advisory"] is True


# ── build_forecast_overlay: #983 ADDITIVE-расширения присутствуют ─────────────


class TestOverlayForecast983Additions:
    def _ranking(self) -> WhatToBuildRanking:
        return _ranking(
            [
                _ranked(room_bucket="Студии 15-30", deficit_index=0.6, obj_class="комфорт"),
                _ranked(room_bucket="2-к 45-60", deficit_index=0.2, obj_class="бизнес"),
                _ranked(room_bucket="3-к 60-80", deficit_index=-0.3, obj_class="комфорт"),
            ]
        )

    def test_overlay_has_new_keys(self) -> None:
        with patch(_RANK, return_value=self._ranking()):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert "class_reco" in out
        assert "usp" in out
        assert "commercial" in out

    def test_each_ranked_segment_has_reason(self) -> None:
        with patch(_RANK, return_value=self._ranking()):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert out["ranked_segments"]
        for seg in out["ranked_segments"]:
            assert seg["reason"]["advisory"] is True
            assert "why" in seg["reason"]
            assert "rejected" in seg["reason"]

    def test_class_reco_present_with_ranked_data(self) -> None:
        with patch(_RANK, return_value=self._ranking()):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert out["class_reco"] is not None
        assert out["class_reco"]["obj_class"] in ("комфорт", "бизнес")

    def test_usp_top_k_with_ranked_data(self) -> None:
        with patch(_RANK, return_value=self._ranking()):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert 1 <= len(out["usp"]) <= _USP_TOP_K
        assert out["usp"][0]["segment"] == "1-Студия"  # сильнейший дефицит

    def test_commercial_degraded_on_mock_db(self) -> None:
        # MagicMock-db → нет реальных нежилых лотов → degraded-honest commercial.
        with patch(_RANK, return_value=self._ranking()):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert out["commercial"]["available"] is False
        assert out["commercial"]["advisory"] is True

    def test_empty_ranking_class_reco_none_usp_empty(self) -> None:
        # Пустой ранкинг → class_reco None, usp [], но overlay не падает + commercial есть.
        with patch(_RANK, return_value=_ranking([])):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        assert out["class_reco"] is None
        assert out["usp"] == []
        assert out["commercial"] is not None

    def test_982_keys_still_present_and_unchanged(self) -> None:
        # #982-инвариант: исходные поля overlay на месте + advisory True + mode корректен.
        with patch(_RANK, return_value=self._ranking()):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        for key in ("horizon_months", "mode", "advisory", "ranked_segments", "warnings"):
            assert key in out
        assert out["mode"] == "demand_supply"
        assert out["advisory"] is True
        assert out["horizon_months"] == 12

    def test_demand_only_reasons_note_supply_excluded(self) -> None:
        # demand_only: per-segment reason оговаривает, что предложение не учтено.
        with (
            patch(_METRICS, return_value=_mk_metrics(4.0)),
            patch(_GET_MACRO, return_value=[]),
            patch(_HOLD, return_value={12: 18.0}),
            patch(_NORM, return_value=_mk_coef(1.0)),
            patch(_MACRO, return_value=_mk_coef(1.0)),
        ):
            out = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num=None,
                horizon_months=12,
                target_class=None,
            )
        assert out["ranked_segments"]
        assert all(
            "предложение участка НЕ учтено" in s["reason"]["why"] for s in out["ranked_segments"]
        )

    def test_overlay_with_983_fields_validates_against_schema(self) -> None:
        from app.schemas.recommend import RecommendForecastOverlay

        with patch(_RANK, return_value=self._ranking()):
            out: dict[str, Any] = build_forecast_overlay(
                MagicMock(),
                district="Ленинский",
                cad_num="66:41:0000000:1",
                horizon_months=12,
                target_class=None,
            )
        model = RecommendForecastOverlay.model_validate(out)
        assert model.class_reco is not None
        assert model.usp
        assert model.commercial is not None
        assert model.ranked_segments[0].reason is not None
