"""Unit-тесты §9.x → §22 оркестратора (`build_site_finder_report`, step 3a, 955-A2).

Оркестратор — ШОВ: считает §9.x-слои и раскладывает их в ЧИСТЫЙ §22-сборщик
(`assemble_report`). Тесты пинят ТРИ контракта шва, БЕЗ живой БД (db = sentinel,
§9.x-вызовы запатчены):

  • МОДАЛЬНЫЙ дефолт класса (audit Q1): competitors [Комфорт, Комфорт, Бизнес] →
    obj_class="Комфорт"; пустые competitors → None (ЕКБ-wide). Явный obj_class имеет
    приоритет над выводом.
  • ПРОВОДКА/СИГНАТУРЫ (ловушки audit): патчим §9.x на sentinel'ы, ассертим что
    `assemble_report` получил КАЖДЫЙ вход в правильном слоте, а §9.x вызваны с
    ПРАВИЛЬНОЙ ГЕТЕРОГЕННОЙ сигнатурой (forecasts/scenarios/special_indices ← `horizons=`;
    score_card/future_supply ← `horizon_months=`; overlay ← `target_class=`). Регрессия
    (не тот kwarg) → тест падает.
  • GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается (его слот None),
    исключение НЕ всплывает.

Детерминированно, без сети, без LLM. DATABASE_URL до импорта app-модулей (зеркало
соседних тестов — на случай side-effect'ов импорта пакета forecasting).
"""

from __future__ import annotations

import contextlib
import os
from collections.abc import Iterator, Mapping
from typing import Any
from unittest.mock import MagicMock, patch

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

from app.services.forecasting.orchestrator import (
    _modal_obj_class,
    _normalize_district,
    _primary_horizon,
    _summarize_supply_layers,
    build_site_finder_report,
)
from app.services.forecasting.report import SiteFinderReport

# Полностью-квалифицированный путь к каждому §9.x-символу как он ИМПОРТИРОВАН в
# orchestrator (патчим там, где используется, не там, где определён).
_MOD = "app.services.forecasting.orchestrator"


def _analyze(competitors: list[dict[str, Any]] | None = None) -> dict[str, Any]:
    """Минимальный analyze-dict (только то, что читает оркестратор/сборщик)."""
    return {
        "cad_num": "66:41:0702048:27",
        "district": {"district_name": "Ленинский"},
        "competitors": competitors if competitors is not None else [],
    }


# ──────────────────────────────────────────────────────────────────────────────
# Модальный дефолт класса (audit Q1).
# ──────────────────────────────────────────────────────────────────────────────


class TestModalObjClass:
    def test_modal_picks_most_common(self) -> None:
        comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}]
        assert _modal_obj_class(comps) == "Комфорт"

    def test_empty_competitors_none(self) -> None:
        assert _modal_obj_class([]) is None

    def test_competitors_without_class_none(self) -> None:
        # есть конкуренты, но ни у одного нет класса → None (честно «не уточнён»).
        assert _modal_obj_class([{"obj_id": 1}, {"comm_name": "ЖК Альфа"}]) is None

    def test_legacy_class_key_supported(self) -> None:
        # legacy-ключ `class` (а не `obj_class`) тоже читается.
        assert _modal_obj_class([{"class": "Стандарт"}, {"class": "Стандарт"}]) == "Стандарт"

    def test_obj_class_preferred_over_legacy(self) -> None:
        comps = [{"obj_class": "Бизнес", "class": "Комфорт"}]
        assert _modal_obj_class(comps) == "Бизнес"

    def test_non_list_none(self) -> None:
        assert _modal_obj_class(None) is None
        assert _modal_obj_class("Комфорт") is None  # type: ignore[arg-type]


class TestPrimaryHorizon:
    def test_prefers_12_when_present(self) -> None:
        assert _primary_horizon([6, 12, 18, 24]) == 12

    def test_falls_back_to_first_when_no_12(self) -> None:
        assert _primary_horizon([6, 18, 24]) == 6

    def test_empty_defaults_to_12(self) -> None:
        assert _primary_horizon([]) == 12


# ──────────────────────────────────────────────────────────────────────────────
# Хелпер сводки supply-слоёв (#950 → слот supply_layers сборщика).
# ──────────────────────────────────────────────────────────────────────────────


class TestSummarizeSupplyLayers:
    def test_none_or_empty_none(self) -> None:
        assert _summarize_supply_layers(None) is None
        assert _summarize_supply_layers([]) is None

    def test_aggregates_units_by_layer(self) -> None:
        rows = [
            MagicMock(layer=1, units_estimate=1200, as_dict=lambda: {"layer": 1}),
            MagicMock(layer=2, units_estimate=800, as_dict=lambda: {"layer": 2}),
            MagicMock(layer=3, units_estimate=500, as_dict=lambda: {"layer": 3}),
            MagicMock(layer=1, units_estimate=None, as_dict=lambda: {"layer": 1}),  # None skip
        ]
        summary = _summarize_supply_layers(rows)
        assert summary is not None
        assert summary["open_units"] == 1200
        assert summary["hidden_units"] == 800
        assert summary["future_units"] == 500
        assert summary["n_rows"] == 4


# ──────────────────────────────────────────────────────────────────────────────
# ПРОВОДКА + СИГНАТУРНЫЕ ловушки (audit): §9.x → sentinel, ассерт слот+kwarg.
# ──────────────────────────────────────────────────────────────────────────────


def _patched_layers() -> dict[str, Any]:
    """Контекст-менеджеры-патчи всех §9.x на распознаваемые sentinel-возвраты."""
    # supply_layers возвращает СПИСОК строк (оркестратор свернёт в сводку-dict).
    supply_row = MagicMock(layer=1, units_estimate=10, as_dict=lambda: {"layer": 1})
    return {
        "compute_market_metrics": MagicMock(return_value="MM"),
        "compute_all_layers": MagicMock(return_value=[supply_row]),
        "compute_future_supply_pressure": MagicMock(return_value="FSP"),
        "compute_demand_supply_forecast": MagicMock(return_value=["F6", "F12"]),
        # #1871 P1: compute_scenarios теперь возвращает кортеж
        # `(scenarios, collapsed, collapse_reason)`. Sentinel: scenarios=["SC"], не схлопнуто.
        "compute_scenarios": MagicMock(return_value=(["SC"], False, None)),
        "compute_score_card": MagicMock(return_value="PSC"),
        "compute_special_indices": MagicMock(return_value="SI"),
        "build_forecast_overlay": MagicMock(return_value="OVL"),
        "get_monthly_macro": MagicMock(return_value=[]),
    }


@contextlib.contextmanager
def _patch_layers(mocks: Mapping[str, Any], assemble: Any) -> Iterator[None]:
    """Запатчить ВСЕ §9.x-символы + assemble_report (по `mocks`-словарю). Компактно.

    Патчим там, где символы ИМПОРТИРОВАНЫ в orchestrator (`_MOD`), не там, где определены.
    """
    with contextlib.ExitStack() as stack:
        for name, mock in mocks.items():
            stack.enter_context(patch(f"{_MOD}.{name}", mock))
        stack.enter_context(patch(f"{_MOD}.assemble_report", side_effect=assemble))
        yield


class TestWiringAndSignatures:
    """Патчим §9.x на sentinel'ы + перехватываем assemble_report → ассертим слоты+kwargs."""

    def _run(
        self, *, obj_class: str | None = None, competitors: list[dict[str, Any]] | None = None
    ) -> tuple[SiteFinderReport, dict[str, Any], dict[str, Any]]:
        mocks = _patched_layers()
        captured: dict[str, Any] = {}

        def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
            captured["analyze"] = analyze
            captured.update(kwargs)
            return SiteFinderReport()

        with _patch_layers(mocks, _fake_assemble):
            report = build_site_finder_report(
                MagicMock(name="db"),
                analyze=_analyze(competitors),
                cad_num="66:41:0702048:27",
                district="Ленинский",
                obj_class=obj_class,
                horizons=[6, 12, 18],
            )
        return report, captured, mocks

    def test_each_layer_lands_in_correct_slot(self) -> None:
        _report, captured, _mocks = self._run(obj_class="Комфорт")
        # assemble_report вызван (captured заполнен) — каждый sentinel в СВОЁМ слоте.
        assert captured["market_metrics"] == "MM"
        assert captured["future_supply"] == "FSP"
        assert captured["forecasts"] == ["F6", "F12"]
        assert captured["scenarios"] == ["SC"]
        assert captured["product_scores"] == "PSC"
        assert captured["special_indices"] == "SI"
        # overlay идёт именно в recommendation_overlay (НЕ product_tz/product_scores).
        assert captured["recommendation_overlay"] == "OVL"
        # supply_layers свёрнут в сводку-dict (не сырой список строк).
        assert isinstance(captured["supply_layers"], dict)
        assert captured["supply_layers"]["open_units"] == 10
        # meta-проводка.
        assert captured["cad_num"] == "66:41:0702048:27"
        assert captured["district"] == "Ленинский"
        assert captured["horizons"] == [6, 12, 18]
        assert captured["advisory"] is True
        assert captured["segment"] == {
            "obj_class": "Комфорт",
            "room_bucket": None,
            "district": "Ленинский",
            "price_bucket": None,
        }

    def test_horizons_services_get_horizons_kwarg(self) -> None:
        """forecasts/scenarios/special_indices ДОЛЖНЫ получить `horizons=` (Sequence)."""
        _report, _captured, mocks = self._run(obj_class="Комфорт")
        horizons_services = (
            "compute_demand_supply_forecast",
            "compute_scenarios",
            "compute_special_indices",
        )
        for name in horizons_services:
            _args, kwargs = mocks[name].call_args
            assert kwargs.get("horizons") == [6, 12, 18], f"{name} must take horizons="
            assert "horizon_months" not in kwargs, f"{name} must NOT take horizon_months"
            assert kwargs.get("cad_num") == "66:41:0702048:27"
            assert kwargs.get("district") == "Ленинский"
            # spec прокинут как SegmentSpec.
            assert kwargs["spec"].obj_class == "Комфорт"

    def test_horizon_months_services_get_int_kwarg(self) -> None:
        """score_card/future_supply ДОЛЖНЫ получить `horizon_months=` (int=primary=12)."""
        _report, _captured, mocks = self._run(obj_class="Комфорт")
        _args, sc_kwargs = mocks["compute_score_card"].call_args
        assert sc_kwargs.get("horizon_months") == 12  # 12 в сетке [6,12,18] → primary
        assert "horizons" not in sc_kwargs
        assert sc_kwargs["spec"].obj_class == "Комфорт"
        _args, fsp_kwargs = mocks["compute_future_supply_pressure"].call_args
        assert fsp_kwargs.get("horizon_months") == 12
        assert "horizons" not in fsp_kwargs

    def test_overlay_gets_target_class_not_spec(self) -> None:
        """overlay ДОЛЖЕН получить `target_class=` (str) + `horizon_months=`, НЕ spec."""
        _report, _captured, mocks = self._run(obj_class="Бизнес")
        _args, kwargs = mocks["build_forecast_overlay"].call_args
        assert kwargs.get("target_class") == "Бизнес"
        assert kwargs.get("horizon_months") == 12
        assert "spec" not in kwargs
        assert "horizons" not in kwargs

    def test_market_metrics_and_supply_use_premise_kind(self) -> None:
        """§9.2/§9.3 ходят по premise_kind, без spec/horizons."""
        _report, _captured, mocks = self._run(obj_class="Комфорт")
        _args, mm_kwargs = mocks["compute_market_metrics"].call_args
        assert mm_kwargs.get("premise_kind") == "квартира"
        assert mm_kwargs.get("district") == "Ленинский"
        _args, al_kwargs = mocks["compute_all_layers"].call_args
        assert al_kwargs.get("premise_kind") == "квартира"

    def test_modal_class_default_flows_into_spec_and_overlay(self) -> None:
        """obj_class=None + конкуренты [Комфорт×2, Бизнес] → модальный 'Комфорт' всюду."""
        comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}]
        _report, captured, mocks = self._run(obj_class=None, competitors=comps)
        assert captured["segment"]["obj_class"] == "Комфорт"
        _args, ovl_kwargs = mocks["build_forecast_overlay"].call_args
        assert ovl_kwargs.get("target_class") == "Комфорт"
        _args, f_kwargs = mocks["compute_demand_supply_forecast"].call_args
        assert f_kwargs["spec"].obj_class == "Комфорт"

    def test_empty_competitors_default_class_none(self) -> None:
        """obj_class=None + пустые конкуренты → None (ЕКБ-wide all-class)."""
        _report, captured, mocks = self._run(obj_class=None, competitors=[])
        assert captured["segment"]["obj_class"] is None
        _args, ovl_kwargs = mocks["build_forecast_overlay"].call_args
        assert ovl_kwargs.get("target_class") is None


# ──────────────────────────────────────────────────────────────────────────────
# GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается, слот None.
# ──────────────────────────────────────────────────────────────────────────────


class TestGracefulDegradation:
    def test_one_layer_raises_report_still_assembles(self) -> None:
        mocks = _patched_layers()
        # forecasts-слой бросает — отчёт ДОЛЖЕН собраться с forecasts=None.
        mocks["compute_demand_supply_forecast"] = MagicMock(side_effect=RuntimeError("boom"))
        captured: dict[str, Any] = {}

        def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
            captured.update(kwargs)
            return SiteFinderReport()

        with _patch_layers(mocks, _fake_assemble):
            # НЕ должно бросить, несмотря на падение слоя.
            report = build_site_finder_report(
                MagicMock(name="db"),
                analyze=_analyze([{"obj_class": "Комфорт"}]),
                cad_num="66:41:0702048:27",
                district="Ленинский",
                horizons=[6, 12, 18],
            )
        assert isinstance(report, SiteFinderReport)
        # упавший слой → None в слоте; остальные сохранены.
        assert captured["forecasts"] is None
        assert captured["market_metrics"] == "MM"
        assert captured["scenarios"] == ["SC"]

    def test_all_layers_raise_report_still_assembles(self) -> None:
        """Даже если ВСЕ §9.x-слои бросают — отчёт собирается (все слоты None)."""
        boom = MagicMock(side_effect=RuntimeError("boom"))
        # Каждый §9.x-символ → бросающий mock (имена зеркалят _patched_layers).
        mocks = {name: boom for name in _patched_layers()}
        captured: dict[str, Any] = {}

        def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
            captured.update(kwargs)
            return SiteFinderReport()

        with _patch_layers(mocks, _fake_assemble):
            report = build_site_finder_report(
                MagicMock(name="db"),
                analyze=_analyze([{"obj_class": "Комфорт"}]),
                cad_num="66:41:0702048:27",
                district="Ленинский",
            )
        assert isinstance(report, SiteFinderReport)
        for slot in (
            "market_metrics",
            "supply_layers",
            "future_supply",
            "forecasts",
            "scenarios",
            "product_scores",
            "special_indices",
            "recommendation_overlay",
        ):
            assert captured[slot] is None, f"{slot} should degrade to None"
        # сегмент всё равно проброшен (он PURE, не от §9.x).
        assert captured["segment"]["obj_class"] == "Комфорт"


# ──────────────────────────────────────────────────────────────────────────────
# Defensive district normalization (#1130 follow-up — silent-degrade guard).
# ──────────────────────────────────────────────────────────────────────────────


class TestNormalizeDistrict:
    def test_str_passes_through(self) -> None:
        assert _normalize_district("Ленинский") == "Ленинский"

    def test_none_passes_through(self) -> None:
        assert _normalize_district(None) is None

    def test_dict_with_district_name_extracted(self) -> None:
        d = {
            "district_name": "Верх-Исетский",
            "dist_to_center": 0.0,
            "median_price_per_m2": 139908.25,
        }
        assert _normalize_district(d) == "Верх-Исетский"

    def test_dict_without_district_name_returns_none(self) -> None:
        # Защитный fallback: dict без ожидаемого поля → None + warning.
        # Не падаем (silent-degrade лучше чем TypeError в forecast_cache.wrapper).
        assert _normalize_district({"dist_to_center": 0.0}) is None

    def test_dict_with_empty_district_name_returns_none(self) -> None:
        assert _normalize_district({"district_name": ""}) is None

    def test_dict_with_non_string_district_name_returns_none(self) -> None:
        # «district_name»: 42 — не строка, защищаемся.
        assert _normalize_district({"district_name": 42}) is None

    def test_unsupported_type_returns_none(self) -> None:
        # list / int / прочие неподдерживаемые типы → None + warning.
        assert _normalize_district([1, 2, 3]) is None
        assert _normalize_district(42) is None


# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #1, orchestrator.py `_safe_call`): один сбойный
# §9.x-слой НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ слоёв. Без
# `db.begin_nested()` сбойный `db.execute` внутри слоя оставляет транзакцию Postgres
# aborted («current transaction is aborted, commands ignored until end of
# transaction block») — каждый ПОСЛЕДУЮЩИЙ слой на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────


class _FakeResult:
    """Мини-результат db.execute() — покрывает `.mappings().first()`/`.all()`."""

    def mappings(self) -> _FakeResult:
        return self

    def first(self) -> None:
        return None

    def all(self) -> list[Any]:
        return []


class _PoisonableSession:
    """Fake Session, симулирующая Postgres 'aborted transaction' семантику.

    `execute()` бросает РОВНО один раз (на первый вызов) и, как реальный Postgres,
    «отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
    совершенно несвязанный) тоже бросает — `current transaction is aborted`.
    `begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает
    ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше — как
    настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (который откатил бы
    всю внешнюю транзакцию — established anti-pattern, `backend.md`).
    """

    def __init__(self) -> None:
        self.poisoned = False
        self._raised = False
        self.calls = 0

    def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
        self.calls += 1
        if self.poisoned:
            raise RuntimeError(
                "current transaction is aborted, commands ignored until end of transaction block"
            )
        if not self._raised:
            self._raised = True
            self.poisoned = True
            raise RuntimeError("simulated §9.x layer query failure")
        return _FakeResult()

    @contextlib.contextmanager
    def begin_nested(self) -> Iterator[None]:
        try:
            yield
        except Exception:
            # ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
            # сессия остаётся рабочей для следующего слоя.
            self.poisoned = False
            raise


class TestSafeCallSavepointDoesNotPoisonSession:
    """`_safe_call` оборачивает вызов слоя в `db.begin_nested()` (SAVEPOINT)."""

    def test_layer_db_failure_leaves_session_usable_for_later_layers(self) -> None:
        db = _PoisonableSession()
        mocks = _patched_layers()
        # market_metrics — единственный слой в этом тесте, реально трогающий `db`:
        # его мок сам вызывает db.execute (через _PoisonableSession бросает один раз).
        # Остальные слои остаются sentinel-моками из _patched_layers() (db не трогают).
        mocks["compute_market_metrics"] = MagicMock(
            side_effect=lambda *a, **kw: db.execute("SELECT 1")
        )
        captured: dict[str, Any] = {}

        def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
            captured.update(kwargs)
            return SiteFinderReport()

        with _patch_layers(mocks, _fake_assemble):
            report = build_site_finder_report(
                db,
                analyze=_analyze([{"obj_class": "Комфорт"}]),
                cad_num="66:41:0702048:27",
                district="Ленинский",
                horizons=[6, 12, 18],
            )
        assert isinstance(report, SiteFinderReport)
        # упавший слой → None в слоте (fallback), остальные слои — sentinel'ы, целы.
        assert captured["market_metrics"] is None
        assert captured["scenarios"] == ["SC"]
        assert captured["forecasts"] == ["F6", "F12"]
        # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
        # сбойного слоя проходит без 'current transaction aborted' (не poisoned).
        db.execute("SELECT 1")
