"""Unit-тесты §7.9 MAI — прокси платёжной нагрузки по РЫНОЧНОЙ ставке (#981, ADVISORY).

Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
  • pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n−1)); rate=0 →
    principal/months (без /0); None principal/rate → None; months ≤0 → None.
  • compute_affordability через MagicMock-сессию + @patch _current_market_rate /
    build_sales_series: #981 DoD — НЕ субсидированные 7.83%, ставка рыночная
    (key_rate ~16-21%), rate_kind=='key_rate_proxy'; нет key_rate → rate_used None +
    'market_unavailable' (НЕ подмена субсидией); payment_to_income ВСЕГДА None;
    confidence=='low'; degraded_reason непуст; payment_at_scenario per-горизонт;
    цена из аргумента vs сегментная (reuse); graceful нет ставки/цены → платёж None.
  • _current_market_rate: берёт самую свежую key_rate из get_monthly_macro + спред.

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

from __future__ import annotations

import os

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

import datetime as dt
from unittest.mock import MagicMock, patch

import pytest

from app.services.forecasting.affordability import (
    _ANNUITY_TERM_MONTHS,
    _DEGRADED_REASON,
    _KEY_RATE_MARKET_SPREAD_PP,
    _RATE_KIND_KEY_RATE_PROXY,
    _RATE_KIND_SUBSIDIZED,
    _RATE_KIND_UNAVAILABLE,
    _REF_AREA_M2,
    MortgageAffordabilityIndex,
    _annuity,
    _current_market_rate,
    _round_or_none,
    _segment_avg_price,
    compute_affordability,
)
from app.services.forecasting.macro_series import MonthlyMacro
from app.services.forecasting.sales_series import SegmentSpec

# Пути патча reused-сервисов (импортированы в модуль affordability).
# #981: индекс берёт РЫНОЧНУЮ ставку через _current_market_rate (key_rate ЦБ),
# поэтому патчим её (не субсидированный _current_mortgage_rate).
_RATE = "app.services.forecasting.affordability._current_market_rate"
_MACRO = "app.services.forecasting.affordability.get_monthly_macro"
_SERIES = "app.services.forecasting.affordability.build_sales_series"

# Мокаемая ключевая ставка ЦБ для orchestrator-тестов. _current_market_rate отдаёт
# РЫНОЧНУЮ ставку = key_rate + _KEY_RATE_MARKET_SPREAD_PP, поэтому ожидаемый rate_used
# выражаем символически (_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP) — тест остаётся
# верным при будущей перекалибровке спреда И ломается при регрессии спреда к 0.
_MOCK_KEY_RATE: float = 18.0
_MOCK_MARKET_RATE: float = _MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP


# ── pure: _annuity ────────────────────────────────────────────────────────────


class TestAnnuity:
    def test_textbook_case_matches_formula(self) -> None:
        # Тело 2 500 000 ₽, 12% годовых, 240 мес. Сверяем с явной формулой
        # P·i·(1+i)^n / ((1+i)^n − 1), i = rate/100/12.
        principal = 2_500_000.0
        rate = 12.0
        months = 240
        i = rate / 100.0 / 12.0
        growth = (1.0 + i) ** months
        expected = principal * i * growth / (growth - 1.0)
        assert _annuity(principal, rate, months) == pytest.approx(expected)

    def test_textbook_case_known_value(self) -> None:
        # Та же вводная — захардкоженное hand-verified значение (~27 527.15 ₽/мес).
        assert _annuity(2_500_000.0, 12.0, 240) == pytest.approx(27_527.15, abs=0.5)

    def test_zero_rate_is_principal_over_months(self) -> None:
        # Беспроцентный кредит: формула вырождается (i=0 → 0/0) → равномерное тело.
        assert _annuity(2_400_000.0, 0.0, 240) == pytest.approx(10_000.0)

    def test_negative_rate_degrades_to_principal_over_months(self) -> None:
        # Артефактно-отрицательная ставка → та же беспроцентная деградация (не /0).
        assert _annuity(1_200_000.0, -3.0, 120) == pytest.approx(10_000.0)

    def test_none_principal_returns_none(self) -> None:
        assert _annuity(None, 12.0, 240) is None

    def test_none_rate_returns_none(self) -> None:
        assert _annuity(2_500_000.0, None, 240) is None

    def test_zero_or_negative_months_returns_none(self) -> None:
        assert _annuity(2_500_000.0, 12.0, 0) is None
        assert _annuity(2_500_000.0, 12.0, -12) is None

    def test_higher_rate_means_higher_payment(self) -> None:
        # Монотонность: выше ставка → выше платёж (при равном теле/сроке).
        low = _annuity(2_500_000.0, 7.0, 240)
        high = _annuity(2_500_000.0, 20.0, 240)
        assert low is not None and high is not None
        assert high > low


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


class TestRoundOrNone:
    def test_rounds(self) -> None:
        assert _round_or_none(27_522.16, 0) == 27_522.0

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


# ── MortgageAffordabilityIndex.as_dict ────────────────────────────────────────


def _make_index(**over: object) -> MortgageAffordabilityIndex:
    base: dict[str, object] = {
        "segment": {
            "obj_class": "комфорт",
            "room_bucket": "2-к 45-60",
            "district": "X",
            "price_bucket": None,
        },
        "monthly_payment_rub": 27_522.16,
        "rate_used": 18.0,
        "rate_kind": _RATE_KIND_KEY_RATE_PROXY,
        "rate_period": "2026-01",
        "ref_area_m2": _REF_AREA_M2,
        "price_per_m2": 120_000.0,
        "annuity_term_months": _ANNUITY_TERM_MONTHS,
        "payment_to_income": None,
        "payment_at_scenario": {6: 27_000.0, 12: 28_000.0},
        "degraded_reason": _DEGRADED_REASON,
        "confidence": "low",
    }
    base.update(over)
    return MortgageAffordabilityIndex(**base)  # type: ignore[arg-type]


class TestAsDict:
    def test_rounds_money_fields(self) -> None:
        d = _make_index().as_dict()
        assert d["monthly_payment_rub"] == 27_522.0
        assert d["price_per_m2"] == 120_000.0
        assert d["payment_at_scenario"] == {6: 27_000.0, 12: 28_000.0}

    def test_honesty_fields_preserved(self) -> None:
        d = _make_index().as_dict()
        assert d["payment_to_income"] is None
        # #981: рыночный прокси (key_rate), НЕ субсидированная средневзвешенная.
        assert d["rate_kind"] == "key_rate_proxy"
        assert d["confidence"] == "low"
        assert d["degraded_reason"]

    def test_none_fields_survive(self) -> None:
        d = _make_index(
            monthly_payment_rub=None, price_per_m2=None, payment_at_scenario=None
        ).as_dict()
        assert d["monthly_payment_rub"] is None
        assert d["price_per_m2"] is None
        assert d["payment_at_scenario"] is None


# ── orchestrator helpers (стабы reused-сервисов) ──────────────────────────────


def _series_stub(prices: list[float | None]) -> MagicMock:
    """Стаб build_sales_series: несёт avg_price_per_m2-список (для _segment_avg_price)."""
    m = MagicMock()
    m.avg_price_per_m2 = prices
    return m


def _run(**over: object) -> MortgageAffordabilityIndex:
    spec = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический")
    return compute_affordability(MagicMock(), spec=spec, **over)  # type: ignore[arg-type]


# ── orchestrator: honesty-контракт (КРИТИЧНО) ─────────────────────────────────


class TestHonestyContract:
    def test_payment_to_income_always_none(self) -> None:
        # Дохода в данных нет (#946) → payment_to_income ВСЕГДА None.
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            res = _run(price_per_m2=120_000.0)
        assert res.payment_to_income is None

    def test_rate_kind_is_key_rate_proxy(self) -> None:
        # #981 DoD: рыночный прокси (key_rate ЦБ), НЕ субсидированная средневзвешенная.
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            res = _run(price_per_m2=120_000.0)
        assert res.rate_kind == "key_rate_proxy"
        assert res.rate_kind != _RATE_KIND_SUBSIDIZED

    def test_rate_used_is_market_not_subsidized(self) -> None:
        # #981 DoD-якорь: ставка рыночная (key_rate + калиброванный спред 4.5 п.п.),
        # НЕ subsidized_weighted 7.83%. rate_used = key_rate + _KEY_RATE_MARKET_SPREAD_PP
        # (символически — устойчиво к перекалибровке спреда).
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            res = _run(price_per_m2=120_000.0)
        assert res.rate_used == pytest.approx(_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP)
        assert res.rate_used is not None and res.rate_used > 15.0
        # точно НЕ субсидированный бленд ~7.83%.
        assert res.rate_used != pytest.approx(7.83, abs=0.5)

    def test_confidence_always_low(self) -> None:
        # Деградированный прокси → confidence ВСЕГДА low (даже при полных входах).
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            res = _run(price_per_m2=120_000.0)
        assert res.confidence == "low"

    def test_degraded_reason_explains_proxy_and_income_gap(self) -> None:
        # degraded_reason объясняет выбор прокси ставки (key_rate) И пробел дохода.
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            res = _run(price_per_m2=120_000.0)
        assert res.degraded_reason
        lower = res.degraded_reason.lower()
        assert "ставк" in lower  # упоминает ставку
        assert "key_rate" in lower  # называет рыночный прокси явно
        assert "доход" in lower  # упоминает доход


# ── orchestrator: расчёт платежа ──────────────────────────────────────────────


class TestPaymentComputation:
    def test_monthly_payment_uses_annuity(self) -> None:
        # price 120k × ref 50 м² = 6 000 000 ₽ тело; рыночная ставка = key_rate +
        # калиброванный спред 4.5 п.п., 240 мес. Платёж считается на рыночной ставке.
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            res = _run(price_per_m2=120_000.0)
        principal = 120_000.0 * _REF_AREA_M2
        expected = _annuity(principal, _MOCK_MARKET_RATE, _ANNUITY_TERM_MONTHS)
        assert res.monthly_payment_rub == pytest.approx(expected)
        assert res.rate_used == pytest.approx(_MOCK_KEY_RATE + _KEY_RATE_MARKET_SPREAD_PP)
        assert res.rate_period == "2026-01"
        assert res.price_per_m2 == 120_000.0
        assert res.ref_area_m2 == _REF_AREA_M2

    def test_explicit_price_overrides_segment(self) -> None:
        # Явная цена в аргументе → build_sales_series НЕ вызывается.
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            with patch(_SERIES) as series_mock:
                res = _run(price_per_m2=150_000.0)
        series_mock.assert_not_called()
        assert res.price_per_m2 == 150_000.0

    def test_segment_price_used_when_arg_none(self) -> None:
        # Без price_per_m2 → сегментная средняя из build_sales_series (reuse).
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            with patch(_SERIES, return_value=_series_stub([100_000.0, None, 140_000.0])):
                res = _run()
        # средняя НЕнулевых: (100k + 140k) / 2 = 120k.
        assert res.price_per_m2 == pytest.approx(120_000.0)


# ── orchestrator: payment_at_scenario per-горизонт ────────────────────────────


class TestPaymentAtScenario:
    def test_scenario_payment_per_horizon(self) -> None:
        # rate_path: разные ставки на горизонтах → платёж считается на каждой.
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
        assert res.payment_at_scenario is not None
        principal = 120_000.0 * _REF_AREA_M2
        # rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной
        # базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания
        # выражаем символически — тест переживёт перекалибровку спреда.
        assert res.payment_at_scenario[6] == pytest.approx(
            _annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
        )
        assert res.payment_at_scenario[12] == pytest.approx(
            _annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
        )
        # Выше ставка → выше платёж на этом горизонте.
        assert res.payment_at_scenario[12] > res.payment_at_scenario[6]

    def test_no_rate_path_yields_none_scenario(self) -> None:
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            res = _run(price_per_m2=120_000.0)
        assert res.payment_at_scenario is None

    def test_scenario_drops_uncomputable_horizon(self) -> None:
        # Нет цены → principal None → платёж на горизонте None → ключ выпадает.
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            with patch(_SERIES, return_value=_series_stub([None])):
                res = _run(rate_path={6: 8.0})
        assert res.payment_at_scenario == {}


# ── orchestrator: graceful (нет ставки / нет цены) ────────────────────────────


class TestGraceful:
    def test_no_rate_yields_none_payment_and_market_unavailable(self) -> None:
        # Рыночной ставки (key_rate) нет в БД → платёж None, rate_used None,
        # rate_kind='market_unavailable' (#981: НЕ подменяем субсидией). Не crash.
        with patch(_RATE, return_value=(None, None)):
            res = _run(price_per_m2=120_000.0)
        assert res.monthly_payment_rub is None
        assert res.rate_used is None
        assert res.rate_kind == _RATE_KIND_UNAVAILABLE
        assert res.rate_kind != _RATE_KIND_SUBSIDIZED
        assert "market_unavailable" in res.degraded_reason.lower()
        assert res.confidence == "low"

    def test_no_price_yields_none_payment(self) -> None:
        # Тонкий сегмент (нет цены) → principal None → платёж None.
        with patch(_RATE, return_value=(_MOCK_MARKET_RATE, "2026-01")):
            with patch(_SERIES, return_value=_series_stub([None, None])):
                res = _run()
        assert res.price_per_m2 is None
        assert res.monthly_payment_rub is None

    def test_returns_index_always(self) -> None:
        with patch(_RATE, return_value=(None, None)):
            with patch(_SERIES, return_value=_series_stub([])):
                res = _run()
        assert isinstance(res, MortgageAffordabilityIndex)
        assert res.payment_to_income is None
        assert res.confidence == "low"


# ── pure-ish: _segment_avg_price ──────────────────────────────────────────────


class TestSegmentAvgPrice:
    def test_averages_nonnull_months(self) -> None:
        spec = SegmentSpec(obj_class="комфорт")
        with patch(_SERIES, return_value=_series_stub([110_000.0, None, 130_000.0])):
            price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
        assert price == pytest.approx(120_000.0)

    def test_all_none_yields_none(self) -> None:
        spec = SegmentSpec(obj_class="комфорт")
        with patch(_SERIES, return_value=_series_stub([None, None])):
            price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
        assert price is None

    def test_empty_series_yields_none(self) -> None:
        spec = SegmentSpec(obj_class="комфорт")
        with patch(_SERIES, return_value=_series_stub([])):
            price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
        assert price is None


# ── _current_market_rate (#981: key_rate ЦБ как рыночный прокси) ───────────────


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


class TestCurrentMarketRate:
    def test_takes_latest_key_rate_with_spread(self) -> None:
        # Самая свежая непустая key_rate (ряд ASC) + калиброванный рыночный спред.
        macro = [
            _macro_pt(dt.date(2025, 11, 1), 19.0),
            _macro_pt(dt.date(2025, 12, 1), 20.0),
            _macro_pt(dt.date(2026, 1, 1), 21.0),
        ]
        with patch(_MACRO, return_value=macro):
            rate, period = _current_market_rate(MagicMock())
        assert rate == pytest.approx(21.0 + _KEY_RATE_MARKET_SPREAD_PP)
        assert period == "2026-01"
        # рыночный диапазон, НЕ субсидированный ~7.83%.
        assert rate is not None and rate > 15.0

    def test_default_spread_is_calibrated_market_spread(self) -> None:
        # Регрессионный якорь: дефолтный спред = 4.5 п.п. (калибровка по прод
        # mortgage_rate_primary_domrf 19.125% vs key_rate ≈ 14.5%). Мокаем key_rate K
        # → _current_market_rate без явного spread_pp обязан вернуть K + 4.5. Откат
        # спреда к 0 (старый stopgap, занижавший заёмную стоимость) здесь упадёт.
        assert _KEY_RATE_MARKET_SPREAD_PP == 4.5
        key_rate = 14.5  # прод-якорь ключевой ставки ЦБ
        with patch(_MACRO, return_value=[_macro_pt(dt.date(2026, 6, 1), key_rate)]):
            rate, _period = _current_market_rate(MagicMock())
        assert rate == pytest.approx(key_rate + _KEY_RATE_MARKET_SPREAD_PP)
        assert rate == pytest.approx(19.0)  # ≈ наблюдаемая рыночная ипотека ~19%

    def test_spread_applied(self) -> None:
        macro = [_macro_pt(dt.date(2026, 1, 1), 18.0)]
        with patch(_MACRO, return_value=macro):
            rate, _period = _current_market_rate(MagicMock(), spread_pp=2.5)
        assert rate == pytest.approx(20.5)

    def test_ignores_trailing_none_key_rate(self) -> None:
        # Хвостовой None key_rate игнорируется → берётся предыдущая 21.0 + спред.
        macro = [
            _macro_pt(dt.date(2025, 12, 1), 21.0),
            _macro_pt(dt.date(2026, 1, 1), None),
        ]
        with patch(_MACRO, return_value=macro):
            rate, period = _current_market_rate(MagicMock())
        assert rate == pytest.approx(21.0 + _KEY_RATE_MARKET_SPREAD_PP)
        assert period == "2025-12"

    def test_no_key_rate_yields_none(self) -> None:
        # Пустой macro_indicator / нет ни одной key_rate → (None, None) (data-gap).
        with patch(_MACRO, return_value=[_macro_pt(dt.date(2026, 1, 1), None)]):
            assert _current_market_rate(MagicMock()) == (None, None)
        with patch(_MACRO, return_value=[]):
            assert _current_market_rate(MagicMock()) == (None, None)
