"""Unit-тесты §14.2 продуктового скоринга (#985, 954-B, ADVISORY).

Чистые тесты — БЕЗ живой БД (мокаем все backing-сервисы):
  • Каждый pure-скорер (_score_*): value ∈ [0,1]; правильное направление инверсии для
    supply_risk / future_competition / mortgage_sensitivity (high-bad → low-score);
    None-вход → None + 'unavailable'-причина (НИКОГДА 0-как-заглушка).
  • Pure-агрегаты: _weighted_overall — renorm над доступными (пропуск скора НЕ тянет
    overall к 0; все скоры None → overall None); _min_confidence.
  • compute_score_card через @patch ВСЕХ backing-сервисов: 10 скоров присутствуют,
    overall взвешен, advisory ВСЕГДА True, graceful (сбой одного сервиса → его скор
    unavailable, карта всё равно возвращается).

Детерминированно, без LLM. Мокаем backing-сервисы + 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.product_scoring import (
    _COMP_TOP_N,
    _DEFAULT_HORIZON_MONTHS,
    _DEMAND_HALF_VELOCITY,
    _DIFF_TARGET_USP,
    _INFRA_HALF_WEIGHT,
    _MORTG_MAX_X_PCT,
    _PRICE_HALF_PAYMENT_RUB,
    _SCORE_KEYS,
    _SCORE_WEIGHTS,
    ProductScore,
    ProductScoreCard,
    _clamp01,
    _count_positive_usp,
    _min_confidence,
    _overlay_confidence,
    _poi_weight_sum,
    _quarter_from_cad,
    _saturating,
    _score_commercial,
    _score_confidence,
    _score_demand,
    _score_differentiation,
    _score_future_competition,
    _score_infra_fit,
    _score_market_fit,
    _score_mortgage_sensitivity,
    _score_price_feasibility,
    _score_supply_risk,
    _weighted_overall,
    compute_score_card,
)
from app.services.forecasting.regression import DistributedLagFit
from app.services.forecasting.sales_series import SegmentSpec

# Пути патча (backing-сервисы импортированы в namespace product_scoring).
_MOD = "app.services.forecasting.product_scoring"
_P_FORECAST = f"{_MOD}.compute_demand_supply_forecast"
_P_METRICS = f"{_MOD}.compute_market_metrics"
_P_FSP = f"{_MOD}.compute_future_supply_pressure"
_P_COMPETITORS = f"{_MOD}.get_competitors"
_P_AFFORD = f"{_MOD}.compute_affordability"
_P_POI = f"{_MOD}.compute_poi_weighted_top7"
# §9.6 теперь идёт через адаптер compute_rate_regime_sensitivity (в namespace
# product_scoring), который зовёт compute_district_rate_regression (Almon-ADL #978)
# в namespace regression. Патчим РЕАЛЬНЫЙ Almon-вход настоящим DistributedLagFit и
# прогоняем адаптер целиком → mortgage_sensitivity тестит НОВЫЙ путь (а не мокает
# уже-неиспользуемый single-lag compute_rate_sensitivity — это был бы false-green).
_P_REG = "app.services.forecasting.regression.compute_district_rate_regression"
_P_OVERLAY = f"{_MOD}.build_forecast_overlay"


# ──────────────────────────────────────────────────────────────────────────────
# Pure-хелперы: _clamp01 / _saturating
# ──────────────────────────────────────────────────────────────────────────────


class TestClamp01:
    def test_clamps_below(self) -> None:
        assert _clamp01(-0.3) == 0.0

    def test_clamps_above(self) -> None:
        assert _clamp01(1.7) == 1.0

    def test_passthrough_in_range(self) -> None:
        assert _clamp01(0.42) == 0.42


class TestSaturating:
    def test_half_at_half_constant(self) -> None:
        assert _saturating(8.0, 8.0) == pytest.approx(0.5)

    def test_monotone_increasing(self) -> None:
        assert _saturating(4.0, 8.0) < _saturating(16.0, 8.0)

    def test_in_unit_range(self) -> None:
        for v in (0.0, 1.0, 100.0, 5000.0):
            out = _saturating(v, 8.0)
            assert out is not None
            assert 0.0 <= out < 1.0

    def test_none_input_none(self) -> None:
        assert _saturating(None, 8.0) is None

    def test_nonpositive_half_none(self) -> None:
        assert _saturating(5.0, 0.0) is None

    def test_negative_value_clamped_to_zero(self) -> None:
        assert _saturating(-5.0, 8.0) == 0.0


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 1: market_fit (deficit_index → [0,1] прямой remap)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreMarketFit:
    def test_strong_deficit_high(self) -> None:
        value, _conf, _r = _score_market_fit(1.0, "medium")
        assert value == pytest.approx(1.0)

    def test_strong_overstock_low(self) -> None:
        value, _conf, _r = _score_market_fit(-1.0, "medium")
        assert value == pytest.approx(0.0)

    def test_balance_midpoint(self) -> None:
        value, _conf, _r = _score_market_fit(0.0, "high")
        assert value == pytest.approx(0.5)

    def test_higher_deficit_higher_score(self) -> None:
        low, _c1, _r1 = _score_market_fit(-0.4, "medium")
        high, _c2, _r2 = _score_market_fit(0.6, "medium")
        assert low is not None and high is not None
        assert high > low

    def test_in_range(self) -> None:
        for di in (-1.0, -0.5, 0.0, 0.3, 1.0):
            value, _c, _r = _score_market_fit(di, "medium")
            assert value is not None
            assert 0.0 <= value <= 1.0

    def test_confidence_passthrough(self) -> None:
        _v, conf, _r = _score_market_fit(0.5, "high")
        assert conf == "high"

    def test_none_input_unavailable(self) -> None:
        value, conf, reason = _score_market_fit(None, "medium")
        assert value is None
        assert conf == "low"
        assert "неизмерим" in reason


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 2: demand (unit_velocity → saturating)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreDemand:
    def test_half_at_half_velocity(self) -> None:
        value, _c, _r = _score_demand(_DEMAND_HALF_VELOCITY, "medium")
        assert value == pytest.approx(0.5)

    def test_higher_velocity_higher_score(self) -> None:
        low, _c1, _r1 = _score_demand(2.0, "medium")
        high, _c2, _r2 = _score_demand(40.0, "medium")
        assert low is not None and high is not None
        assert high > low

    def test_in_range(self) -> None:
        for v in (0.0, 1.0, 8.0, 50.0):
            value, _c, _r = _score_demand(v, "medium")
            assert value is not None
            assert 0.0 <= value < 1.0

    def test_none_input_unavailable(self) -> None:
        value, conf, reason = _score_demand(None, "medium")
        assert value is None
        assert conf == "low"
        assert reason  # непустая причина


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 3: supply_risk (ИНВЕРТИРОВАН — high pressure/overstock → low score)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreSupplyRisk:
    def test_inverted_high_pressure_low_score(self) -> None:
        low_risk, _c1, _r1 = _score_supply_risk(0.1, 0.1, "medium")
        high_risk, _c2, _r2 = _score_supply_risk(0.9, 0.9, "medium")
        assert low_risk is not None and high_risk is not None
        # Инверсия: больше давление/затоварка → НИЖЕ скор.
        assert high_risk < low_risk

    def test_full_pressure_zero_score(self) -> None:
        value, _c, _r = _score_supply_risk(1.0, 1.0, "medium")
        assert value == pytest.approx(0.0)

    def test_no_pressure_full_score(self) -> None:
        value, _c, _r = _score_supply_risk(0.0, 0.0, "medium")
        assert value == pytest.approx(1.0)

    def test_mean_of_available(self) -> None:
        # risk = mean(0.2, 0.8) = 0.5 → score = 0.5.
        value, _c, _r = _score_supply_risk(0.2, 0.8, "medium")
        assert value == pytest.approx(0.5)

    def test_one_channel_only(self) -> None:
        # Только future_pressure доступен (overstock None) → risk=0.4 → score=0.6.
        value, _c, _r = _score_supply_risk(0.4, None, "medium")
        assert value == pytest.approx(0.6)

    def test_in_range(self) -> None:
        for p, o in ((0.0, 0.0), (0.5, 0.3), (1.0, 1.0), (0.7, None), (None, 0.2)):
            value, _c, _r = _score_supply_risk(p, o, "medium")
            assert value is not None
            assert 0.0 <= value <= 1.0

    def test_both_none_unavailable(self) -> None:
        value, conf, reason = _score_supply_risk(None, None, "medium")
        assert value is None
        assert conf == "low"
        assert reason


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 4: future_competition (ИНВЕРТИРОВАН — more/stronger competitors → low score)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreFutureCompetition:
    def test_inverted_more_competition_lower_score(self) -> None:
        weak, _c1, _r1 = _score_future_competition([0.2, 0.1], 2, "medium")
        strong, _c2, _r2 = _score_future_competition([0.9, 0.9, 0.9, 0.9, 0.9], 20, "medium")
        assert weak is not None and strong is not None
        # Инверсия: больше/сильнее конкурентов → НИЖЕ скор.
        assert strong < weak

    def test_no_competitors_full_score(self) -> None:
        value, _c, _r = _score_future_competition([], 0, "medium")
        assert value == pytest.approx(1.0)

    def test_in_range(self) -> None:
        cases = [([0.5], 1), ([0.9] * 5, 30), ([0.1, 0.2, 0.3], 3)]
        for weights, n in cases:
            value, _c, _r = _score_future_competition(weights, n, "medium")
            assert value is not None
            assert 0.0 <= value <= 1.0

    def test_only_top_n_averaged(self) -> None:
        # 7 весов, но усредняются только top _COMP_TOP_N — низкие хвостовые игнор.
        weights = [0.9] * _COMP_TOP_N + [0.0, 0.0]
        value, _c, _r = _score_future_competition(weights, len(weights), "medium")
        # mean top-N = 0.9; count_scale = 7/12 → density≈0.525 → score≈0.475.
        assert value is not None
        assert value < 0.5

    def test_none_weights_unavailable(self) -> None:
        value, conf, reason = _score_future_competition(None, None, "medium")
        assert value is None
        assert conf == "low"
        assert reason


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 5: price_feasibility (меньше платёж → выше скор; saturating-инверс)
# ──────────────────────────────────────────────────────────────────────────────


class TestScorePriceFeasibility:
    def test_half_at_half_payment(self) -> None:
        value, _c, _r = _score_price_feasibility(_PRICE_HALF_PAYMENT_RUB, "low")
        assert value == pytest.approx(0.5)

    def test_lower_payment_higher_score(self) -> None:
        cheap, _c1, _r1 = _score_price_feasibility(20_000.0, "low")
        pricey, _c2, _r2 = _score_price_feasibility(200_000.0, "low")
        assert cheap is not None and pricey is not None
        assert cheap > pricey

    def test_in_range(self) -> None:
        for p in (0.0, 30_000.0, 60_000.0, 500_000.0):
            value, _c, _r = _score_price_feasibility(p, "low")
            assert value is not None
            assert 0.0 < value <= 1.0

    def test_none_input_unavailable(self) -> None:
        value, conf, reason = _score_price_feasibility(None, "low")
        assert value is None
        assert conf == "low"
        assert reason


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 6: infra_fit (Σ POI weight → saturating, прямой)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreInfraFit:
    def test_half_at_half_weight(self) -> None:
        value, _c, _r = _score_infra_fit(_INFRA_HALF_WEIGHT, "medium")
        assert value == pytest.approx(0.5)

    def test_higher_weight_higher_score(self) -> None:
        low, _c1, _r1 = _score_infra_fit(0.01, "medium")
        high, _c2, _r2 = _score_infra_fit(0.5, "medium")
        assert low is not None and high is not None
        assert high > low

    def test_in_range(self) -> None:
        for w in (0.0, 0.02, 0.06, 1.0):
            value, _c, _r = _score_infra_fit(w, "medium")
            assert value is not None
            assert 0.0 <= value < 1.0

    def test_none_input_unavailable(self) -> None:
        value, conf, reason = _score_infra_fit(None, "medium")
        assert value is None
        assert conf == "low"
        assert reason


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 7: mortgage_sensitivity (ИНВЕРТИРОВАН по магнитуде x_pct)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreMortgageSensitivity:
    def test_inverted_more_negative_lower_score(self) -> None:
        mild, _c1, _r1 = _score_mortgage_sensitivity(-2.0, "medium")
        severe, _c2, _r2 = _score_mortgage_sensitivity(-20.0, "medium")
        assert mild is not None and severe is not None
        # Инверсия по магнитуде: чувствительнее (отрицательнее) → НИЖЕ скор.
        assert severe < mild

    def test_full_sensitivity_zero_score(self) -> None:
        value, _c, _r = _score_mortgage_sensitivity(-_MORTG_MAX_X_PCT, "medium")
        assert value == pytest.approx(0.0)

    def test_beyond_max_clamped_zero(self) -> None:
        value, _c, _r = _score_mortgage_sensitivity(-100.0, "medium")
        assert value == pytest.approx(0.0)

    def test_non_negative_x_pct_full_score(self) -> None:
        # x_pct ≥ 0 (не доверяем «растёт при росте ставки») → нет риска → score 1.0.
        value, _c, reason = _score_mortgage_sensitivity(3.0, "medium")
        assert value == pytest.approx(1.0)
        assert "малочувствителен" in reason

    def test_in_range(self) -> None:
        for x in (-25.0, -10.0, -1.0, 0.0, 5.0):
            value, _c, _r = _score_mortgage_sensitivity(x, "medium")
            assert value is not None
            assert 0.0 <= value <= 1.0

    def test_none_input_unavailable(self) -> None:
        value, conf, reason = _score_mortgage_sensitivity(None, "medium")
        assert value is None
        assert conf == "low"
        assert reason


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 8: differentiation (число USP-ниш → n/target, прямой)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreDifferentiation:
    def test_target_usp_full_score(self) -> None:
        value, _c, _r = _score_differentiation(int(_DIFF_TARGET_USP), "medium")
        assert value == pytest.approx(1.0)

    def test_zero_usp_zero_score(self) -> None:
        value, _c, _r = _score_differentiation(0, "medium")
        assert value == pytest.approx(0.0)

    def test_more_usp_higher_score(self) -> None:
        low, _c1, _r1 = _score_differentiation(1, "medium")
        high, _c2, _r2 = _score_differentiation(3, "medium")
        assert low is not None and high is not None
        assert high > low

    def test_above_target_clamped(self) -> None:
        value, _c, _r = _score_differentiation(10, "medium")
        assert value == pytest.approx(1.0)

    def test_in_range(self) -> None:
        for n in (0, 1, 2, 3, 5):
            value, _c, _r = _score_differentiation(n, "medium")
            assert value is not None
            assert 0.0 <= value <= 1.0

    def test_none_input_unavailable(self) -> None:
        value, conf, reason = _score_differentiation(None, "medium")
        assert value is None
        assert conf == "low"
        assert reason


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 9: commercial (ДЕГРАДИРУЕТ в None, НЕ 0)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreCommercial:
    def test_available_share_scaled(self) -> None:
        signal = {"available": True, "commercial_sell_through_pct": 40.0, "confidence": "medium"}
        value, conf, _r = _score_commercial(signal)
        assert value == pytest.approx(0.4)
        assert conf == "medium"

    def test_in_range(self) -> None:
        for share in (0.0, 25.0, 100.0):
            value, _c, _r = _score_commercial(
                {"available": True, "commercial_sell_through_pct": share, "confidence": "low"}
            )
            assert value is not None
            assert 0.0 <= value <= 1.0

    def test_unavailable_signal_degrades_to_none(self) -> None:
        # Ключевое отличие commercial: degraded → None, НЕ 0-как-заглушка.
        value, conf, reason = _score_commercial({"available": False, "caveat": "x"})
        assert value is None
        assert conf == "low"
        assert reason

    def test_none_signal_degrades_to_none(self) -> None:
        value, _conf, _r = _score_commercial(None)
        assert value is None

    def test_missing_share_degrades_to_none(self) -> None:
        value, _conf, _r = _score_commercial({"available": True})
        assert value is None

    def test_bad_confidence_falls_back_low(self) -> None:
        value, conf, _r = _score_commercial(
            {"available": True, "commercial_sell_through_pct": 10.0, "confidence": "garbage"}
        )
        assert value is not None
        assert conf == "low"


# ──────────────────────────────────────────────────────────────────────────────
# Скорер 10: confidence (META — mean label + availability)
# ──────────────────────────────────────────────────────────────────────────────


class TestScoreConfidence:
    def test_all_high_full_availability(self) -> None:
        value, label, _r = _score_confidence(["high"] * 9, 9, 9)
        # mean_label=1.0, availability=1.0 → value=1.0; label=min=high.
        assert value == pytest.approx(1.0)
        assert label == "high"

    def test_low_label_drags_label(self) -> None:
        _v, label, _r = _score_confidence(["high", "medium", "low"], 3, 9)
        # MIN-агрегат → 'low'.
        assert label == "low"

    def test_partial_availability_lowers_value(self) -> None:
        full, _l1, _r1 = _score_confidence(["high"] * 9, 9, 9)
        partial, _l2, _r2 = _score_confidence(["high"] * 3, 3, 9)
        assert full is not None and partial is not None
        assert partial < full

    def test_in_range(self) -> None:
        value, _l, _r = _score_confidence(["medium", "low"], 5, 9)
        assert value is not None
        assert 0.0 <= value <= 1.0

    def test_empty_contributing(self) -> None:
        value, label, _r = _score_confidence([], 0, 9)
        assert value == pytest.approx(0.0)
        assert label == "low"


# ──────────────────────────────────────────────────────────────────────────────
# Pure-агрегат: _min_confidence
# ──────────────────────────────────────────────────────────────────────────────


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

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

    def test_empty_low(self) -> None:
        assert _min_confidence([]) == "low"


# ──────────────────────────────────────────────────────────────────────────────
# Pure-агрегат: _weighted_overall — renorm над доступными
# ──────────────────────────────────────────────────────────────────────────────


def _score(key: str, value: float | None) -> ProductScore:
    return ProductScore(key=key, value=value, confidence="medium", reason="x", source="svc")


class TestWeightedOverall:
    def test_all_available_weighted_mean(self) -> None:
        # 2 равновесных скора 0.0 и 1.0 → среднее 0.5.
        scores = {"a": _score("a", 0.0), "b": _score("b", 1.0)}
        weights = {"a": 0.5, "b": 0.5}
        assert _weighted_overall(scores, weights) == pytest.approx(0.5)

    def test_missing_score_renormalized_not_dragged(self) -> None:
        # b отсутствует (value None): overall = только a (renorm) = 0.8, НЕ занижен.
        scores = {"a": _score("a", 0.8), "b": _score("b", None)}
        weights = {"a": 0.5, "b": 0.5}
        out = _weighted_overall(scores, weights)
        assert out == pytest.approx(0.8)

    def test_renorm_weights_proportional(self) -> None:
        # a(w=0.2,val=1.0) + c(w=0.6,val=0.0); b(w=0.2) отсутствует.
        # renorm над {a,c}: (0.2*1 + 0.6*0)/(0.2+0.6) = 0.25.
        scores = {
            "a": _score("a", 1.0),
            "b": _score("b", None),
            "c": _score("c", 0.0),
        }
        weights = {"a": 0.2, "b": 0.2, "c": 0.6}
        assert _weighted_overall(scores, weights) == pytest.approx(0.25)

    def test_zero_weight_score_excluded(self) -> None:
        # confidence-мета (вес 0) НЕ участвует в overall, даже если value задан.
        scores = {"a": _score("a", 1.0), "meta": _score("meta", 0.0)}
        weights = {"a": 0.5, "meta": 0.0}
        assert _weighted_overall(scores, weights) == pytest.approx(1.0)

    def test_all_unavailable_none(self) -> None:
        scores = {"a": _score("a", None), "b": _score("b", None)}
        weights = {"a": 0.5, "b": 0.5}
        assert _weighted_overall(scores, weights) is None

    def test_in_range(self) -> None:
        scores = {"a": _score("a", 0.3), "b": _score("b", 0.9)}
        weights = {"a": 0.4, "b": 0.6}
        out = _weighted_overall(scores, weights)
        assert out is not None
        assert 0.0 <= out <= 1.0


class TestScoreWeightsContract:
    def test_weights_sum_to_one(self) -> None:
        assert sum(_SCORE_WEIGHTS.values()) == pytest.approx(1.0)

    def test_weights_cover_all_keys(self) -> None:
        assert set(_SCORE_WEIGHTS) == set(_SCORE_KEYS)

    def test_confidence_meta_zero_weight(self) -> None:
        assert _SCORE_WEIGHTS["confidence"] == 0.0


# ──────────────────────────────────────────────────────────────────────────────
# Pure-хелперы оркестратора: _quarter_from_cad / _count_positive_usp / _overlay_conf
# ──────────────────────────────────────────────────────────────────────────────


class TestQuarterFromCad:
    def test_strips_last_segment(self) -> None:
        assert _quarter_from_cad("66:41:0303161:123") == "66:41:0303161"

    def test_already_quarter_passthrough(self) -> None:
        assert _quarter_from_cad("66:41:0303161") == "66:41:0303161"


class TestCountPositiveUsp:
    def test_counts_positive_deficit(self) -> None:
        overlay = {
            "usp": [
                {"deficit_index": 0.5},
                {"deficit_index": 0.1},
                {"deficit_index": -0.2},
                {"deficit_index": None},
            ]
        }
        assert _count_positive_usp(overlay) == 2

    def test_empty_usp_zero(self) -> None:
        assert _count_positive_usp({"usp": []}) == 0

    def test_none_overlay_none(self) -> None:
        assert _count_positive_usp(None) is None

    def test_missing_usp_key_none(self) -> None:
        assert _count_positive_usp({"ranked_segments": []}) is None


class TestOverlayConfidence:
    def test_max_of_ranked(self) -> None:
        overlay = {
            "ranked_segments": [
                {"confidence": "low"},
                {"confidence": "medium"},
            ]
        }
        assert _overlay_confidence(overlay) == "medium"

    def test_empty_low(self) -> None:
        assert _overlay_confidence({"ranked_segments": []}) == "low"

    def test_none_low(self) -> None:
        assert _overlay_confidence(None) == "low"


# ──────────────────────────────────────────────────────────────────────────────
# Orchestrator: compute_score_card via @patch всех backing-сервисов
# ──────────────────────────────────────────────────────────────────────────────


def _forecast_stub(deficit_index: float | None, confidence: str = "medium") -> MagicMock:
    f = MagicMock()
    f.deficit_index = deficit_index
    f.confidence = confidence
    return f


def _metrics_stub(
    unit_velocity: float | None = 10.0,
    overstock_index: float | None = 0.3,
    confidence: str = "medium",
) -> MagicMock:
    m = MagicMock()
    m.unit_velocity = unit_velocity
    m.overstock_index = overstock_index
    m.confidence = confidence
    return m


def _fsp_stub(index: float | None = 0.4, confidence: str = "medium") -> MagicMock:
    f = MagicMock()
    f.index = index
    f.confidence = confidence
    return f


def _competitor_stub(relevance_weight: float | None) -> MagicMock:
    c = MagicMock()
    c.relevance_weight = relevance_weight
    return c


def _competitors_response_stub(weights: list[float | None]) -> MagicMock:
    r = MagicMock()
    r.competitors = [_competitor_stub(w) for w in weights]
    return r


def _afford_stub(payment: float | None = 50_000.0, confidence: str = "low") -> MagicMock:
    a = MagicMock()
    a.monthly_payment_rub = payment
    a.confidence = confidence
    return a


def _poi_response_stub(weights: list[float]) -> MagicMock:
    r = MagicMock()
    r.top_poi = [MagicMock(weight=w) for w in weights]
    return r


def _sens_stub(x_pct: float | None = -5.0, source: str = "regression") -> DistributedLagFit:
    """РЕАЛЬНЫЙ Almon DistributedLagFit-вход §9.6 (#978) для адаптера → mortgage_sens.

    'regression'-source → адаптер выдаёт confidence='medium' + x_pct=fit.x_pct;
    'fallback' → x_pct=None / confidence='low' (build_fit_result обнуляет coef/x_pct
    в fallback). Это НАСТОЯЩИЙ контракт DistributedLagFit→адаптер, не MagicMock-bag.
    """
    gated = source == "regression"
    return DistributedLagFit(
        segment={"district": "X", "obj_class": None},
        best_lag_months=3 if gated else None,
        coef=-0.05 if gated else None,
        x_pct=x_pct if gated else None,
        r2=0.5 if gated else 0.05,
        n=40 if gated else 12,
        per_lag_coef=None,
        hac_se=None,
        hac_bandwidth=None,
        almon_degree=2,
        source=source,
        phrase="…",
    )


def _overlay_stub(
    n_positive_usp: int = 2,
    commercial: dict[str, Any] | None = None,
    ranked_confidence: str = "medium",
) -> dict[str, Any]:
    usp = [{"deficit_index": 0.5} for _ in range(n_positive_usp)]
    return {
        "horizon_months": 12,
        "mode": "demand_supply",
        "advisory": True,
        "ranked_segments": [{"confidence": ranked_confidence}],
        "warnings": [],
        "usp": usp,
        "commercial": commercial,
    }


def _db_with_centroid(lat: float | None = 56.8, lon: float | None = 60.6) -> MagicMock:
    """MagicMock db, чей execute(...).mappings().first() отдаёт центроид участка."""
    db = MagicMock()
    row = None if lat is None or lon is None else {"lat": lat, "lon": lon}
    db.execute.return_value.mappings.return_value.first.return_value = row
    return db


def _patch_all(
    *,
    forecast: Any = None,
    metrics: Any = None,
    fsp: Any = None,
    competitors: Any = None,
    afford: Any = None,
    poi: Any = None,
    sens: Any = None,
    overlay: Any = None,
) -> Any:
    """Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом)."""
    commercial = {"available": True, "commercial_sell_through_pct": 30.0, "confidence": "medium"}
    forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)]
    competitors_rv = (
        competitors if competitors is not None else _competitors_response_stub([0.4, 0.2])
    )
    overlay_rv = overlay if overlay is not None else _overlay_stub(commercial=commercial)
    patchers = [
        patch(_P_FORECAST, return_value=forecast_rv),
        patch(_P_METRICS, return_value=metrics if metrics is not None else _metrics_stub()),
        patch(_P_FSP, return_value=fsp if fsp is not None else _fsp_stub()),
        patch(_P_COMPETITORS, return_value=competitors_rv),
        patch(_P_AFFORD, return_value=afford if afford is not None else _afford_stub()),
        patch(_P_POI, return_value=poi if poi is not None else _poi_response_stub([0.03, 0.02])),
        patch(_P_REG, return_value=sens if sens is not None else _sens_stub()),
        patch(_P_OVERLAY, return_value=overlay_rv),
    ]
    return _MultiPatch(patchers)


class _MultiPatch:
    def __init__(self, patchers: list[Any]) -> None:
        self._patchers = patchers

    def __enter__(self) -> None:
        for p in self._patchers:
            p.start()

    def __exit__(self, *exc: Any) -> None:
        for p in reversed(self._patchers):
            p.stop()


# district задан → §9.6 Almon-адаптер реально фитит район×класс (без района адаптер
# короткозамыкает в low/None — тогда mortgage_sensitivity не упражнял бы регрессию).
_SPEC = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический")
_CAD = "66:41:0303161:123"


class TestComputeScoreCardHappy:
    def test_returns_score_card(self) -> None:
        with _patch_all():
            card = compute_score_card(
                _db_with_centroid(), spec=_SPEC, district="Академический", cad_num=_CAD
            )
        assert isinstance(card, ProductScoreCard)

    def test_all_ten_scores_present(self) -> None:
        with _patch_all():
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        assert set(card.scores) == set(_SCORE_KEYS)
        assert len(card.scores) == 10

    def test_all_values_in_range_when_backed(self) -> None:
        with _patch_all():
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        for key in _SCORE_KEYS:
            value = card.scores[key].value
            assert value is not None, f"{key} unexpectedly unavailable"
            assert 0.0 <= value <= 1.0

    def test_overall_weighted_in_range(self) -> None:
        with _patch_all():
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        assert card.overall is not None
        assert 0.0 <= card.overall <= 1.0

    def test_advisory_always_true(self) -> None:
        with _patch_all():
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        assert card.advisory is True

    def test_segment_and_horizon_carried(self) -> None:
        with _patch_all():
            card = compute_score_card(
                _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD, horizon_months=18
            )
        assert card.segment == _SPEC.as_dict()
        assert card.horizon_months == 18

    def test_default_horizon(self) -> None:
        with _patch_all():
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        assert card.horizon_months == _DEFAULT_HORIZON_MONTHS

    def test_as_dict_shape(self) -> None:
        with _patch_all():
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        d = card.as_dict()
        assert d["advisory"] is True
        assert set(d["scores"]) == set(_SCORE_KEYS)
        assert "overall" in d
        assert d["scores"]["market_fit"]["source"] == "demand_supply_forecast"


class TestComputeScoreCardGraceful:
    def test_forecast_raises_market_fit_unavailable_card_returns(self) -> None:
        # Сбой одного сервиса (#980) → market_fit unavailable, КАРТА всё равно собрана.
        with _patch_all():
            with patch(_P_FORECAST, side_effect=RuntimeError("boom")):
                card = compute_score_card(
                    _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
                )
        assert card.scores["market_fit"].value is None
        assert card.scores["market_fit"].source == "unavailable"
        # Остальные скоры по-прежнему backed.
        assert card.scores["demand"].value is not None
        assert card.overall is not None

    def test_metrics_raises_demand_and_supply_degrade(self) -> None:
        with _patch_all():
            with patch(_P_METRICS, side_effect=RuntimeError("boom")):
                card = compute_score_card(
                    _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
                )
        assert card.scores["demand"].value is None
        assert card.scores["demand"].source == "unavailable"
        # supply_risk теряет overstock-канал, но future_supply.index ещё есть → backed.
        assert card.scores["supply_risk"].value is not None

    def test_competitors_raises_future_competition_unavailable(self) -> None:
        with _patch_all():
            with patch(_P_COMPETITORS, side_effect=RuntimeError("no geom")):
                card = compute_score_card(
                    _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
                )
        assert card.scores["future_competition"].value is None
        assert card.scores["future_competition"].source == "unavailable"

    def test_poi_raises_infra_unavailable(self) -> None:
        with _patch_all():
            with patch(_P_POI, side_effect=RuntimeError("poi boom")):
                card = compute_score_card(
                    _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
                )
        assert card.scores["infra_fit"].value is None
        assert card.scores["infra_fit"].source == "unavailable"

    def test_no_centroid_infra_unavailable(self) -> None:
        # db без геометрии участка (центроид None) → infra_fit unavailable.
        with _patch_all():
            card = compute_score_card(
                _db_with_centroid(lat=None, lon=None), spec=_SPEC, district="X", cad_num=_CAD
            )
        assert card.scores["infra_fit"].value is None
        assert card.scores["infra_fit"].source == "unavailable"

    def test_overlay_raises_differentiation_and_commercial_unavailable(self) -> None:
        with _patch_all():
            with patch(_P_OVERLAY, side_effect=RuntimeError("overlay boom")):
                card = compute_score_card(
                    _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
                )
        assert card.scores["differentiation"].value is None
        assert card.scores["commercial"].value is None
        assert card.scores["commercial"].source == "unavailable"

    def test_commercial_degraded_signal_none_not_zero(self) -> None:
        # overlay есть, но commercial.available=False → commercial None (НЕ 0).
        degraded = _overlay_stub(commercial={"available": False, "caveat": "thin"})
        with _patch_all(overlay=degraded):
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        assert card.scores["commercial"].value is None
        assert card.scores["commercial"].source == "unavailable"

    def test_all_services_raise_card_still_returns_overall_none(self) -> None:
        # Тотальный сбой: каждый backing бросает → все продуктовые скоры unavailable,
        # overall None, но КАРТА возвращается (никогда не crash) с advisory True.
        boom = RuntimeError("boom")
        db = MagicMock()
        db.execute.side_effect = boom
        with (
            patch(_P_FORECAST, side_effect=boom),
            patch(_P_METRICS, side_effect=boom),
            patch(_P_FSP, side_effect=boom),
            patch(_P_COMPETITORS, side_effect=boom),
            patch(_P_AFFORD, side_effect=boom),
            patch(_P_POI, side_effect=boom),
            patch(_P_REG, side_effect=boom),
            patch(_P_OVERLAY, side_effect=boom),
        ):
            card = compute_score_card(db, spec=_SPEC, district="X", cad_num=_CAD)
        assert isinstance(card, ProductScoreCard)
        assert card.advisory is True
        # future_competition None (signal failed); все 9 продуктовых unavailable.
        product_keys = [k for k in _SCORE_KEYS if k != "confidence"]
        assert all(card.scores[k].value is None for k in product_keys)
        assert card.overall is None
        assert card.confidence == "low"
        # confidence-мета всё равно посчитан (value не None — это data-quality мера).
        assert card.scores["confidence"].value is not None

    def test_missing_score_does_not_drag_overall(self) -> None:
        # Один сервис падает (market_fit, самый тяжёлый вес) → overall пересчитан renorm
        # над оставшимися, НЕ занижен нулём за выпавший market_fit.
        with _patch_all():
            with patch(_P_FORECAST, side_effect=RuntimeError("boom")):
                card_missing = compute_score_card(
                    _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
                )
            card_full = compute_score_card(
                _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
            )
        assert card_missing.overall is not None and card_full.overall is not None
        # overall остаётся валидным средним доступных (в [0,1]), не схлопывается к 0.
        assert 0.0 <= card_missing.overall <= 1.0


# ── §9.6 mortgage_sensitivity через Almon-ADL адаптер (#978, НОВЫЙ путь) ────────


class TestMortgageSensitivityViaRegimeAdapter:
    """mortgage_sensitivity берёт x_pct/confidence из compute_rate_regime_sensitivity
    (адаптер над Almon-ADL #978). Проверяем НОВЫЙ контракт сквозь реальный адаптер:
    'regression'-фит → x_pct surfaced + medium; 'fallback'-фит → low-conf путь."""

    def test_regression_fit_surfaces_x_pct_and_medium(self) -> None:
        # 'regression' DistributedLagFit (x_pct=-12.0) → mortgage_sensitivity backed
        # (value не None, инвертирован по магнитуде) + confidence 'medium' (capped).
        with _patch_all(sens=_sens_stub(x_pct=-12.0, source="regression")):
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        ms = card.scores["mortgage_sensitivity"]
        assert ms.value is not None
        assert 0.0 <= ms.value <= 1.0
        assert ms.confidence == "medium"  # Almon-адаптер кэпит ≤ medium

    def test_more_negative_x_pct_lowers_score(self) -> None:
        # Инверсия сохранена сквозь адаптер: чувствительнее (отрицательнее x_pct) →
        # НИЖЕ скор (mortgage_sensitivity high-bad → low-score).
        with _patch_all(sens=_sens_stub(x_pct=-2.0, source="regression")):
            mild = compute_score_card(
                _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
            ).scores["mortgage_sensitivity"]
        with _patch_all(sens=_sens_stub(x_pct=-25.0, source="regression")):
            severe = compute_score_card(
                _db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD
            ).scores["mortgage_sensitivity"]
        assert mild.value is not None and severe.value is not None
        assert severe.value < mild.value

    def test_fallback_fit_uses_low_confidence_path(self) -> None:
        # 'fallback' DistributedLagFit → адаптер выдаёт x_pct=None + confidence='low'
        # → mortgage_sensitivity value None (unavailable), честная деградация.
        with _patch_all(sens=_sens_stub(source="fallback")):
            card = compute_score_card(_db_with_centroid(), spec=_SPEC, district="X", cad_num=_CAD)
        ms = card.scores["mortgage_sensitivity"]
        assert ms.value is None
        assert ms.confidence == "low"
        # Карта всё равно собрана, прочие скоры backed.
        assert card.scores["demand"].value is not None

    def test_district_none_spec_degrades_low(self) -> None:
        # spec.district=None → адаптер короткозамыкает (регрессию НЕ зовёт) → x_pct
        # None + low → mortgage_sensitivity unavailable. compute_district_rate_
        # regression не должен вызываться ни разу.
        no_district = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60")
        with _patch_all():
            with patch(_P_REG) as reg_mock:
                card = compute_score_card(
                    _db_with_centroid(), spec=no_district, district="X", cad_num=_CAD
                )
        reg_mock.assert_not_called()
        assert card.scores["mortgage_sensitivity"].value is None
        assert card.scores["mortgage_sensitivity"].confidence == "low"


# ── #2464 cluster A: _poi_weight_sum SAVEPOINT-регрессия (session-poisoning) ────
# ОБА db.execute-сайта внутри _poi_weight_sum раньше ловили сбой БЕЗ SAVEPOINT/rollback:
#   1) центроид-lookup (db.execute(_PARCEL_CENTROID_SQL, ...));
#   2) compute_poi_weighted_top7(...) — poi_score.py сам делает raw db.execute БЕЗ своего
#      try/except, поэтому сбой ПРОБРАСЫВАЕТСЯ наверх и глотается вторым except здесь.
# На реальном Postgres любой из них отравляет §22-report session (compute_score_card делит
# одну db-сессию с остальными §22-скорерами): следующий db.execute на ТОЙ ЖЕ сессии падал
# "current transaction is aborted". Фикс: ОБА сайта обёрнуты в db.begin_nested() (swallow
# site для poi — это второй except _poi_weight_sum, там и ставим savepoint).


class TestPoiWeightSumSavepoint:
    def test_centroid_query_failure_returns_none(self) -> None:
        """DB-сбой на центроид-lookup → None (fallback → infra_fit unavailable)."""
        db = MagicMock()
        db.execute.side_effect = RuntimeError("connection refused")
        assert _poi_weight_sum(db, cad_num=_CAD) is None

    def test_uses_begin_nested_savepoint(self) -> None:
        """Фикс: центроид-запрос обёрнут в db.begin_nested(), не голый db.execute."""
        db = MagicMock()
        db.execute.return_value.mappings.return_value.first.return_value = None
        _poi_weight_sum(db, cad_num=_CAD)
        assert db.begin_nested.call_count == 1

    def test_session_usable_after_centroid_failure(self) -> None:
        """execute сбоит на 1-м вызове (центроид), db остаётся usable для следующего
        (не связанного) вызова на этой же сессии — имитирует §22 shared report-session."""
        db = MagicMock()
        db.execute.side_effect = RuntimeError("boom")
        assert _poi_weight_sum(db, cad_num=_CAD) is None

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

    def test_poi_call_failure_returns_none(self) -> None:
        """Второй сайт: центроид резолвится, но compute_poi_weighted_top7 бросает →
        None (документированный fallback, infra_fit unavailable)."""
        db = MagicMock()
        db.execute.return_value.mappings.return_value.first.return_value = {
            "lat": 56.8,
            "lon": 60.6,
        }
        with patch(_P_POI, side_effect=RuntimeError("poi_score raw db.execute failed")):
            assert _poi_weight_sum(db, cad_num=_CAD) is None

    def test_uses_begin_nested_for_poi_call(self) -> None:
        """Фикс второго сайта: вызов compute_poi_weighted_top7 обёрнут в db.begin_nested().
        При успешном центроиде оба сайта под savepoint → begin_nested вызван дважды."""
        db = MagicMock()
        db.execute.return_value.mappings.return_value.first.return_value = {
            "lat": 56.8,
            "lon": 60.6,
        }
        with patch(_P_POI, return_value=_poi_response_stub([0.01])):
            _poi_weight_sum(db, cad_num=_CAD)
        assert db.begin_nested.call_count == 2

    def test_session_usable_after_poi_failure(self) -> None:
        """poi_score (второй сайт) бросает → db остаётся usable для следующего
        (не связанного) вызова на этой же сессии — имитирует §22 shared report-session
        (без savepoint здесь была бы 'current transaction is aborted' на следующем скоре)."""
        db = MagicMock()
        db.execute.return_value.mappings.return_value.first.return_value = {
            "lat": 56.8,
            "lon": 60.6,
        }
        with patch(_P_POI, side_effect=RuntimeError("poi boom")):
            assert _poi_weight_sum(db, cad_num=_CAD) is None
        # begin_nested вызван для обоих сайтов (центроид успешен, poi сбоит внутри savepoint).
        assert db.begin_nested.call_count == 2

        # Следующий (не связанный) вызов на той же сессии отрабатывает — не отравлена.
        next_result = MagicMock()
        next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
        db.execute.return_value = next_result
        assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]

    def test_centroid_success_then_poi_computed(self) -> None:
        """Happy path: центроид резолвится → compute_poi_weighted_top7 вызван, Σ weight отдан.
        Оба db-сайта под savepoint → begin_nested вызван дважды."""
        db = MagicMock()
        db.execute.return_value.mappings.return_value.first.return_value = {
            "lat": 56.8,
            "lon": 60.6,
        }
        with patch(_P_POI, return_value=_poi_response_stub([0.03, 0.02])):
            out = _poi_weight_sum(db, cad_num=_CAD)
        assert out == pytest.approx(0.05)
        assert db.begin_nested.call_count == 2
