"""Unit-тесты §9.7 ранкера «что строить» (#981, ADVISORY).

Чистые тесты — БЕЗ живой БД (мок #980 compute_demand_supply_forecast → ячейки с
известным deficit_index):
  • pure _rank_key / _segment_key / _build_grid (декартова сетка, размер, порядок).
  • rank_segments через MagicMock-сессию + @patch #980: сортировка по deficit_index
    УБЫВАНИЕ; отбрасывание ячеек с deficit_index None (тонкие данные); tie-break
    (равный индекс → выше confidence → стабильный segment-ключ); generated_advisory
    ВСЕГДА True; graceful (вся сетка None → пустой ранкинг; сбой #980 на ячейке →
    ячейка выпадает, не crash); n_cells_scanned/ranked корректны.

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

from __future__ import annotations

import os

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

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

import pytest

from app.services.forecasting.what_to_build import (
    _DEFAULT_CLASSES,
    _DEFAULT_PRICE_BUCKETS,
    _DEFAULT_ROOM_BUCKETS,
    RankedSegment,
    WhatToBuildRanking,
    _build_grid,
    _rank_key,
    _round_or_none,
    _segment_key,
    rank_segments,
)

# Путь патча #980 (импортирован в модуль what_to_build).
_FORECAST = "app.services.forecasting.what_to_build.compute_demand_supply_forecast"


# ── pure: _segment_key ────────────────────────────────────────────────────────


class TestSegmentKey:
    def test_fixed_axis_order(self) -> None:
        seg = {
            "obj_class": "комфорт",
            "room_bucket": "2-к 45-60",
            "district": "X",
            "price_bucket": "бизнес",
        }
        assert _segment_key(seg) == "комфорт|2-к 45-60|X|бизнес"

    def test_none_axes_become_empty(self) -> None:
        seg = {"obj_class": "эконом", "room_bucket": None, "district": None, "price_bucket": None}
        assert _segment_key(seg) == "эконом|||"

    def test_deterministic(self) -> None:
        seg = {"obj_class": "a", "room_bucket": "b", "district": "c", "price_bucket": "d"}
        assert _segment_key(seg) == _segment_key(dict(seg))


# ── pure: _rank_key (сорт-ключ для DESC + tie-break) ──────────────────────────


def _cell(
    *,
    deficit_index: float,
    confidence: str = "medium",
    obj_class: str = "комфорт",
    room_bucket: str = "2-к 45-60",
    balance_units: float | None = None,
) -> RankedSegment:
    return RankedSegment(
        segment={
            "obj_class": obj_class,
            "room_bucket": room_bucket,
            "district": "X",
            "price_bucket": None,
        },
        deficit_index=deficit_index,
        balance_units=balance_units,
        confidence=confidence,  # type: ignore[arg-type]
    )


class TestRankKey:
    def test_higher_deficit_sorts_first(self) -> None:
        # sorted(key=_rank_key) БЕЗ reverse: больший deficit_index → меньший ключ → выше.
        cells = [_cell(deficit_index=0.2), _cell(deficit_index=0.9), _cell(deficit_index=-0.5)]
        ranked = sorted(cells, key=_rank_key)
        assert [c.deficit_index for c in ranked] == [0.9, 0.2, -0.5]

    def test_tie_break_by_confidence(self) -> None:
        # Равный deficit_index → выше confidence идёт первой.
        low = _cell(deficit_index=0.5, confidence="low", obj_class="a")
        high = _cell(deficit_index=0.5, confidence="high", obj_class="b")
        med = _cell(deficit_index=0.5, confidence="medium", obj_class="c")
        ranked = sorted([low, high, med], key=_rank_key)
        assert [c.confidence for c in ranked] == ["high", "medium", "low"]

    def test_tie_break_stable_segment_key(self) -> None:
        # Равный index И confidence → стабильный лексикографический segment-ключ.
        b = _cell(deficit_index=0.5, confidence="medium", obj_class="бизнес")
        a = _cell(deficit_index=0.5, confidence="medium", obj_class="комфорт")
        # 'бизнес' < 'комфорт' лексикографически → b первой.
        ranked = sorted([a, b], key=_rank_key)
        assert ranked[0].segment["obj_class"] == "бизнес"
        assert ranked[1].segment["obj_class"] == "комфорт"

    def test_key_shape(self) -> None:
        key = _rank_key(_cell(deficit_index=0.7, confidence="high"))
        assert key[0] == pytest.approx(-0.7)  # negate для DESC
        assert key[1] == -2  # high rank=2, negate
        assert isinstance(key[2], str)


# ── pure: _build_grid ─────────────────────────────────────────────────────────


class TestBuildGrid:
    def test_cartesian_size(self) -> None:
        grid = _build_grid(
            district="X",
            classes=["эконом", "комфорт"],
            room_buckets=["студ", "1-к", "2-к"],
            price_buckets=[None],
        )
        assert len(grid) == 2 * 3 * 1

    def test_price_axis_multiplies(self) -> None:
        grid = _build_grid(
            district="X",
            classes=["комфорт"],
            room_buckets=["2-к"],
            price_buckets=["эконом", "комфорт", "бизнес", "премиум"],
        )
        assert len(grid) == 4

    def test_district_propagated(self) -> None:
        grid = _build_grid(
            district="Академический",
            classes=["комфорт"],
            room_buckets=["2-к"],
            price_buckets=[None],
        )
        assert all(spec.district == "Академический" for spec in grid)

    def test_axes_assigned(self) -> None:
        grid = _build_grid(
            district="X", classes=["бизнес"], room_buckets=["3-к"], price_buckets=["премиум"]
        )
        spec = grid[0]
        assert spec.obj_class == "бизнес"
        assert spec.room_bucket == "3-к"
        assert spec.price_bucket == "премиум"

    def test_deterministic_order(self) -> None:
        # class-внешний, room-средний, price-внутренний.
        grid = _build_grid(
            district="X", classes=["a", "b"], room_buckets=["r1", "r2"], price_buckets=[None]
        )
        order = [(s.obj_class, s.room_bucket) for s in grid]
        assert order == [("a", "r1"), ("a", "r2"), ("b", "r1"), ("b", "r2")]

    def test_default_grid_cell_count(self) -> None:
        # Дефолтная сетка: 3 класса × 5 room × 1 price = 15 ячеек.
        grid = _build_grid(
            district="X",
            classes=_DEFAULT_CLASSES,
            room_buckets=_DEFAULT_ROOM_BUCKETS,
            price_buckets=_DEFAULT_PRICE_BUCKETS,
        )
        assert len(grid) == 15


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


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

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


# ── dataclass as_dict ─────────────────────────────────────────────────────────


class TestAsDict:
    def test_ranked_segment_as_dict(self) -> None:
        d = _cell(deficit_index=0.789, balance_units=42.49).as_dict()
        assert d["deficit_index"] == 0.789
        assert d["balance_units"] == 42.5
        assert d["confidence"] == "medium"

    def test_ranking_as_dict(self) -> None:
        ranking = WhatToBuildRanking(
            district="X",
            cad_num="66:41:1:1",
            horizon_months=12,
            ranked=[_cell(deficit_index=0.5)],
            n_cells_scanned=15,
            n_cells_ranked=1,
            generated_advisory=True,
        )
        d = ranking.as_dict()
        assert d["horizon_months"] == 12
        assert d["n_cells_scanned"] == 15
        assert d["n_cells_ranked"] == 1
        assert d["generated_advisory"] is True
        assert len(d["ranked"]) == 1


# ── orchestrator helpers (стаб #980) ──────────────────────────────────────────


def _forecast_stub(deficit_index: float | None, *, confidence: str = "medium") -> MagicMock:
    """Одиночный DemandSupplyForecast-стаб с заданным deficit_index/confidence."""
    f = MagicMock()
    f.deficit_index = deficit_index
    f.balance_units = None if deficit_index is None else deficit_index * 100.0
    f.confidence = confidence
    return f


def _segment_indexed_side_effect(
    index_by_room: dict[str, float | None],
    *,
    confidence_by_room: dict[str, str] | None = None,
) -> Any:
    """side_effect для #980: deficit_index/confidence зависят от room_bucket spec.

    Позволяет задать разный сигнал по ячейкам сетки (по room_bucket), чтобы
    проверить сортировку/отбрасывание. Возвращает [forecast] (список на 1 горизонт);
    forecast.segment = spec.as_dict() (как делает настоящий #980).
    """
    conf_map = confidence_by_room or {}

    def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
        room = spec.room_bucket
        deficit = index_by_room.get(room)
        confidence = conf_map.get(room, "medium")
        f = _forecast_stub(deficit, confidence=confidence)
        f.segment = spec.as_dict()
        return [f]

    return _side


def _run(**over: object) -> WhatToBuildRanking:
    kwargs: dict[str, object] = {
        "district": "Академический",
        "cad_num": "66:41:0303161:123",
        "horizon_months": 12,
    }
    kwargs.update(over)
    return rank_segments(MagicMock(), **kwargs)  # type: ignore[arg-type]


# ── orchestrator: сортировка DESC по deficit_index ────────────────────────────


class TestRankingOrder:
    def test_sorted_descending_by_deficit_index(self) -> None:
        # Сетка из 3 room (1 класс, 1 price) с разным deficit_index → DESC-порядок.
        side = _segment_indexed_side_effect(
            {"студия": 0.1, "1-к": 0.9, "2-к": -0.4},
        )
        with patch(_FORECAST, side_effect=side):
            res = _run(
                classes=["комфорт"],
                room_buckets=["студия", "1-к", "2-к"],
                price_buckets=[None],
            )
        indices = [r.deficit_index for r in res.ranked]
        assert indices == [0.9, 0.1, -0.4]
        # Топ-ячейка = сильнейший build-сигнал (room_bucket '1-к').
        assert res.ranked[0].segment["room_bucket"] == "1-к"

    def test_balance_units_carried(self) -> None:
        side = _segment_indexed_side_effect({"2-к": 0.5})
        with patch(_FORECAST, side_effect=side):
            res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
        assert res.ranked[0].balance_units == pytest.approx(50.0)


# ── orchestrator: отбрасывание None-ячеек (тонкие данные) ─────────────────────


class TestNoneDrop:
    def test_none_deficit_cells_dropped(self) -> None:
        # 2-к имеет deficit_index None (тонкие данные) → НЕ в ранкинге.
        side = _segment_indexed_side_effect(
            {"студия": 0.3, "1-к": None, "2-к": 0.7},
        )
        with patch(_FORECAST, side_effect=side):
            res = _run(
                classes=["комфорт"],
                room_buckets=["студия", "1-к", "2-к"],
                price_buckets=[None],
            )
        rooms = [r.segment["room_bucket"] for r in res.ranked]
        assert "1-к" not in rooms  # None-ячейка отброшена
        assert rooms == ["2-к", "студия"]  # DESC по 0.7, 0.3
        assert res.n_cells_scanned == 3
        assert res.n_cells_ranked == 2

    def test_all_none_yields_empty_ranking(self) -> None:
        # Вся сетка тонкая → пустой ранкинг (graceful, не crash).
        side = _segment_indexed_side_effect(
            {"студия": None, "1-к": None},
        )
        with patch(_FORECAST, side_effect=side):
            res = _run(classes=["комфорт"], room_buckets=["студия", "1-к"], price_buckets=[None])
        assert res.ranked == []
        assert res.n_cells_ranked == 0
        assert res.n_cells_scanned == 2
        assert res.generated_advisory is True


# ── orchestrator: tie-break ───────────────────────────────────────────────────


class TestTieBreak:
    def test_equal_deficit_higher_confidence_first(self) -> None:
        # Равный deficit_index по двум room → выше confidence первой.
        side = _segment_indexed_side_effect(
            {"студия": 0.5, "1-к": 0.5},
            confidence_by_room={"студия": "low", "1-к": "medium"},
        )
        with patch(_FORECAST, side_effect=side):
            res = _run(classes=["комфорт"], room_buckets=["студия", "1-к"], price_buckets=[None])
        assert res.ranked[0].segment["room_bucket"] == "1-к"  # medium > low
        assert res.ranked[1].segment["room_bucket"] == "студия"

    def test_equal_deficit_and_confidence_stable_segment_key(self) -> None:
        # Равные index+confidence по двум классам → стабильный segment-ключ (ASC).
        side = _segment_indexed_side_effect({"2-к": 0.5})  # одинаковый index по обоим классам
        with patch(_FORECAST, side_effect=side):
            res = _run(classes=["комфорт", "бизнес"], room_buckets=["2-к"], price_buckets=[None])
        # 'бизнес' < 'комфорт' лексикографически → первым.
        assert res.ranked[0].segment["obj_class"] == "бизнес"
        assert res.ranked[1].segment["obj_class"] == "комфорт"


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


class TestAdvisoryFlag:
    def test_advisory_always_true(self) -> None:
        side = _segment_indexed_side_effect({"2-к": 0.5})
        with patch(_FORECAST, side_effect=side):
            res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
        assert res.generated_advisory is True

    def test_advisory_true_even_when_empty(self) -> None:
        side = _segment_indexed_side_effect({"2-к": None})
        with patch(_FORECAST, side_effect=side):
            res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
        assert res.generated_advisory is True

    def test_cells_inherit_capped_confidence(self) -> None:
        # confidence ячейки = то, что вернул #980 (он сам ≤ medium); ранкер не поднимает.
        side = _segment_indexed_side_effect({"2-к": 0.5}, confidence_by_room={"2-к": "medium"})
        with patch(_FORECAST, side_effect=side):
            res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
        assert res.ranked[0].confidence == "medium"


# ── orchestrator: graceful (сбой #980 на ячейке / пустая сетка) ───────────────


class TestGraceful:
    def test_forecast_exception_on_cell_skips_it(self) -> None:
        # #980 кидает на 1-к → ячейка выпадает, остальные ранжируются (не crash).
        def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
            if spec.room_bucket == "1-к":
                raise ValueError("boom")
            f = _forecast_stub(0.5)
            f.segment = spec.as_dict()
            return [f]

        with patch(_FORECAST, side_effect=_side):
            res = _run(
                classes=["комфорт"],
                room_buckets=["студия", "1-к", "2-к"],
                price_buckets=[None],
            )
        rooms = [r.segment["room_bucket"] for r in res.ranked]
        assert "1-к" not in rooms
        assert set(rooms) == {"студия", "2-к"}
        assert res.n_cells_scanned == 3  # сетка прогнана целиком
        assert res.n_cells_ranked == 2

    def test_empty_forecast_list_skips_cell(self) -> None:
        # #980 вернул [] для ячейки → ячейка выпадает.
        def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
            if spec.room_bucket == "студия":
                return []
            f = _forecast_stub(0.5)
            f.segment = spec.as_dict()
            return [f]

        with patch(_FORECAST, side_effect=_side):
            res = _run(classes=["комфорт"], room_buckets=["студия", "2-к"], price_buckets=[None])
        rooms = [r.segment["room_bucket"] for r in res.ranked]
        assert rooms == ["2-к"]

    def test_empty_grid_yields_empty_ranking(self) -> None:
        # Пустые оси → пустая сетка → пустой ранкинг (не вызываем #980 вовсе).
        with patch(_FORECAST) as fc:
            res = _run(classes=[], room_buckets=[], price_buckets=[None])
        fc.assert_not_called()
        assert res.ranked == []
        assert res.n_cells_scanned == 0
        assert res.n_cells_ranked == 0

    def test_returns_ranking_always(self) -> None:
        side = _segment_indexed_side_effect({"2-к": 0.5})
        with patch(_FORECAST, side_effect=side):
            res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
        assert isinstance(res, WhatToBuildRanking)
        assert all(isinstance(r, RankedSegment) for r in res.ranked)


# ── orchestrator: горизонт пробрасывается в #980 ──────────────────────────────


class TestHorizonAndRatePath:
    def test_horizon_passed_to_forecast(self) -> None:
        captured: dict[str, Any] = {}

        def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
            captured["horizons"] = horizons
            f = _forecast_stub(0.5)
            f.segment = spec.as_dict()
            return [f]

        with patch(_FORECAST, side_effect=_side):
            _run(horizon_months=24, classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
        assert captured["horizons"] == [24]

    def test_rate_path_passed_to_forecast(self) -> None:
        captured: dict[str, Any] = {}

        def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
            captured["rate_path"] = rate_path
            f = _forecast_stub(0.5)
            f.segment = spec.as_dict()
            return [f]

        with patch(_FORECAST, side_effect=_side):
            _run(
                rate_path={12: 18.0},
                classes=["комфорт"],
                room_buckets=["2-к"],
                price_buckets=[None],
            )
        assert captured["rate_path"] == {12: 18.0}

    def test_default_grid_scans_fifteen_cells(self) -> None:
        # Дефолтная сетка (без override осей) = 15 ячеек прогона #980.
        side = _segment_indexed_side_effect(
            {r: 0.5 for r in _DEFAULT_ROOM_BUCKETS},
        )
        with patch(_FORECAST, side_effect=side):
            res = _run()
        assert res.n_cells_scanned == 15
