"""Тесты §25 special_indices (#986/954-C) — pure-helpers + mocked-БД + оркестратор.

Покрытие:
  • Pure-математика БЕЗ БД: Product Void (порог/доля/счёт), Launch Window (пик +
    tie-break + нормализация), Competitor Strength (топ-N среднее), Cannibalization
    (доля same-class), Artificial Demand (доля ипотеки), Cost-of-Error (монотонность
    + произведение), нормализации в [0,1], confidence-helpers, _avg_ticket.
  • Artificial-Demand SQL: MagicMock-сессия — форма SQL (CAST(:x AS type), не ::),
    параметры; сигнал есть → индекс; нет проданных → None + caveat.
  • compute_special_indices: @patch бэкенд-сервисов — 6 индексов присутствуют,
    advisory True, per-index graceful (сбой одного → unavailable, карточка цела).

Всё детерминировано, без БД (сессия мокается). Зеркалит стиль test_what_to_build /
test_market_metrics.
"""

from __future__ import annotations

import contextlib
from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from app.services.forecasting.sales_series import (
    PRICE_BUCKET_BUSINESS,
    PRICE_BUCKET_COMFORT,
    PRICE_BUCKET_ECONOMY,
    PRICE_BUCKET_PREMIUM,
    SegmentSpec,
)
from app.services.forecasting.special_indices import (
    _INDEX_KEYS,
    _METHOD_UNAVAILABLE,
    KEY_ARTIFICIAL_DEMAND,
    KEY_CANNIBALIZATION,
    KEY_COMPETITOR_STRENGTH,
    KEY_COST_OF_ERROR,
    KEY_LAUNCH_WINDOW,
    KEY_PRODUCT_VOID,
    SpecialIndex,
    _add_months,
    _aggregate_overlap,
    _artificial_demand_share,
    _avg_ticket_rub,
    _candidate_release_month,
    _candidate_unit_mix_from_recommend,
    _cannibalization_index,
    _canonical_room_bucket,
    _cap_confidence,
    _clamp01,
    _class_overlap,
    _competitor_strength,
    _cost_of_error_index,
    _count_void,
    _geo_weight,
    _haversine_km,
    _launch_window_horizon,
    _min_confidence,
    _normalize_shares,
    _oversupply_risk_from_deficit,
    _own_portfolio_overlap,
    _pick_launch_window,
    _portfolio_has_unit_mix,
    _price_bucket_to_band,
    _price_overlap,
    _query_artificial_demand,
    _query_parcel_centroid,
    _timing_overlap,
    _unit_mix_similarity,
    _void_index,
    compute_special_indices,
)
from app.services.site_finder.own_portfolio import OwnProject

# Patch-таргеты — имена, импортированные В модуль special_indices. _DSF используется
# точечно (re-patch поверх _full_stack_patch); остальные сервисы патчатся через
# patch.multiple(_MOD, ...) по kwargs-имени, поэтому отдельных констант им не нужно.
_MOD = "app.services.forecasting.special_indices"
_DSF = f"{_MOD}.compute_demand_supply_forecast"


# ──────────────────────────────────────────────────────────────────────────────
# Pure: _clamp01 / confidence-helpers
# ──────────────────────────────────────────────────────────────────────────────


class TestClamp01:
    def test_in_range_passthrough(self) -> None:
        assert _clamp01(0.4) == 0.4

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

    def test_below_zero_clamped(self) -> None:
        assert _clamp01(-0.3) == 0.0


class TestCapConfidence:
    def test_high_capped_to_medium(self) -> None:
        assert _cap_confidence("high") == "medium"

    def test_medium_unchanged(self) -> None:
        assert _cap_confidence("medium") == "medium"

    def test_low_unchanged(self) -> None:
        assert _cap_confidence("low") == "low"


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

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

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

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


# ──────────────────────────────────────────────────────────────────────────────
# Pure: Product Void (порог / доля / счёт)
# ──────────────────────────────────────────────────────────────────────────────


class TestVoidIndex:
    def test_share_above_threshold(self) -> None:
        # 2 из 4 измеренных ≥ 0.25 → 0.5.
        assert _void_index([0.3, 0.1, 0.9, -0.2]) == 0.5

    def test_threshold_is_inclusive(self) -> None:
        # ровно на пороге 0.25 → считается пустотой.
        assert _void_index([0.25]) == 1.0

    def test_below_threshold_excluded(self) -> None:
        # 0.24 < 0.25 → не пустота.
        assert _void_index([0.24]) == 0.0

    def test_none_cells_excluded_from_denominator(self) -> None:
        # None не входит ни в числитель, ни в знаменатель: 1 из 2 измеренных.
        assert _void_index([0.9, None, 0.0]) == 0.5

    def test_empty_yields_zero(self) -> None:
        assert _void_index([]) == 0.0

    def test_all_none_yields_zero(self) -> None:
        assert _void_index([None, None]) == 0.0

    def test_in_range(self) -> None:
        v = _void_index([0.9, 0.8, 0.7])
        assert 0.0 <= v <= 1.0
        assert v == 1.0

    def test_custom_threshold(self) -> None:
        assert _void_index([0.5, 0.4], threshold=0.45) == 0.5


class TestCountVoid:
    def test_counts_at_or_above_threshold(self) -> None:
        assert _count_void([0.3, 0.25, 0.1, 0.99]) == 3

    def test_none_skipped(self) -> None:
        assert _count_void([None, 0.9, None]) == 1

    def test_empty_zero(self) -> None:
        assert _count_void([]) == 0


# ──────────────────────────────────────────────────────────────────────────────
# Pure: Launch Window (пик + tie-break + нормализация)
# ──────────────────────────────────────────────────────────────────────────────


class TestPickLaunchWindow:
    def test_picks_peak_horizon(self) -> None:
        h, strength = _pick_launch_window({6: 0.1, 12: 0.4, 18: 0.2, 24: -0.1})
        assert h == 12
        # 0.4 / 0.5 (saturation) = 0.8.
        assert strength == pytest.approx(0.8)

    def test_strength_in_range(self) -> None:
        _, strength = _pick_launch_window({6: 0.9, 12: 0.95})
        assert strength is not None
        assert 0.0 <= strength <= 1.0
        assert strength == 1.0  # 0.95/0.5 clamp → 1.0

    def test_tie_break_prefers_earlier_horizon(self) -> None:
        # равный пиковый deficit на 6 и 18 → берём меньший горизонт (раньше выгоднее).
        h, _ = _pick_launch_window({6: 0.5, 18: 0.5})
        assert h == 6

    def test_none_horizons_ignored(self) -> None:
        # 12 — None (тонко), пик среди измеренных = 24.
        h, _ = _pick_launch_window({6: 0.1, 12: None, 24: 0.3})
        assert h == 24

    def test_all_none_degrades(self) -> None:
        assert _pick_launch_window({6: None, 12: None}) == (None, None)

    def test_empty_degrades(self) -> None:
        assert _pick_launch_window({}) == (None, None)

    def test_nonpositive_peak_zero_strength_but_horizon_returned(self) -> None:
        # все горизонты ≤0 (баланс/затоварка): окна «строить» нет (strength 0),
        # но аргмакс-горизонт возвращаем (наименее плохой) для explainability.
        h, strength = _pick_launch_window({6: -0.3, 12: -0.1, 24: -0.5})
        assert h == 12  # наибольший (наименее отрицательный) deficit
        assert strength == 0.0

    def test_saturation_nonpositive_degrades_to_sign(self) -> None:
        h, strength = _pick_launch_window({6: 0.2}, saturation=0.0)
        assert h == 6
        assert strength == 1.0


# ──────────────────────────────────────────────────────────────────────────────
# Pure: Competitor Strength (топ-N среднее)
# ──────────────────────────────────────────────────────────────────────────────


class TestCompetitorStrength:
    def test_mean_of_top_n(self) -> None:
        # топ-5 по убыванию из 6: 0.9,0.8,0.7,0.6,0.5 → mean 0.7.
        v = _competitor_strength([0.1, 0.5, 0.9, 0.6, 0.8, 0.7])
        assert v == pytest.approx(0.7)

    def test_fewer_than_top_n(self) -> None:
        v = _competitor_strength([0.4, 0.6])
        assert v == pytest.approx(0.5)

    def test_none_weights_skipped(self) -> None:
        v = _competitor_strength([None, 0.8, None, 0.4])
        assert v == pytest.approx(0.6)

    def test_empty_is_none(self) -> None:
        assert _competitor_strength([]) is None

    def test_all_none_is_none(self) -> None:
        assert _competitor_strength([None, None]) is None

    def test_in_range(self) -> None:
        v = _competitor_strength([0.9, 0.95, 1.0])
        assert v is not None and 0.0 <= v <= 1.0

    def test_custom_top_n(self) -> None:
        v = _competitor_strength([0.9, 0.8, 0.1], top_n=2)
        assert v == pytest.approx(0.85)


# ──────────────────────────────────────────────────────────────────────────────
# Pure: Cannibalization (доля same-class)
# ──────────────────────────────────────────────────────────────────────────────


class TestCannibalizationIndex:
    def test_share_of_same_class(self) -> None:
        # same = 0.6+0.4 = 1.0; all = 0.6+0.4+0.5+0.5 = 2.0 → 0.5.
        v = _cannibalization_index([0.6, 0.4], [0.6, 0.4, 0.5, 0.5])
        assert v == pytest.approx(0.5)

    def test_no_same_class_is_zero(self) -> None:
        # конкуренты есть, но ни одного в нашем классе → 0.0 (валидно).
        v = _cannibalization_index([], [0.5, 0.5])
        assert v == 0.0

    def test_no_competitors_is_none(self) -> None:
        # окружения нет вообще → None (неизмеримо).
        assert _cannibalization_index([], []) is None

    def test_all_same_class_is_one(self) -> None:
        v = _cannibalization_index([0.5, 0.5], [0.5, 0.5])
        assert v == 1.0

    def test_none_weights_skipped(self) -> None:
        v = _cannibalization_index([0.5], [0.5, None, 0.5])
        assert v == pytest.approx(0.5)

    def test_in_range(self) -> None:
        v = _cannibalization_index([0.3], [0.3, 0.7])
        assert v is not None and 0.0 <= v <= 1.0


# ──────────────────────────────────────────────────────────────────────────────
# §25.3 TRUE own-portfolio overlap — pure-оси (класс/цена/квартирография/тайминг/гео)
# ──────────────────────────────────────────────────────────────────────────────


class TestClassOverlap:
    def test_same_class_full(self) -> None:
        assert _class_overlap("комфорт", "комфорт") == 1.0

    def test_case_and_language_insensitive(self) -> None:
        # 'Комфорт' (Title) vs 'comfort' (EN) → один класс → 1.0 (reuse _normalize_class).
        assert _class_overlap("Комфорт", "comfort") == 1.0
        assert _class_overlap("комфорт-класс", "Комфорт") == 1.0

    def test_adjacent_class_partial(self) -> None:
        # комфорт (1) ↔ комфорт+ (2) = 1 шаг → 0.5.
        assert _class_overlap("комфорт", "комфорт+") == 0.5

    def test_two_steps(self) -> None:
        # комфорт (1) ↔ бизнес (3) = 2 шага → 0.2.
        assert _class_overlap("комфорт", "бизнес") == 0.2

    def test_far_class_low(self) -> None:
        # эконом (0) ↔ премиум (5) = 5 шагов → far 0.05.
        assert _class_overlap("эконом", "премиум") == 0.05

    def test_unknown_class_is_none(self) -> None:
        # нераспознанный класс → ось НЕДОСТУПНА (None, НЕ 0).
        assert _class_overlap("комфорт", "абракадабра") is None
        assert _class_overlap(None, "комфорт") is None


class TestPriceBucketToBand:
    def test_economy_band(self) -> None:
        assert _price_bucket_to_band(PRICE_BUCKET_ECONOMY) == (0.0, 120_000.0)

    def test_comfort_band(self) -> None:
        assert _price_bucket_to_band(PRICE_BUCKET_COMFORT) == (120_000.0, 160_000.0)

    def test_business_band(self) -> None:
        assert _price_bucket_to_band(PRICE_BUCKET_BUSINESS) == (160_000.0, 220_000.0)

    def test_premium_band_open_right(self) -> None:
        band = _price_bucket_to_band(PRICE_BUCKET_PREMIUM)
        assert band is not None
        assert band[0] == 220_000.0
        assert band[1] == float("inf")

    def test_unknown_is_none(self) -> None:
        assert _price_bucket_to_band(None) is None
        assert _price_bucket_to_band("unknown") is None


class TestPriceOverlap:
    def test_full_containment_of_narrow_band(self) -> None:
        # кандидат [120k,160k] (40k), наш [100k,200k] (100k): пересечение 40k /
        # min-ширина 40k = 1.0 (узкая вилка полностью внутри широкой).
        assert _price_overlap((120_000.0, 160_000.0), 100_000.0, 200_000.0) == 1.0

    def test_partial_overlap(self) -> None:
        # кандидат [120k,160k] (40k), наш [140k,180k] (40k): пересечение [140k,160k]=20k
        # / min-ширина 40k = 0.5.
        assert _price_overlap((120_000.0, 160_000.0), 140_000.0, 180_000.0) == pytest.approx(0.5)

    def test_no_overlap_is_zero(self) -> None:
        # непересекающиеся вилки → 0.0 (валидно: нет ценовой конкуренции).
        assert _price_overlap((120_000.0, 160_000.0), 200_000.0, 240_000.0) == 0.0

    def test_swapped_own_bounds_handled(self) -> None:
        # наш min>max (грязь) — нормализуем порядок, результат тот же.
        assert _price_overlap((120_000.0, 160_000.0), 200_000.0, 140_000.0) == pytest.approx(0.5)

    def test_premium_both_open_right_full(self) -> None:
        # оба премиум (right=+inf): полное пересечение → 1.0.
        assert _price_overlap((220_000.0, float("inf")), 250_000.0, float("inf")) == 1.0

    def test_missing_candidate_band_is_none(self) -> None:
        assert _price_overlap(None, 100_000.0, 200_000.0) is None

    def test_missing_own_bound_is_none(self) -> None:
        # граница нашего проекта None → ось НЕДОСТУПНА (None, НЕ 0).
        assert _price_overlap((120_000.0, 160_000.0), None, 200_000.0) is None
        assert _price_overlap((120_000.0, 160_000.0), 100_000.0, None) is None

    def test_own_point_inside_candidate_is_one(self) -> None:
        # #1224: own_min==own_max (нулевая ширина — допустимо CHECK миграции 148 и
        # Pydantic own_project.py:75) ВНУТРИ вилки кандидата → 1.0, а не 0.0
        # (которое выдавал старый фильтр w>0). Полное накрытие узкого = 1.0 по
        # документированной семантике; разрыв [148k,152k]→1.0 vs [150k,150k]→0.0
        # устранён.
        assert _price_overlap((120_000.0, 160_000.0), 150_000.0, 150_000.0) == 1.0

    def test_own_point_on_candidate_boundary_is_one(self) -> None:
        # точка на границе вилки кандидата (lo<=hi включительно) — внутри → 1.0.
        assert _price_overlap((120_000.0, 160_000.0), 120_000.0, 120_000.0) == 1.0
        assert _price_overlap((120_000.0, 160_000.0), 160_000.0, 160_000.0) == 1.0

    def test_own_point_outside_candidate_is_zero(self) -> None:
        # точка ВНЕ вилки кандидата → 0.0 (нет ценовой конкуренции).
        assert _price_overlap((120_000.0, 160_000.0), 200_000.0, 200_000.0) == 0.0
        assert _price_overlap((120_000.0, 160_000.0), 100_000.0, 100_000.0) == 0.0

    def test_candidate_point_inside_own_is_one(self) -> None:
        # симметричный случай: вырожденная вилка кандидата (c_lo==c_hi) ВНУТРИ
        # вилки нашего проекта → 1.0.
        assert _price_overlap((150_000.0, 150_000.0), 120_000.0, 160_000.0) == 1.0

    def test_candidate_point_outside_own_is_zero(self) -> None:
        assert _price_overlap((200_000.0, 200_000.0), 120_000.0, 160_000.0) == 0.0

    def test_both_points_equal_is_one(self) -> None:
        # обе стороны — одна и та же точка → полное накрытие = 1.0.
        assert _price_overlap((150_000.0, 150_000.0), 150_000.0, 150_000.0) == 1.0

    def test_both_points_different_is_zero(self) -> None:
        # обе стороны — точки, но РАЗНЫЕ → 0.0.
        assert _price_overlap((150_000.0, 150_000.0), 200_000.0, 200_000.0) == 0.0


class TestUnitMixSimilarity:
    def test_identical_mix_is_one(self) -> None:
        mix = {"studio": 0.3, "1k": 0.4, "2k": 0.3}
        assert _unit_mix_similarity(mix, dict(mix)) == pytest.approx(1.0)

    def test_disjoint_mix_is_zero(self) -> None:
        # непересекающиеся ключи → L1=2 → 1−0.5·2 = 0.0.
        a = {"studio": 1.0}
        b = {"3k": 1.0}
        assert _unit_mix_similarity(a, b) == pytest.approx(0.0)

    def test_partial_similarity(self) -> None:
        # a={s:0.5,1k:0.5}, b={s:0.5,2k:0.5}: |0.5-0.5|+|0.5-0|+|0-0.5| = 1.0 →
        # 1−0.5·1.0 = 0.5.
        a = {"studio": 0.5, "1k": 0.5}
        b = {"studio": 0.5, "2k": 0.5}
        assert _unit_mix_similarity(a, b) == pytest.approx(0.5)

    def test_unnormalized_input_normalized(self) -> None:
        # доли не в сумме 1 (счётчики) — нормируются на свою сумму перед сравнением.
        a = {"studio": 30.0, "1k": 30.0}  # → 0.5/0.5
        b = {"studio": 1.0, "1k": 1.0}  # → 0.5/0.5
        assert _unit_mix_similarity(a, b) == pytest.approx(1.0)

    def test_missing_mix_is_none(self) -> None:
        assert _unit_mix_similarity(None, {"studio": 1.0}) is None
        assert _unit_mix_similarity({"studio": 1.0}, None) is None
        assert _unit_mix_similarity({}, {"studio": 1.0}) is None

    def test_in_range(self) -> None:
        v = _unit_mix_similarity({"studio": 0.7, "1k": 0.3}, {"studio": 0.2, "1k": 0.8})
        assert v is not None and 0.0 <= v <= 1.0

    def test_cross_vocabulary_alignment_recommend_vs_manual(self) -> None:
        # КЛЮЧЕВОЕ: кандидат из recommend_mix (RU-подписи _BUCKET_PRETTY) vs наш проект
        # (manual латиница own_planned_project). Без приведения ключей к канону L1=2 → 0;
        # с каноном идентичные доли по комнатности → 1.0.
        candidate = {"Студии 15-30": 0.3, "1-к 30-45": 0.4, "2-к 45-60": 0.3}
        own = {"studio": 0.3, "1k": 0.4, "2k": 0.3}
        assert _unit_mix_similarity(candidate, own) == pytest.approx(1.0)

    def test_cross_vocabulary_partial(self) -> None:
        # частичное совпадение через канон: студии совпали (0.5), остальное разъехалось.
        candidate = {"Студии 15-30": 0.5, "1-к 30-45": 0.5}
        own = {"студия": 0.5, "2к": 0.5}  # короткие RU-варианты ручного ввода
        # canon: candidate {studio:0.5,1k:0.5}, own {studio:0.5,2k:0.5} → L1=1.0 → 0.5.
        assert _unit_mix_similarity(candidate, own) == pytest.approx(0.5)

    def test_large_bucket_folds_to_4k_plus(self) -> None:
        # «80+ м²» (recommend_mix) ↔ «4k» (manual) оба → канон 4k+ → совпадают.
        assert _unit_mix_similarity({"80+ м²": 1.0}, {"4k": 1.0}) == pytest.approx(1.0)


class TestCanonicalRoomBucket:
    def test_recommend_mix_pretty_labels(self) -> None:
        # RU-подписи recommend_mix / _BUCKET_PRETTY → каноны комнатности.
        assert _canonical_room_bucket("Студии 15-30") == "studio"
        assert _canonical_room_bucket("1-к 30-45") == "1k"
        assert _canonical_room_bucket("2-к 45-60") == "2k"
        assert _canonical_room_bucket("3-к 60-80") == "3k"
        assert _canonical_room_bucket("80+ м²") == "4k+"

    def test_manual_latin_keys(self) -> None:
        # латиница own_planned_project (миграция 148).
        assert _canonical_room_bucket("studio") == "studio"
        assert _canonical_room_bucket("1k") == "1k"
        assert _canonical_room_bucket("4k") == "4k+"

    def test_short_ru_variants(self) -> None:
        assert _canonical_room_bucket("студия") == "studio"
        assert _canonical_room_bucket("1к") == "1k"

    def test_case_and_whitespace_insensitive(self) -> None:
        assert _canonical_room_bucket("  STUDIO  ") == "studio"
        assert _canonical_room_bucket("1-К 30-45") == "1k"

    def test_unknown_key_self_normalized_not_dropped(self) -> None:
        # неузнанный формат → нормализованный (lower+strip) ключ как есть (само-сопоставится).
        assert _canonical_room_bucket("Пентхаус") == "пентхаус"
        assert _canonical_room_bucket(" Loft ") == "loft"


class TestNormalizeSharesCanon:
    def test_folds_duplicate_canon_keys(self) -> None:
        # «studio»+«студия» схлопываются в один канон с суммой долей.
        out = _normalize_shares({"studio": 0.25, "студия": 0.25, "1k": 0.5})
        assert out is not None
        assert set(out.keys()) == {"studio", "1k"}
        assert out["studio"] == pytest.approx(0.5)
        assert out["1k"] == pytest.approx(0.5)

    def test_normalizes_to_sum_one(self) -> None:
        out = _normalize_shares({"Студии 15-30": 30.0, "1-к 30-45": 10.0})
        assert out is not None
        assert sum(out.values()) == pytest.approx(1.0)
        assert out["studio"] == pytest.approx(0.75)

    def test_keys_sorted_deterministic(self) -> None:
        out = _normalize_shares({"2k": 0.3, "studio": 0.3, "1k": 0.4})
        assert out is not None
        assert list(out.keys()) == sorted(out.keys())

    def test_nonpositive_and_none_dropped(self) -> None:
        out = _normalize_shares({"studio": 1.0, "1k": 0.0, "2k": -0.5})
        assert out is not None
        assert set(out.keys()) == {"studio"}

    def test_empty_or_all_invalid_is_none(self) -> None:
        assert _normalize_shares({}) is None
        assert _normalize_shares({"studio": 0.0, "1k": -1.0}) is None


class TestCandidateUnitMixFromRecommend:
    def test_extracts_bucket_share_pairs(self) -> None:
        # recommend_mix-форма: buckets=[{bucket, share_pct, …}] → {bucket: share}.
        result = {
            "buckets": [
                {"bucket": "Студии 15-30", "share_pct": 30.0, "deal_count": 100},
                {"bucket": "1-к 30-45", "share_pct": 45.0, "deal_count": 150},
                {"bucket": "2-к 45-60", "share_pct": 25.0, "deal_count": 80},
            ]
        }
        mix = _candidate_unit_mix_from_recommend(result)
        assert mix == {"Студии 15-30": 30.0, "1-к 30-45": 45.0, "2-к 45-60": 25.0}

    def test_skips_nonpositive_and_malformed(self) -> None:
        result = {
            "buckets": [
                {"bucket": "Студии 15-30", "share_pct": 50.0},
                {"bucket": "1-к 30-45", "share_pct": 0.0},  # 0 доля — пропуск
                {"bucket": "2-к 45-60"},  # нет share_pct — пропуск
                {"share_pct": 10.0},  # нет bucket — пропуск
                "garbage",  # не dict — пропуск
            ]
        }
        mix = _candidate_unit_mix_from_recommend(result)
        assert mix == {"Студии 15-30": 50.0}

    def test_empty_buckets_is_none(self) -> None:
        assert _candidate_unit_mix_from_recommend({"buckets": []}) is None

    def test_missing_buckets_key_is_none(self) -> None:
        # district unknown / degraded recommend_mix без списка buckets → None.
        assert _candidate_unit_mix_from_recommend({"scope": {"error": "x"}}) is None

    def test_buckets_not_list_is_none(self) -> None:
        assert _candidate_unit_mix_from_recommend({"buckets": "nope"}) is None

    def test_extracted_mix_feeds_similarity_against_manual_own(self) -> None:
        # end-to-end извлечения: extracted candidate ↔ manual own → ось похожести считается.
        result = {
            "buckets": [
                {"bucket": "Студии 15-30", "share_pct": 50.0},
                {"bucket": "1-к 30-45", "share_pct": 50.0},
            ]
        }
        candidate = _candidate_unit_mix_from_recommend(result)
        own = {"studio": 0.5, "1k": 0.5}
        assert _unit_mix_similarity(candidate, own) == pytest.approx(1.0)


class TestTimingOverlap:
    def test_same_month_full(self) -> None:
        m = date(2026, 6, 1)
        assert _timing_overlap(m, m) == pytest.approx(1.0)

    def test_half_life_is_half(self) -> None:
        # 12 мес расхождения = half_life → 0.5.
        a = date(2026, 6, 1)
        b = date(2027, 6, 1)
        assert _timing_overlap(a, b) == pytest.approx(0.5)

    def test_decays_with_distance(self) -> None:
        near = _timing_overlap(date(2026, 6, 1), date(2026, 9, 1))
        far = _timing_overlap(date(2026, 6, 1), date(2030, 6, 1))
        assert near is not None and far is not None
        assert near > far

    def test_symmetric(self) -> None:
        a, b = date(2026, 1, 1), date(2027, 1, 1)
        assert _timing_overlap(a, b) == _timing_overlap(b, a)

    def test_missing_date_is_none(self) -> None:
        assert _timing_overlap(None, date(2026, 6, 1)) is None
        assert _timing_overlap(date(2026, 6, 1), None) is None

    def test_in_range(self) -> None:
        v = _timing_overlap(date(2026, 6, 1), date(2028, 1, 1))
        assert v is not None and 0.0 <= v <= 1.0


class TestAddMonths:
    def test_within_year(self) -> None:
        assert _add_months(date(2026, 1, 1), 5) == date(2026, 6, 1)

    def test_year_boundary_oct_plus_six_is_apr_next_year(self) -> None:
        # Oct + 6 = Apr следующего года (явный year-boundary кейс из ТЗ).
        assert _add_months(date(2026, 10, 1), 6) == date(2027, 4, 1)

    def test_exactly_twelve_months_same_month_next_year(self) -> None:
        assert _add_months(date(2026, 3, 1), 12) == date(2027, 3, 1)

    def test_twenty_four_months_two_years(self) -> None:
        assert _add_months(date(2026, 6, 1), 24) == date(2028, 6, 1)

    def test_zero_months_normalizes_to_first_of_month(self) -> None:
        # +0 мес всё равно нормализует к 1-му числу (день отбрасывается).
        assert _add_months(date(2026, 6, 17), 0) == date(2026, 6, 1)

    def test_december_rollover(self) -> None:
        assert _add_months(date(2026, 12, 1), 1) == date(2027, 1, 1)

    def test_negative_months_go_back_across_year(self) -> None:
        assert _add_months(date(2026, 2, 1), -3) == date(2025, 11, 1)

    def test_result_is_always_first_of_month(self) -> None:
        for m in range(1, 25):
            assert _add_months(date(2026, 7, 23), m).day == 1


def _launch_window_index(horizon: int | None) -> SpecialIndex:
    """Готовый Launch Window SpecialIndex с заданным best_horizon_months (None → unavail)."""
    if horizon is None:
        return SpecialIndex(
            key=KEY_LAUNCH_WINDOW,
            value=None,
            label=None,
            confidence="low",
            detail={"reason": "deficit None на всех горизонтах"},
            method=_METHOD_UNAVAILABLE,
            advisory=True,
        )
    return SpecialIndex(
        key=KEY_LAUNCH_WINDOW,
        value=0.8,
        label=f"{horizon} мес",
        confidence="medium",
        detail={"best_horizon_months": horizon, "deficit_by_horizon": {}},
        method="deficit_peak_scan",
        advisory=True,
    )


class TestLaunchWindowHorizon:
    def test_reads_best_horizon(self) -> None:
        assert _launch_window_horizon(_launch_window_index(18)) == 18

    def test_unavailable_index_is_none(self) -> None:
        # Launch Window недоступен (нет best_horizon_months) → None (ось тайминга off).
        assert _launch_window_horizon(_launch_window_index(None)) is None


class TestCandidateReleaseMonth:
    def test_derives_as_of_plus_horizon(self) -> None:
        # дата отчёта + горизонт окна запуска = месяц выхода кандидата на рынок.
        m = _candidate_release_month(_launch_window_index(12), as_of=date(2026, 6, 9))
        assert m == date(2027, 6, 1)

    def test_horizon_crosses_year_boundary(self) -> None:
        # окт + 6 мес → апр следующего года (year-boundary в деривации тайминга).
        m = _candidate_release_month(_launch_window_index(6), as_of=date(2026, 10, 20))
        assert m == date(2027, 4, 1)

    def test_unavailable_launch_window_is_none(self) -> None:
        # Launch Window недоступен → release_month None → тайминговая ось исключается.
        assert _candidate_release_month(_launch_window_index(None), as_of=date(2026, 6, 9)) is None


class TestGeoWeight:
    def test_zero_distance_full_weight(self) -> None:
        assert _geo_weight(0.0) == pytest.approx(1.0)

    def test_decays_with_distance(self) -> None:
        near = _geo_weight(1.0)
        far = _geo_weight(10.0)
        assert near > far
        assert 0.0 < far < near <= 1.0

    def test_scale_km_at_one_e_inverse(self) -> None:
        # distance == scale → exp(-1) ≈ 0.368.
        import math

        assert _geo_weight(3.0) == pytest.approx(math.exp(-1.0))

    def test_unknown_distance_floor_weight(self) -> None:
        # нет координат → низкий floor (НЕ 0, НЕ 1): проект сигналит, но не доминирует.
        # #1633: понижено с 0.1 до 0.05 — старое значение 0.1 совпадало с весом
        # подтверждённо дальнего проекта (~6.9 км), создавая инверсию.
        assert _geo_weight(None) == pytest.approx(0.05)

    def test_negative_distance_clamped(self) -> None:
        assert _geo_weight(-5.0) == pytest.approx(1.0)

    def test_near_project_weight_exceeds_far_project_weight(self) -> None:
        # #1633: ближний проект ВСЕГДА перебивает дальний (монотонность).
        near = _geo_weight(1.0)
        far = _geo_weight(10.0)
        assert near > far

    def test_confirmed_far_project_weight_below_unknown(self) -> None:
        # #1633: подтверждённо дальний (>6.9 км) должен иметь вес НИЖЕ неизвестного.
        # До фикса: exp(-6.9/3)≈0.10 == _GEO_WEIGHT_UNKNOWN (0.10) — инверсия/паритет.
        # После фикса: _GEO_WEIGHT_UNKNOWN=0.05, exp(-6.9/3)≈0.10 > 0.05 (правильно).
        # А при ещё бо́льшем расстоянии (10 км, ≈0.036) — тем более выше 0.05.
        # Здесь проверяем что неизвестный вес НИЖЕ вполне подтверждённо БЛИЗКОГО (1 км),
        # и что подтверждённо ДАЛЬНИЙ (10 км) не превышает неизвестный (чтобы сигналил,
        # но скромно — оба низкие; инверсия устранена).
        import math

        unknown = _geo_weight(None)
        confirmed_near = _geo_weight(1.0)  # ≈ 0.72
        confirmed_far = _geo_weight(10.0)  # ≈ 0.036
        # confirmed_near >> unknown >> confirmed_far (правильная иерархия).
        assert confirmed_near > unknown
        assert unknown > confirmed_far
        # Числовая граница: exp(-6.9/3) ≈ 0.10 — вес "just-confirmed-far";
        # unknown (0.05) строго ниже этого порога.
        assert unknown < math.exp(-6.9 / 3.0)


# ──────────────────────────────────────────────────────────────────────────────
# §25.3 own-portfolio overlap — пара (среднее доступных осей) + агрегация (soft-max)
# ──────────────────────────────────────────────────────────────────────────────


class TestOwnPortfolioOverlapPair:
    def test_averages_available_axes(self) -> None:
        # доступны class=0.8, price=0.4 → среднее 0.6; signal = 0.6 × geo 0.5 = 0.3.
        pair = _own_portfolio_overlap(
            class_overlap=0.8,
            price_overlap=0.4,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=0.5,
        )
        assert pair.overlap == pytest.approx(0.6)
        assert pair.n_axes == 2
        assert pair.signal == pytest.approx(0.3)

    def test_none_axes_excluded_not_zeroed(self) -> None:
        # только class=1.0 доступен (остальные None) → overlap=1.0 (НЕ размыт нулями).
        pair = _own_portfolio_overlap(
            class_overlap=1.0,
            price_overlap=None,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=1.0,
        )
        assert pair.overlap == pytest.approx(1.0)
        assert pair.n_axes == 1

    def test_no_axes_yields_none(self) -> None:
        # ни одной оси → overlap/signal None (пара неинформативна, НЕ 0).
        pair = _own_portfolio_overlap(
            class_overlap=None,
            price_overlap=None,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=1.0,
        )
        assert pair.overlap is None
        assert pair.signal is None
        assert pair.n_axes == 0

    def test_all_four_axes(self) -> None:
        pair = _own_portfolio_overlap(
            class_overlap=1.0,
            price_overlap=0.5,
            unit_mix_overlap=0.5,
            timing_overlap=0.0,
            geo_weight=1.0,
        )
        assert pair.overlap == pytest.approx(0.5)
        assert pair.n_axes == 4


class TestAggregateOverlap:
    def test_takes_max_signal_not_mean(self) -> None:
        # один сильный (0.9) + два слабых (0.1) → агрегат = 0.9 (soft-max), не среднее.
        strong = _own_portfolio_overlap(
            class_overlap=0.9,
            price_overlap=0.9,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=1.0,
        )
        weak1 = _own_portfolio_overlap(
            class_overlap=0.1,
            price_overlap=0.1,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=1.0,
        )
        weak2 = _own_portfolio_overlap(
            class_overlap=0.1,
            price_overlap=0.1,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=1.0,
        )
        assert _aggregate_overlap([strong, weak1, weak2]) == pytest.approx(0.9)

    def test_geo_weight_attenuates_distant_strong_overlap(self) -> None:
        # сильное пересечение, но далеко (geo 0.1) → signal 0.05; близкое слабое
        # (overlap 0.3, geo 1.0 → 0.3) перебивает. Агрегат = 0.3.
        distant_strong = _own_portfolio_overlap(
            class_overlap=1.0,
            price_overlap=1.0,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=0.1,
        )
        near_weak = _own_portfolio_overlap(
            class_overlap=0.3,
            price_overlap=0.3,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=1.0,
        )
        assert _aggregate_overlap([distant_strong, near_weak]) == pytest.approx(0.3)

    def test_all_none_signals_is_none(self) -> None:
        empty = _own_portfolio_overlap(
            class_overlap=None,
            price_overlap=None,
            unit_mix_overlap=None,
            timing_overlap=None,
            geo_weight=1.0,
        )
        assert _aggregate_overlap([empty, empty]) is None

    def test_empty_is_none(self) -> None:
        assert _aggregate_overlap([]) is None


class TestHaversine:
    def test_zero_distance(self) -> None:
        assert _haversine_km(60.6, 56.8, 60.6, 56.8) == pytest.approx(0.0, abs=1e-9)

    def test_known_distance_ekb_scale(self) -> None:
        # ~1 км по долготе на широте ЕКБ (56.8°): 0.0164° lon ≈ 1 км. Допуск широкий.
        d = _haversine_km(60.6, 56.8, 60.6164, 56.8)
        assert 0.9 < d < 1.1

    def test_symmetric(self) -> None:
        a = _haversine_km(60.6, 56.8, 60.7, 56.9)
        b = _haversine_km(60.7, 56.9, 60.6, 56.8)
        assert a == pytest.approx(b)


# ──────────────────────────────────────────────────────────────────────────────
# Pure: Artificial Demand (доля ипотеки)
# ──────────────────────────────────────────────────────────────────────────────


class TestArtificialDemandShare:
    def test_basic_share(self) -> None:
        assert _artificial_demand_share(30, 100) == pytest.approx(0.3)

    def test_full_mortgage(self) -> None:
        assert _artificial_demand_share(50, 50) == 1.0

    def test_zero_mortgage(self) -> None:
        assert _artificial_demand_share(0, 80) == 0.0

    def test_no_sold_is_none(self) -> None:
        # нет проданных лотов → None (НЕ фабрикуем 0).
        assert _artificial_demand_share(0, 0) is None

    def test_none_mortgage_is_none(self) -> None:
        assert _artificial_demand_share(None, 50) is None

    def test_clamped_to_one_on_dirty_data(self) -> None:
        # грязные данные (mortgage > sold) → clamp 1.0, не >1.
        assert _artificial_demand_share(120, 100) == 1.0

    def test_in_range(self) -> None:
        v = _artificial_demand_share(17, 23)
        assert v is not None and 0.0 <= v <= 1.0


# ──────────────────────────────────────────────────────────────────────────────
# Pure: Cost-of-Error (монотонность + произведение + чек)
# ──────────────────────────────────────────────────────────────────────────────


class TestAvgTicket:
    def test_price_times_area(self) -> None:
        # 200_000 ₽/м² × 50 м² = 10 млн.
        assert _avg_ticket_rub(200_000.0) == 10_000_000.0

    def test_none_price_is_none(self) -> None:
        assert _avg_ticket_rub(None) is None

    def test_nonpositive_price_is_none(self) -> None:
        assert _avg_ticket_rub(0.0) is None

    def test_custom_area(self) -> None:
        assert _avg_ticket_rub(100_000.0, area_m2=80.0) == 8_000_000.0


class TestOversupplyRiskFromDeficit:
    def test_negative_deficit_is_magnitude(self) -> None:
        assert _oversupply_risk_from_deficit(-0.4) == pytest.approx(0.4)

    def test_positive_deficit_zero_risk(self) -> None:
        assert _oversupply_risk_from_deficit(0.6) == 0.0

    def test_zero_deficit_zero_risk(self) -> None:
        assert _oversupply_risk_from_deficit(0.0) == 0.0

    def test_none_is_none(self) -> None:
        assert _oversupply_risk_from_deficit(None) is None


class TestCostOfErrorIndex:
    def test_product_of_risk_and_ticket(self) -> None:
        # risk 0.5 × (10млн/15млн clamp=0.6667) ≈ 0.3333.
        v = _cost_of_error_index(0.5, 10_000_000.0)
        assert v == pytest.approx(0.5 * (10_000_000.0 / 15_000_000.0))

    def test_monotonic_in_risk(self) -> None:
        low = _cost_of_error_index(0.2, 10_000_000.0)
        high = _cost_of_error_index(0.8, 10_000_000.0)
        assert low is not None and high is not None
        assert high > low

    def test_monotonic_in_ticket(self) -> None:
        cheap = _cost_of_error_index(0.5, 5_000_000.0)
        pricey = _cost_of_error_index(0.5, 12_000_000.0)
        assert cheap is not None and pricey is not None
        assert pricey > cheap

    def test_in_range(self) -> None:
        v = _cost_of_error_index(0.9, 30_000_000.0)  # ticket clamps to 1.0
        assert v is not None and 0.0 <= v <= 1.0
        assert v == pytest.approx(0.9)

    def test_none_risk_is_none(self) -> None:
        assert _cost_of_error_index(None, 10_000_000.0) is None

    def test_none_ticket_is_none(self) -> None:
        assert _cost_of_error_index(0.5, None) is None

    def test_nonpositive_norm_degrades_to_risk_only(self) -> None:
        v = _cost_of_error_index(0.4, 10_000_000.0, ticket_norm_rub=0.0)
        assert v == pytest.approx(0.4)


# ──────────────────────────────────────────────────────────────────────────────
# Artificial-Demand SQL: MagicMock-сессия (форма SQL + параметры + сигнал/деградация)
# ──────────────────────────────────────────────────────────────────────────────


def _mock_db_one(row: dict[str, Any] | None) -> MagicMock:
    """Сессия, чей единственный execute().mappings().first() вернёт row."""
    db = MagicMock()
    result = MagicMock()
    result.mappings.return_value.first.return_value = row
    db.execute.return_value = result
    return db


def _executed_sql(db: MagicMock, call_index: int = 0) -> str:
    args, _ = db.execute.call_args_list[call_index]
    return str(args[0])


def _executed_params(db: MagicMock, call_index: int = 0) -> dict[str, Any]:
    args, _ = db.execute.call_args_list[call_index]
    return args[1]


# Patch-таргет резолвера admin→micro (#1205 — fix зеркалит sales_series/market_metrics):
# `_query_artificial_demand` зовёт `resolve_objective_districts` ДО SQL. В shape/params
# тестах патчим резолвер identity-обёрткой (raw→[raw], None→None), чтобы _mock_db_one
# остался валиден (1 execute). Отдельные тесты переопределяют side_effect для проверки
# admin→micros разворота.
_RESOLVE_AD = f"{_MOD}.resolve_objective_districts"


@pytest.fixture
def _identity_resolver() -> Any:
    return patch(_RESOLVE_AD, side_effect=lambda _db, d: [d] if d is not None else None)


class TestArtificialDemandSQL:
    def test_sql_uses_cast_not_double_colon(self, _identity_resolver: Any) -> None:
        db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
        with _identity_resolver:
            _query_artificial_demand(
                db, district="Академический", obj_class="комфорт", premise_kind="квартира"
            )
        sql = _executed_sql(db)
        assert "CAST(:premise_kind AS text)" in sql
        # #1205: district-фильтр теперь через has_district + districts (admin→micros резолв).
        assert "CAST(:has_district AS boolean)" in sql
        assert "CAST(:districts AS text[])" in sql
        assert "CAST(:obj_class AS text)" in sql
        # psycopg v3: никогда :x::type
        assert ":premise_kind::" not in sql
        assert ":has_district::" not in sql
        assert ":districts::" not in sql
        assert ":obj_class::" not in sql

    def test_sql_reads_mortgage_signal_columns(self, _identity_resolver: Any) -> None:
        db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
        with _identity_resolver:
            _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
        sql = _executed_sql(db)
        # сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
        assert "encumbrance_type" in sql
        assert "bank_name" in sql
        assert "objective_lots" in sql

    def test_params_passed(self, _identity_resolver: Any) -> None:
        db = _mock_db_one({"n_sold": 1, "n_mortgage": 0})
        with _identity_resolver:
            _query_artificial_demand(
                db, district="Пионерский", obj_class="бизнес", premise_kind="квартира"
            )
        p = _executed_params(db)
        # #1205: identity-резолвер → districts=[district], has_district=True.
        assert p["has_district"] is True
        assert p["districts"] == ["Пионерский"]
        assert p["obj_class"] == "бизнес"
        assert p["premise_kind"] == "квартира"
        # Старый сырый `district` bind больше НЕ передаётся (вокабуляр-фикс #1205).
        assert "district" not in p

    def test_signal_present_returns_counts(self, _identity_resolver: Any) -> None:
        db = _mock_db_one({"n_sold": 80, "n_mortgage": 52})
        with _identity_resolver:
            out = _query_artificial_demand(
                db, district=None, obj_class=None, premise_kind="квартира"
            )
        assert out == {"n_sold": 80, "n_mortgage": 52}

    def test_empty_row_degrades_to_zeros(self, _identity_resolver: Any) -> None:
        db = _mock_db_one(None)
        with _identity_resolver:
            out = _query_artificial_demand(
                db, district=None, obj_class=None, premise_kind="квартира"
            )
        assert out == {"n_sold": 0, "n_mortgage": 0}

    def test_null_counts_coerced_to_zero(self, _identity_resolver: Any) -> None:
        db = _mock_db_one({"n_sold": None, "n_mortgage": None})
        with _identity_resolver:
            out = _query_artificial_demand(
                db, district=None, obj_class=None, premise_kind="квартира"
            )
        assert out == {"n_sold": 0, "n_mortgage": 0}

    def test_none_district_disables_filter(self, _identity_resolver: Any) -> None:
        # #1205: district=None → has_district=False, districts=[]. EKB-wide.
        db = _mock_db_one({"n_sold": 200, "n_mortgage": 80})
        with _identity_resolver:
            _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
        p = _executed_params(db)
        assert p["has_district"] is False
        assert p["districts"] == []


class TestArtificialDemandDistrictResolution:
    """#1205 — admin 'Кировский' разворачивается в МИКРО через resolve_objective_districts.

    `objective_lots.district` хранит микро-вокабуляр ('Уралмаш', 'ЖБИ', ...). Без
    резолва SQL фильтровал ol.district = 'Кировский' → 0 строк → ложное 'unavailable'
    в каждом district-scoped отчёте. Тот же класс бага, что #1211 (_price_sensitivity).
    """

    def test_admin_input_invokes_resolver(self) -> None:
        # admin → resolver вызван с теми же db, district.
        db = _mock_db_one({"n_sold": 50, "n_mortgage": 20})
        with patch(_RESOLVE_AD, return_value=["Уралмаш", "ЖБИ", "Эльмаш"]) as m_resolve:
            _query_artificial_demand(
                db, district="Кировский", obj_class=None, premise_kind="квартира"
            )
        m_resolve.assert_called_once()
        call_args, _ = m_resolve.call_args
        assert call_args[0] is db
        assert call_args[1] == "Кировский"

    def test_resolved_micros_land_in_sql_bind(self) -> None:
        # Резолвнутые микро попадают в :districts; has_district=True.
        db = _mock_db_one({"n_sold": 50, "n_mortgage": 20})
        with patch(_RESOLVE_AD, return_value=["Уралмаш", "ЖБИ", "Эльмаш"]):
            _query_artificial_demand(
                db, district="Кировский", obj_class=None, premise_kind="квартира"
            )
        p = _executed_params(db)
        assert p["has_district"] is True
        assert p["districts"] == ["Уралмаш", "ЖБИ", "Эльмаш"]
        # Сырое admin-имя НЕ протекает в SQL (regression-guard #1205).
        assert "Кировский" not in p.get("districts", [])

    def test_admin_yields_nonzero_sold_after_resolve(self) -> None:
        # Сценарий бага: admin 'Кировский' → раньше 0 строк (фильтр по admin-имени) →
        # 'unavailable'. После фикса: резолвер развернул в микро, SQL вернул n_sold>0.
        db = _mock_db_one({"n_sold": 120, "n_mortgage": 75})
        with patch(_RESOLVE_AD, return_value=["Уралмаш", "Эльмаш"]):
            out = _query_artificial_demand(
                db, district="Кировский", obj_class=None, premise_kind="квартира"
            )
        assert out["n_sold"] == 120
        assert out["n_mortgage"] == 75

    def test_unresolved_admin_degrades_to_ekb_wide(self) -> None:
        # Резолвер вернул None (admin без чистых микро / 'не определён') → has_district=False.
        db = _mock_db_one({"n_sold": 300, "n_mortgage": 110})
        with patch(_RESOLVE_AD, return_value=None):
            _query_artificial_demand(
                db, district="не определён", obj_class=None, premise_kind="квартира"
            )
        p = _executed_params(db)
        assert p["has_district"] is False
        assert p["districts"] == []


# ──────────────────────────────────────────────────────────────────────────────
# Artificial-Demand builder via mocked rows: signal → index; no signal → None+caveat
# ──────────────────────────────────────────────────────────────────────────────


_SPEC = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический")


def _patch_all_unavailable() -> Any:
    """Контекст: все 5 НЕ-Artificial бэкенда брошены/пусты (изолируем Artificial)."""
    return patch.multiple(
        _MOD,
        compute_demand_supply_forecast=MagicMock(return_value=[]),
        rank_segments=MagicMock(return_value=MagicMock(ranked=[], n_cells_ranked=0)),
        get_competitors=MagicMock(side_effect=ValueError("no geom")),
        compute_market_metrics=MagicMock(
            return_value=MagicMock(overstock_index=None, confidence="low")
        ),
        compute_affordability=MagicMock(return_value=MagicMock(price_per_m2=None)),
    )


class TestArtificialDemandBuilder:
    def test_signal_present_yields_index(self) -> None:
        db = MagicMock()
        with (
            _patch_all_unavailable(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 65},
            ),
        ):
            card = compute_special_indices(db, spec=_SPEC, district="Академический")
        idx = card.indices[KEY_ARTIFICIAL_DEMAND]
        assert idx.value == pytest.approx(0.65)
        assert idx.method == "mortgage_share_objective_lots"
        assert idx.detail["n_sold"] == 100
        assert idx.detail["n_mortgage"] == 65
        assert idx.advisory is True

    def test_no_signal_yields_none_with_caveat(self) -> None:
        db = MagicMock()
        with (
            _patch_all_unavailable(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 0, "n_mortgage": 0},
            ),
        ):
            card = compute_special_indices(db, spec=_SPEC, district="Академический")
        idx = card.indices[KEY_ARTIFICIAL_DEMAND]
        assert idx.value is None
        assert idx.method == _METHOD_UNAVAILABLE
        assert "reason" in idx.detail
        assert "фабрикуем" in idx.detail["reason"]  # явный caveat «не фабрикуем»

    def test_small_sample_low_confidence(self) -> None:
        db = MagicMock()
        with (
            _patch_all_unavailable(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 5, "n_mortgage": 3},
            ),
        ):
            card = compute_special_indices(db, spec=_SPEC, district="Академический")
        idx = card.indices[KEY_ARTIFICIAL_DEMAND]
        assert idx.value == pytest.approx(0.6)
        assert idx.confidence == "low"  # n_sold < 30


# ──────────────────────────────────────────────────────────────────────────────
# compute_special_indices: @patch бэкенда — 6 индексов, advisory, graceful per-index
# ──────────────────────────────────────────────────────────────────────────────


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


def _ranked_stub(deficit: float, *, confidence: str = "medium") -> MagicMock:
    seg = MagicMock()
    seg.deficit_index = deficit
    seg.confidence = confidence
    seg.as_dict.return_value = {"deficit_index": deficit}
    return seg


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


def _full_stack_patch() -> Any:
    """Все 5 бэкенд-сервисов отдают здоровые данные → все 6 индексов считаются."""
    forecasts = [_forecast_stub(0.1 * h, horizon=h) for h in (6, 12, 18, 24)]
    ranking = MagicMock()
    ranking.ranked = [_ranked_stub(0.5), _ranked_stub(0.3), _ranked_stub(-0.1)]
    ranking.n_cells_ranked = 3
    competitors_resp = MagicMock()
    competitors_resp.competitors = [
        _competitor_stub(0.8, "комфорт"),
        _competitor_stub(0.6, "комфорт"),
        _competitor_stub(0.5, "бизнес"),
    ]
    metrics = MagicMock()
    metrics.overstock_index = 0.4
    metrics.confidence = "medium"
    afford = MagicMock()
    afford.price_per_m2 = 200_000.0
    return patch.multiple(
        _MOD,
        compute_demand_supply_forecast=MagicMock(return_value=forecasts),
        rank_segments=MagicMock(return_value=ranking),
        get_competitors=MagicMock(return_value=competitors_resp),
        compute_market_metrics=MagicMock(return_value=metrics),
        compute_affordability=MagicMock(return_value=afford),
    )


class TestComputeSpecialIndicesShape:
    def test_all_six_indices_present(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        assert set(card.indices.keys()) == set(_INDEX_KEYS)
        assert len(card.indices) == 6

    def test_advisory_always_true(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        assert card.advisory is True
        assert all(idx.advisory is True for idx in card.indices.values())

    def test_all_values_in_range_when_present(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        for idx in card.indices.values():
            if idx.value is not None:
                assert 0.0 <= idx.value <= 1.0, idx.key

    def test_confidence_capped_at_medium(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        assert card.confidence in ("low", "medium")  # никогда high
        for idx in card.indices.values():
            assert idx.confidence in ("low", "medium")

    def test_as_dict_roundtrip(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        d = card.as_dict()
        assert d["advisory"] is True
        assert set(d["indices"].keys()) == set(_INDEX_KEYS)
        assert d["district"] == "Академический"
        # каждый индекс сериализуется с ключами контракта.
        for sub in d["indices"].values():
            assert set(sub.keys()) == {
                "key",
                "value",
                "label",
                "confidence",
                "detail",
                "method",
                "advisory",
            }


class TestComputeSpecialIndicesValues:
    def test_launch_window_picks_peak_horizon(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        lw = card.indices[KEY_LAUNCH_WINDOW]
        # deficit = 0.1*h → пик на h=24 (0.1*24=2.4 clamps), label «24 мес».
        assert lw.label == "24 мес"
        assert lw.detail["best_horizon_months"] == 24

    def test_product_void_counts_segments(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        pv = card.indices[KEY_PRODUCT_VOID]
        # ranked deficits [0.5,0.3,-0.1]: 2 ≥ 0.25 → share 2/3.
        assert pv.value == pytest.approx(2 / 3)
        assert pv.detail["n_void"] == 2

    def test_competitor_strength_mean_top_n(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        cs = card.indices[KEY_COMPETITOR_STRENGTH]
        # relevance [0.8,0.6,0.5] → mean 0.6333.
        assert cs.value == pytest.approx((0.8 + 0.6 + 0.5) / 3)

    def test_cannibalization_same_class_share(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        can = card.indices[KEY_CANNIBALIZATION]
        # same-class (комфорт) = 0.8+0.6=1.4; all=1.9 → 0.7368.
        assert can.value == pytest.approx((0.8 + 0.6) / (0.8 + 0.6 + 0.5))

    def test_cost_of_error_product(self) -> None:
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        coe = card.indices[KEY_COST_OF_ERROR]
        # risk 0.4 × ticket(200k×50=10млн / 15млн = 0.6667) ≈ 0.2667.
        ticket_factor = (200_000.0 * 50.0) / 15_000_000.0
        assert coe.value == pytest.approx(0.4 * ticket_factor)
        assert coe.detail["risk_source"] == "overstock_index"


class TestComputeSpecialIndicesGraceful:
    def test_no_cad_num_degrades_competitor_indices(self) -> None:
        # без cad_num: Cannibalization + Competitor Strength → unavailable, остальные ок.
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(db, spec=_SPEC, district="Академический")
        assert card.indices[KEY_CANNIBALIZATION].method == _METHOD_UNAVAILABLE
        assert card.indices[KEY_COMPETITOR_STRENGTH].method == _METHOD_UNAVAILABLE
        assert card.indices[KEY_CANNIBALIZATION].value is None
        # quick-win Artificial Demand всё равно посчитан.
        assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.4)

    def test_per_index_failure_isolated(self) -> None:
        # один сервис (rank_segments) бросает → Product Void unavailable, остальные 5 ок.
        forecasts = [_forecast_stub(0.2, horizon=h) for h in (6, 12, 18, 24)]
        metrics = MagicMock(overstock_index=0.3, confidence="medium")
        afford = MagicMock(price_per_m2=180_000.0)
        comp = MagicMock()
        comp.competitors = [_competitor_stub(0.7, "комфорт")]
        db = MagicMock()
        with (
            patch.multiple(
                _MOD,
                compute_demand_supply_forecast=MagicMock(return_value=forecasts),
                rank_segments=MagicMock(side_effect=RuntimeError("boom")),
                get_competitors=MagicMock(return_value=comp),
                compute_market_metrics=MagicMock(return_value=metrics),
                compute_affordability=MagicMock(return_value=afford),
            ),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 50, "n_mortgage": 20},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        # сбойный индекс — unavailable; карточка цела (все 6 ключей).
        assert card.indices[KEY_PRODUCT_VOID].method == _METHOD_UNAVAILABLE
        assert card.indices[KEY_PRODUCT_VOID].value is None
        assert len(card.indices) == 6
        # остальные посчитаны.
        assert card.indices[KEY_LAUNCH_WINDOW].value is not None
        assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.4)
        assert card.advisory is True

    def test_all_backends_fail_card_still_returns(self) -> None:
        # тотальный сбой: каждый индекс unavailable, но карточка возвращается (не crash).
        db = MagicMock()
        with (
            patch.multiple(
                _MOD,
                compute_demand_supply_forecast=MagicMock(side_effect=RuntimeError("x")),
                rank_segments=MagicMock(side_effect=RuntimeError("x")),
                get_competitors=MagicMock(side_effect=RuntimeError("x")),
                compute_market_metrics=MagicMock(side_effect=RuntimeError("x")),
                compute_affordability=MagicMock(side_effect=RuntimeError("x")),
            ),
            patch(
                f"{_MOD}._query_artificial_demand",
                side_effect=RuntimeError("x"),
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        assert len(card.indices) == 6
        assert all(idx.method == _METHOD_UNAVAILABLE for idx in card.indices.values())
        assert all(idx.value is None for idx in card.indices.values())
        assert card.advisory is True
        assert card.confidence == "low"

    def test_launch_window_all_none_degrades(self) -> None:
        # deficit None на всех горизонтах → Launch Window unavailable.
        forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(_DSF, return_value=forecasts),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        assert card.indices[KEY_LAUNCH_WINDOW].method == _METHOD_UNAVAILABLE
        assert card.indices[KEY_LAUNCH_WINDOW].value is None

    def test_cost_of_error_falls_back_to_negative_deficit(self) -> None:
        # overstock_index None → Cost-of-Error берёт магнитуду отрицательного дефицита.
        metrics = MagicMock(overstock_index=None, confidence="medium")
        afford = MagicMock(price_per_m2=200_000.0)
        # forecast на _VOID_HORIZON_MONTHS=12 (fallback) с отрицательным дефицитом.
        forecasts = [_forecast_stub(-0.5, horizon=12)]
        comp = MagicMock()
        comp.competitors = [_competitor_stub(0.7, "комфорт")]
        db = MagicMock()
        with (
            patch.multiple(
                _MOD,
                compute_demand_supply_forecast=MagicMock(return_value=forecasts),
                rank_segments=MagicMock(return_value=MagicMock(ranked=[], n_cells_ranked=0)),
                get_competitors=MagicMock(return_value=comp),
                compute_market_metrics=MagicMock(return_value=metrics),
                compute_affordability=MagicMock(return_value=afford),
            ),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 50, "n_mortgage": 20},
            ),
        ):
            card = compute_special_indices(
                db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        coe = card.indices[KEY_COST_OF_ERROR]
        assert coe.detail["risk_source"] == "negative_deficit"
        # risk |−0.5|=0.5 × ticket(10млн/15млн=0.6667) ≈ 0.3333.
        assert coe.value == pytest.approx(0.5 * ((200_000.0 * 50.0) / 15_000_000.0))

    def test_aggregate_spec_no_class_param_is_none(self) -> None:
        # spec без obj_class → Artificial-Demand SQL получает obj_class=None (агрегат).
        db = MagicMock()
        captured: dict[str, Any] = {}

        def _capture(_db: Any, *, district: Any, obj_class: Any, premise_kind: Any) -> dict:
            captured["obj_class"] = obj_class
            captured["district"] = district
            return {"n_sold": 40, "n_mortgage": 10}

        spec_no_class = SegmentSpec(room_bucket="2-к 45-60")
        with _full_stack_patch(), patch(f"{_MOD}._query_artificial_demand", side_effect=_capture):
            card = compute_special_indices(db, spec=spec_no_class, district=None)
        assert captured["obj_class"] is None
        assert captured["district"] is None
        assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.25)


# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #3, `_run` внутри `compute_special_indices`):
# сбой ОДНОГО §25-индекса НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ
# индексов/слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
# сбойный `db.execute` (внутри `_query_artificial_demand`) оставляет транзакцию
# Postgres aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────


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

    def mappings(self) -> _FakeIndicesResult:
        return self

    def first(self) -> None:
        return None


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

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

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

    def execute(self, *args: Any, **kwargs: Any) -> _FakeIndicesResult:
        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 artificial_demand query failure")
        return _FakeIndicesResult()

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


class TestRunSavepointDoesNotPoisonSession:
    """`_run` (внутренний gate compute_special_indices) оборачивает builder в SAVEPOINT."""

    def test_artificial_demand_db_failure_leaves_session_usable(self) -> None:
        db = _PoisonableSession()
        # district=None/cad_num=None → Cannibalization/Competitor Strength деградируют
        # БЕЗ обращения к db (см. test_no_cad_num_degrades_competitor_indices); own
        # portfolio патчим в [] (own_developer_ids-гейт не задействуем) — единственный
        # builder, реально трогающий db в этом тесте, это Artificial Demand.
        with _full_stack_patch(), patch(f"{_MOD}.get_own_portfolio", return_value=[]):
            card = compute_special_indices(db, spec=_SPEC, district=None, cad_num=None)
        # упавший индекс деградировал в fallback (НЕ крашнул карточку).
        assert card.indices[KEY_ARTIFICIAL_DEMAND].method == _METHOD_UNAVAILABLE
        assert card.indices[KEY_ARTIFICIAL_DEMAND].value is None
        assert len(card.indices) == 6
        # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
        # сбойного индекса проходит без 'current transaction aborted' (не poisoned).
        db.execute("SELECT 1")


# ──────────────────────────────────────────────────────────────────────────────
# RELEASE-SAVEPOINT trap (#2464 cluster A, second-order): `_query_parcel_centroid` —
# ВНУТРЕННИЙ helper, зовётся из `_build_cannibalization` ПОД внешним SAVEPOINT
# `_run`. Если он глотает db-сбой БЕЗ своего savepoint, tx остаётся aborted; внешний
# `_run`-savepoint выходит «нормально» → RELEASE SAVEPOINT → RELEASE в aborted-tx САМ
# падает (Postgres допускает лишь ROLLBACK / ROLLBACK TO SAVEPOINT) → внешний except
# ловит RELEASE-ошибку, но tx не откачена → отравление каскадит в следующий индекс.
# Тест воспроизводит ИМЕННО RELEASE-trap: `_ReleaseTrapSession` моделирует, что
# RELEASE в aborted-tx падает (без внутреннего savepoint тест краснеет).
# ──────────────────────────────────────────────────────────────────────────────


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

    def mappings(self) -> _FakeReleaseResult:
        return self

    def first(self) -> None:
        return None


class _ReleaseTrapSession:
    """Fake Session, моделирующая Postgres SAVEPOINT/RELEASE/ROLLBACK в aborted-tx.

    `execute()` бросает РОВНО раз (помечает tx aborted). `begin_nested()`:
      • ВХОД в poisoned-tx → SAVEPOINT запрещён → бросает;
      • ВЫХОД с исключением → ROLLBACK TO SAVEPOINT (снимает poison), пробрасывает;
      • ВЫХОД нормально → RELEASE SAVEPOINT, и если poisoned — RELEASE ПАДАЕТ (trap).
    """

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

    def execute(self, *args: Any, **kwargs: Any) -> _FakeReleaseResult:
        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 centroid query failure")
        return _FakeReleaseResult()

    @contextlib.contextmanager
    def begin_nested(self):  # type: ignore[no-untyped-def]
        if self.poisoned:
            raise RuntimeError(
                "current transaction is aborted, commands ignored until end of transaction block"
            )
        try:
            yield
        except Exception:
            self.poisoned = False  # ROLLBACK TO SAVEPOINT
            raise
        else:
            if self.poisoned:
                # RELEASE SAVEPOINT в aborted-tx ПАДАЕТ — тот самый trap.
                raise RuntimeError(
                    "current transaction is aborted, "
                    "commands ignored until end of transaction block"
                )


def _outer_savepoint_call(db: Any, fn: Any, *, fallback: Any) -> Any:
    """Зеркало `_run`/`_safe_call`: fn() под ВНЕШНИМ savepoint (RELEASE на норм-выходе)."""
    try:
        with db.begin_nested():
            return fn()
    except Exception:
        return fallback


class TestReleaseSavepointTrapParcelCentroid:
    """`_query_parcel_centroid` НЕ отравляет внешний SAVEPOINT (RELEASE-trap)."""

    def test_centroid_failure_survives_outer_release(self) -> None:
        db = _ReleaseTrapSession()  # первый execute (centroid) падает
        result = _outer_savepoint_call(
            db,
            lambda: _query_parcel_centroid(db, cad_num="66:41:0303161:123"),
            fallback="OUTER_FALLBACK",
        )
        # centroid проглочен → его fallback (None), НЕ внешний OUTER_FALLBACK: значит
        # внутренний savepoint откатил сбой и внешний RELEASE прошёл чисто.
        assert result is None
        # КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
        db.execute("SELECT 1")


# ──────────────────────────────────────────────────────────────────────────────
# §25.3 Cannibalization dispatch — TRUE own-portfolio vs PROXY fallback
# ──────────────────────────────────────────────────────────────────────────────

# Центроид участка для гео-веса (lon, lat) в окрестностях ЕКБ.
_CENTROID = (60.6000, 56.8000)
_CAND_SPEC = SegmentSpec(
    obj_class="комфорт",
    room_bucket="2-к 45-60",
    district="Академический",
    price_bucket=PRICE_BUCKET_COMFORT,
)


def _own(
    name: str,
    *,
    source: str = "future",
    obj_class: str | None = "комфорт",
    price_min: float | None = 120_000.0,
    price_max: float | None = 160_000.0,
    unit_mix: dict[str, float] | None = None,
    release_month: date | None = None,
    lon: float | None = None,
    lat: float | None = None,
) -> OwnProject:
    return OwnProject(
        name=name,
        source=source,  # type: ignore[arg-type]
        obj_class=obj_class,
        release_month=release_month,
        price_min_per_m2=price_min,
        price_max_per_m2=price_max,
        unit_mix=unit_mix,
        district="Академический",
        lon=lon,
        lat=lat,
    )


def _cannibalization_card(
    portfolio: list[OwnProject],
    *,
    centroid: tuple[float, float] | None = _CENTROID,
    cad_num: str | None = "66:41:0303161:123",
) -> Any:
    """Прогнать compute_special_indices с замоканными own-portfolio + центроидом."""
    db = MagicMock()
    with (
        _full_stack_patch(),
        patch(f"{_MOD}.get_own_portfolio", return_value=portfolio),
        patch(f"{_MOD}._query_parcel_centroid", return_value=centroid),
        patch(
            f"{_MOD}._query_artificial_demand",
            return_value={"n_sold": 100, "n_mortgage": 40},
        ),
    ):
        return compute_special_indices(
            db, spec=_CAND_SPEC, district="Академический", cad_num=cad_num
        )


# Дата отчёта фиксируется в тестах тайминга, чтобы выведенный месяц кандидата был
# детерминирован вне зависимости от реального date.today(). _full_stack_patch даёт пик
# дефицита на горизонте 24 мес → candidate_release_month = _FIXED_TODAY + 24 мес.
_FIXED_TODAY = date(2026, 6, 9)
_DERIVED_CANDIDATE_MONTH = date(2028, 6, 1)  # 2026-06 + 24 мес, 1-е число


class _FixedDate(date):
    """date с фиксированным today() (construction делегируется реальному date). PURE."""

    @classmethod
    def today(cls) -> date:  # type: ignore[override]
        return _FIXED_TODAY


def _timing_card(
    portfolio: list[OwnProject],
    *,
    centroid: tuple[float, float] | None = _CENTROID,
    cad_num: str | None = "66:41:0303161:123",
) -> Any:
    """Как _cannibalization_card, но с зафиксированной датой отчёта (тайминговая ось)."""
    db = MagicMock()
    with (
        _full_stack_patch(),
        patch(f"{_MOD}.date", _FixedDate),
        patch(f"{_MOD}.get_own_portfolio", return_value=portfolio),
        patch(f"{_MOD}._query_parcel_centroid", return_value=centroid),
        patch(
            f"{_MOD}._query_artificial_demand",
            return_value={"n_sold": 100, "n_mortgage": 40},
        ),
    ):
        return compute_special_indices(
            db, spec=_CAND_SPEC, district="Академический", cad_num=cad_num
        )


class TestCannibalizationTimingAxisFedFromLaunchWindow:
    """§25.3 тайминговая ось активируется из §25.1 Launch Window (#1169 follow-up)."""

    def test_timing_axis_contributes_when_launch_window_resolves(self) -> None:
        # Launch Window резолвится (пик h=24) → candidate_release_month выведен →
        # тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0).
        own = _own(
            "Наш-А",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            release_month=_DERIVED_CANDIDATE_MONTH,
        )
        card = _timing_card([own])
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.detail["axes_available"]["timing"] == 1  # ось активна (НЕ 0)
        top = can.detail["top_contributors"][0]
        assert top["axes"]["timing"] == pytest.approx(1.0)  # тот же месяц выхода
        assert top["n_axes"] == 3  # class + price + timing (unit_mix всё ещё None)

    def test_near_in_time_project_scores_higher_than_far(self) -> None:
        # near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года
        # позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value.
        near = _timing_card(
            [
                _own(
                    "Близкий-во-времени",
                    lon=_CENTROID[0],
                    lat=_CENTROID[1],
                    release_month=_DERIVED_CANDIDATE_MONTH,
                )
            ]
        )
        far = _timing_card(
            [
                _own(
                    "Далёкий-во-времени",
                    lon=_CENTROID[0],
                    lat=_CENTROID[1],
                    release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48),
                )
            ]
        )
        near_v = near.indices[KEY_CANNIBALIZATION].value
        far_v = far.indices[KEY_CANNIBALIZATION].value
        assert near_v is not None and far_v is not None
        assert near_v > far_v

    def test_timing_excluded_when_launch_window_unavailable(self) -> None:
        # Launch Window недоступен (deficit None на всех горизонтах) → release_month None
        # → тайминговая ось ИСКЛЮЧЕНА (None-not-0), но каннибализация считается по
        # классу/цене/гео (не падает, не фабрикует тайминг).
        forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
        own = _own(
            "Наш-А",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            release_month=date(2027, 1, 1),  # есть дата, но кандидатной нет
        )
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(_DSF, return_value=forecasts),
            patch(f"{_MOD}.date", _FixedDate),
            patch(f"{_MOD}.get_own_portfolio", return_value=[own]),
            patch(f"{_MOD}._query_parcel_centroid", return_value=_CENTROID),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_CAND_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        assert card.indices[KEY_LAUNCH_WINDOW].method == _METHOD_UNAVAILABLE
        can = card.indices[KEY_CANNIBALIZATION]
        # каннибализация всё равно посчитана (класс+цена), тайминговая ось исключена.
        assert can.method == "own_portfolio_overlap"
        assert can.value is not None
        assert can.detail["axes_available"]["timing"] == 0  # НЕ сфабрикована
        assert can.detail["top_contributors"][0]["axes"]["timing"] is None

    def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None:
        # Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict.
        portfolio = [
            _own(
                "Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], release_month=_DERIVED_CANDIDATE_MONTH
            ),
            _own(
                "Наш-Б",
                obj_class="комфорт+",
                lon=60.65,
                lat=56.85,
                release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6),
            ),
        ]
        first = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
        second = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
        assert first == second
        # подтверждаем, что тайминг реально участвовал (ось активна) — не пустой детерминизм.
        assert first["detail"]["axes_available"]["timing"] == 2


class TestCannibalizationTrueMode:
    def test_nonempty_portfolio_uses_own_portfolio_mode(self) -> None:
        # наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.
        portfolio = [
            _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
            _own(
                "Наш-Б",
                obj_class="бизнес",
                price_min=200_000.0,
                price_max=240_000.0,
                lon=60.9,
                lat=57.1,
            ),
        ]
        card = _cannibalization_card(portfolio)
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.method == "own_portfolio_overlap"
        assert can.detail["mode"] == "own_portfolio"
        assert can.detail["proxy"] is False
        # сильнейший каннибализатор = Наш-А (class 1.0 + price 1.0)/2 × geo 1.0 = 1.0.
        assert can.value == pytest.approx(1.0)

    def test_top_contributor_surfaced(self) -> None:
        portfolio = [
            _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
            _own(
                "Наш-Б",
                obj_class="бизнес",
                price_min=200_000.0,
                price_max=240_000.0,
                lon=60.9,
                lat=57.1,
            ),
        ]
        card = _cannibalization_card(portfolio)
        can = card.indices[KEY_CANNIBALIZATION]
        top = can.detail["top_contributors"]
        assert top[0]["name"] == "Наш-А"
        assert "Наш-А" in (can.label or "")
        # explainability: пер-ось breakdown присутствует.
        assert top[0]["axes"]["class"] == pytest.approx(1.0)
        assert top[0]["axes"]["price"] == pytest.approx(1.0)
        # квартирография/тайминг недоступны из spec → None (НЕ 0).
        assert top[0]["axes"]["unit_mix"] is None
        assert top[0]["axes"]["timing"] is None

    def test_geo_weight_attenuates_distant_project(self) -> None:
        # тот же сильный overlap, но проект далеко → значение заметно ниже 1.0.
        near = _cannibalization_card([_own("Близкий", lon=_CENTROID[0], lat=_CENTROID[1])])
        far = _cannibalization_card([_own("Далёкий", lon=61.5, lat=57.5)])
        near_v = near.indices[KEY_CANNIBALIZATION].value
        far_v = far.indices[KEY_CANNIBALIZATION].value
        assert near_v is not None and far_v is not None
        assert near_v > far_v

    def test_no_geometry_still_computes_via_other_axes(self) -> None:
        # нет центроида → гео-вес на floor (_GEO_WEIGHT_UNKNOWN=0.05), класс+цена дают
        # overlap → индекс есть (None-not-0). #1633: floor снижен 0.1→0.05.
        card = _cannibalization_card([_own("Без-гео")], centroid=None)
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.method == "own_portfolio_overlap"
        assert can.detail["has_geometry"] is False
        # overlap 1.0 × geo floor 0.05 = 0.05.
        assert can.value == pytest.approx(0.05)

    def test_unit_mix_and_timing_axes_when_supplied(self) -> None:
        # caller передаёт квартирографию/тайминг кандидата → оси становятся доступны.
        db = MagicMock()
        own_proj = _own(
            "Наш-А",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            unit_mix={"studio": 0.5, "1k": 0.5},
            release_month=date(2026, 6, 1),
        )
        with (
            patch(f"{_MOD}.get_own_portfolio", return_value=[own_proj]),
            patch(f"{_MOD}._query_parcel_centroid", return_value=_CENTROID),
        ):
            from app.services.forecasting.special_indices import _build_cannibalization

            idx = _build_cannibalization(
                db,
                spec=_CAND_SPEC,
                cad_num="66:41:0303161:123",
                candidate_unit_mix={"studio": 0.5, "1k": 0.5},
                candidate_release_month=date(2026, 6, 1),
            )
        top = idx.detail["top_contributors"][0]
        assert top["axes"]["unit_mix"] == pytest.approx(1.0)  # одинаковый микс
        assert top["axes"]["timing"] == pytest.approx(1.0)  # одинаковый месяц
        assert top["n_axes"] == 4

    def test_none_not_zero_when_no_comparable_axes(self) -> None:
        # наш проект без класса И без цены → ни класс, ни цена не считаются;
        # тайминг/квартирография тоже None → пара без осей → индекс unavailable (НЕ 0).
        card = _cannibalization_card(
            [
                _own(
                    "Пустой",
                    obj_class=None,
                    price_min=None,
                    price_max=None,
                    lon=_CENTROID[0],
                    lat=_CENTROID[1],
                )
            ]
        )
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.value is None
        assert can.method == _METHOD_UNAVAILABLE
        assert "фабрикуем" in can.detail["reason"]

    def test_axes_available_summary_excludes_missing(self) -> None:
        # 1 проект, доступны только class+price → summary это отражает (unit_mix/timing 0).
        card = _cannibalization_card([_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1])])
        summary = card.indices[KEY_CANNIBALIZATION].detail["axes_available"]
        assert summary["class"] == 1
        assert summary["price"] == 1
        assert summary["unit_mix"] == 0
        assert summary["timing"] == 0


class TestCannibalizationThinPortfolioConfidence:
    def test_only_current_is_low_with_note(self) -> None:
        # портфель только из current (нет future-пайплайна) → honest low + нота §26.
        portfolio = [
            _own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1]),
            _own("Текущий-2", source="current", lon=_CENTROID[0], lat=_CENTROID[1]),
            _own("Текущий-3", source="current", lon=_CENTROID[0], lat=_CENTROID[1]),
        ]
        can = _cannibalization_card(portfolio).indices[KEY_CANNIBALIZATION]
        assert can.confidence == "low"
        assert can.detail["confidence_note"] is not None
        assert "future" in can.detail["confidence_note"]

    def test_single_project_is_low_data_scarce(self) -> None:
        can = _cannibalization_card([_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]).indices[
            KEY_CANNIBALIZATION
        ]
        assert can.confidence == "low"
        assert "данных мало" in can.detail["confidence_note"]

    def test_healthy_portfolio_medium(self) -> None:
        # ≥2 проекта И есть future → medium (всё равно ≤ cap).
        portfolio = [
            _own("Будущий-1", source="future", lon=_CENTROID[0], lat=_CENTROID[1]),
            _own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1]),
        ]
        can = _cannibalization_card(portfolio).indices[KEY_CANNIBALIZATION]
        assert can.confidence == "medium"
        assert can.detail["confidence_note"] is None


class TestCannibalizationProxyFallback:
    def test_empty_portfolio_falls_back_to_proxy(self) -> None:
        # own-portfolio пуст → ПРОКСИ, явно помеченный.
        can = _cannibalization_card([]).indices[KEY_CANNIBALIZATION]
        assert can.method == "proxy_same_class_relevance_share"
        assert can.detail["mode"] == "proxy"
        assert can.detail["proxy"] is True
        # каведат §26: прокси НЕ выдаётся за истинный индекс.
        assert "прокси" in can.detail["proxy_reason"].lower()
        assert "own-portfolio" in can.detail["proxy_reason"]

    def test_proxy_value_matches_same_class_share(self) -> None:
        # competitors из _full_stack_patch: same-class (комфорт) 0.8+0.6=1.4; all=1.9.
        can = _cannibalization_card([]).indices[KEY_CANNIBALIZATION]
        assert can.value == pytest.approx((0.8 + 0.6) / (0.8 + 0.6 + 0.5))

    def test_proxy_label_marked(self) -> None:
        can = _cannibalization_card([]).indices[KEY_CANNIBALIZATION]
        assert "прокси" in (can.label or "")

    def test_proxy_no_cad_num_unavailable(self) -> None:
        # пустой портфель + нет cad_num → прокси не определить → unavailable.
        can = _cannibalization_card([], cad_num=None).indices[KEY_CANNIBALIZATION]
        assert can.value is None
        assert can.method == _METHOD_UNAVAILABLE


class TestCannibalizationDeterminism:
    def test_same_inputs_identical_output(self) -> None:
        portfolio = [
            _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
            _own(
                "Наш-Б",
                obj_class="бизнес",
                price_min=200_000.0,
                price_max=240_000.0,
                lon=60.9,
                lat=57.1,
            ),
            _own("Наш-В", obj_class="комфорт+", lon=60.65, lat=56.85),
        ]
        first = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
        second = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
        assert first == second

    def test_top_contributor_tie_break_by_name(self) -> None:
        # два проекта с ИДЕНТИЧНЫМ сигналом → детерминированный tie-break по имени (А раньше Я).
        portfolio = [
            _own("Яков", lon=_CENTROID[0], lat=_CENTROID[1]),
            _own("Андрей", lon=_CENTROID[0], lat=_CENTROID[1]),
        ]
        can = _cannibalization_card(portfolio).indices[KEY_CANNIBALIZATION]
        names = [c["name"] for c in can.detail["top_contributors"]]
        assert names == ["Андрей", "Яков"]


# ──────────────────────────────────────────────────────────────────────────────
# §25.3 ось КВАРТИРОГРАФИИ — активируется из recommend_mix (4-я и последняя ось)
# ──────────────────────────────────────────────────────────────────────────────


def _recommend_mix_result(shares_pct: dict[str, float]) -> dict[str, Any]:
    """recommend_mix-подобный ответ: {bucket: share_pct} → форма с `buckets`."""
    return {
        "scope": {"district": "Академический"},
        "buckets": [{"bucket": b, "share_pct": s} for b, s in shares_pct.items()],
        "summary": {"warnings": []},
    }


def _unitmix_card(
    portfolio: list[OwnProject],
    *,
    recommend_return: dict[str, Any] | None = None,
    recommend_side_effect: Exception | None = None,
    centroid: tuple[float, float] | None = _CENTROID,
    cad_num: str | None = "66:41:0303161:123",
) -> Any:
    """compute_special_indices с замоканными own-portfolio + центроид + recommend_mix.

    Дата отчёта фиксируется (_FixedDate), чтобы тайминговая ось была детерминирована и не
    «шумела» при изоляции вклада квартирографии.
    """
    db = MagicMock()
    rec_kwargs: dict[str, Any] = {}
    if recommend_side_effect is not None:
        rec_kwargs["side_effect"] = recommend_side_effect
    else:
        rec_kwargs["return_value"] = recommend_return
    with (
        _full_stack_patch(),
        patch(f"{_MOD}.date", _FixedDate),
        patch(f"{_MOD}.get_own_portfolio", return_value=portfolio),
        patch(f"{_MOD}._query_parcel_centroid", return_value=centroid),
        patch(f"{_MOD}.recommend_mix", MagicMock(**rec_kwargs)),
        patch(
            f"{_MOD}._query_artificial_demand",
            return_value={"n_sold": 100, "n_mortgage": 40},
        ),
    ):
        return compute_special_indices(
            db, spec=_CAND_SPEC, district="Академический", cad_num=cad_num
        )


class TestCannibalizationUnitMixAxisFedFromRecommendMix:
    """§25.3 ось квартирографии активируется из recommend_mix (4-я ось, follow-up)."""

    def test_unit_mix_axis_contributes_when_recommend_resolves(self) -> None:
        # recommend_mix отдаёт микс кандидата (RU-подписи), наш проект — manual латиница с
        # ТЕМ ЖЕ распределением по комнатности → ось квартирографии = 1.0 (через канон).
        own = _own(
            "Наш-А",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
        )
        card = _unitmix_card(
            [own],
            recommend_return=_recommend_mix_result(
                {"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0}
            ),
        )
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.detail["axes_available"]["unit_mix"] == 1  # ось активна (НЕ 0)
        top = can.detail["top_contributors"][0]
        assert top["axes"]["unit_mix"] == pytest.approx(1.0)  # идентичный микс по канону

    def test_similar_mix_scores_higher_than_dissimilar(self) -> None:
        # тот же recommend-микс кандидата; наш проект с ПОХОЖИМ vs НЕпохожим миксом.
        # Все прочие оси (класс/цена/гео/тайминг) идентичны → разница только в квартирографии.
        rec = _recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0})
        similar = _unitmix_card(
            [
                _own(
                    "Похожий",
                    lon=_CENTROID[0],
                    lat=_CENTROID[1],
                    unit_mix={"studio": 0.5, "1k": 0.5},
                )
            ],
            recommend_return=rec,
        )
        dissimilar = _unitmix_card(
            [
                _own(
                    "Непохожий", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"2k": 0.5, "3k": 0.5}
                )
            ],
            recommend_return=rec,
        )
        sim_axis = similar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
            "unit_mix"
        ]
        dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
            "unit_mix"
        ]
        assert sim_axis > dis_axis
        # и итоговое значение каннибализации выше при похожем миксе.
        assert (
            similar.indices[KEY_CANNIBALIZATION].value
            > dissimilar.indices[KEY_CANNIBALIZATION].value
        )

    def test_axis_excluded_when_recommend_returns_none(self) -> None:
        # recommend_mix вернул None → микс None → ось квартирографии исключена (None-not-0),
        # каннибализация всё равно считается по классу/цене/тайм/гео.
        own = _own(
            "Наш-А",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            unit_mix={"studio": 0.5, "1k": 0.5},
        )
        card = _unitmix_card([own], recommend_return=None)
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.method == "own_portfolio_overlap"
        assert can.value is not None
        assert can.detail["axes_available"]["unit_mix"] == 0  # НЕ сфабрикована
        assert can.detail["top_contributors"][0]["axes"]["unit_mix"] is None

    def test_axis_excluded_when_recommend_empty_buckets(self) -> None:
        # recommend_mix отдал пустые buckets (тонкие данные) → микс None → ось исключена.
        own = _own(
            "Наш-А",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            unit_mix={"studio": 0.5, "1k": 0.5},
        )
        card = _unitmix_card([own], recommend_return={"buckets": []})
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.value is not None
        assert can.detail["axes_available"]["unit_mix"] == 0

    def test_axis_excluded_when_recommend_raises_no_crash(self) -> None:
        # recommend_mix БРОСИЛ → graceful None → ось исключена, карточка цела (НЕ crash).
        own = _own(
            "Наш-А",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            unit_mix={"studio": 0.5, "1k": 0.5},
        )
        card = _unitmix_card([own], recommend_side_effect=RuntimeError("heavy query boom"))
        assert len(card.indices) == 6  # карточка возвращена целиком
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.method == "own_portfolio_overlap"
        assert can.value is not None
        assert can.detail["axes_available"]["unit_mix"] == 0

    def test_axis_excluded_when_own_has_no_unit_mix(self) -> None:
        # ЧЕСТНОСТЬ (own_portfolio PR1): current-проект несёт unit_mix=None → даже при
        # валидном recommend-миксе кандидата ось не считается (нечего сравнивать).
        own = _own(
            "Текущий",
            source="current",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            unit_mix=None,
        )
        card = _unitmix_card(
            [own],
            recommend_return=_recommend_mix_result({"Студии 15-30": 100.0}),
        )
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.detail["axes_available"]["unit_mix"] == 0
        assert can.detail["top_contributors"][0]["axes"]["unit_mix"] is None

    def test_all_four_axes_active_together(self) -> None:
        # class + price + timing + unit_mix ВСЕ доступны на одном проекте → n_axes == 4.
        own = _own(
            "Наш-А",
            lon=_CENTROID[0],
            lat=_CENTROID[1],
            unit_mix={"studio": 0.5, "1k": 0.5},
            release_month=_DERIVED_CANDIDATE_MONTH,  # совпадает с выведенным тайм. кандидата
        )
        card = _unitmix_card(
            [own],
            recommend_return=_recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0}),
        )
        can = card.indices[KEY_CANNIBALIZATION]
        summary = can.detail["axes_available"]
        assert summary == {"class": 1, "price": 1, "unit_mix": 1, "timing": 1}
        assert can.detail["top_contributors"][0]["n_axes"] == 4

    def test_unit_mix_axis_deterministic_identical_as_dict(self) -> None:
        # Детерминизм (§16): одинаковые входы → идентичный as_dict (с активной осью).
        portfolio = [
            _own(
                "Наш-А",
                lon=_CENTROID[0],
                lat=_CENTROID[1],
                unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
            ),
            _own(
                "Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85, unit_mix={"1k": 0.6, "2k": 0.4}
            ),
        ]
        rec = _recommend_mix_result({"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0})
        first = (
            _unitmix_card(list(portfolio), recommend_return=rec)
            .indices[KEY_CANNIBALIZATION]
            .as_dict()
        )
        second = (
            _unitmix_card(list(portfolio), recommend_return=rec)
            .indices[KEY_CANNIBALIZATION]
            .as_dict()
        )
        assert first == second
        # подтверждаем, что ось реально участвовала (не пустой детерминизм).
        assert first["detail"]["axes_available"]["unit_mix"] == 2

    def test_recommend_mix_called_with_expected_args(self) -> None:
        # cost/корректность: recommend_mix зовётся с district+target_class+cad_num кандидата.
        own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
        rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(f"{_MOD}.date", _FixedDate),
            patch(f"{_MOD}.get_own_portfolio", return_value=[own]),
            patch(f"{_MOD}._query_parcel_centroid", return_value=_CENTROID),
            patch(f"{_MOD}.recommend_mix", rec_mock),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            compute_special_indices(
                db, spec=_CAND_SPEC, district="Академический", cad_num="66:41:0303161:123"
            )
        assert rec_mock.call_count == 1  # ровно один доп. вызов на отчёт (не O(n))
        _, kwargs = rec_mock.call_args
        assert kwargs["district_name"] == "Академический"
        assert kwargs["target_class"] == "комфорт"  # из _CAND_SPEC.obj_class
        assert kwargs["cad_num"] == "66:41:0303161:123"

    def test_no_district_skips_recommend_mix(self) -> None:
        # нет района → recommend_mix НЕ зовётся (район обязателен) → ось исключена, без вызова.
        own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
        rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
        db = MagicMock()
        with (
            _full_stack_patch(),
            patch(f"{_MOD}.date", _FixedDate),
            patch(f"{_MOD}.get_own_portfolio", return_value=[own]),
            patch(f"{_MOD}._query_parcel_centroid", return_value=_CENTROID),
            patch(f"{_MOD}.recommend_mix", rec_mock),
            patch(
                f"{_MOD}._query_artificial_demand",
                return_value={"n_sold": 100, "n_mortgage": 40},
            ),
        ):
            card = compute_special_indices(
                db, spec=_CAND_SPEC, district=None, cad_num="66:41:0303161:123"
            )
        assert rec_mock.call_count == 0  # район обязателен → нет вызова
        assert card.indices[KEY_CANNIBALIZATION].detail["axes_available"]["unit_mix"] == 0


# ──────────────────────────────────────────────────────────────────────────────
# §25.3 hot-path gate (#1129 regression): тяжёлый recommend_mix зовётся ТОЛЬКО когда
# в портфеле есть проект с unit_mix; get_own_portfolio фетчится РОВНО один раз за отчёт.
# ──────────────────────────────────────────────────────────────────────────────


def _gated_card(
    portfolio: list[OwnProject],
) -> tuple[Any, MagicMock, MagicMock]:
    """Прогнать compute_special_indices, вернув (card, recommend_mix_mock, portfolio_mock).

    Оба зависимых сервиса — явные MagicMock, чтобы тест проверял ИХ call_count: гейт
    дёргает recommend_mix только при наличии unit_mix в портфеле, а get_own_portfolio
    фетчится один раз (без двойного запроса). Дата отчёта зафиксирована (_FixedDate).
    """
    db = MagicMock()
    rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
    portfolio_mock = MagicMock(return_value=portfolio)
    with (
        _full_stack_patch(),
        patch(f"{_MOD}.date", _FixedDate),
        patch(f"{_MOD}.get_own_portfolio", portfolio_mock),
        patch(f"{_MOD}._query_parcel_centroid", return_value=_CENTROID),
        patch(f"{_MOD}.recommend_mix", rec_mock),
        patch(
            f"{_MOD}._query_artificial_demand",
            return_value={"n_sold": 100, "n_mortgage": 40},
        ),
    ):
        card = compute_special_indices(
            db, spec=_CAND_SPEC, district="Академический", cad_num="66:41:0303161:123"
        )
    return card, rec_mock, portfolio_mock


class TestCannibalizationUnitMixGate:
    """§25.3 hot-path gate — recommend_mix только при own-портфеле с миксом (#1129)."""

    def test_no_project_with_unit_mix_skips_recommend_mix(self) -> None:
        # Портфель только из current/domrf (unit_mix=None) → ось квартирографии всё равно
        # исключилась бы → тяжёлый recommend_mix НЕ должен вызываться (call_count == 0).
        portfolio = [
            _own(
                "Текущий-1",
                source="current",
                lon=_CENTROID[0],
                lat=_CENTROID[1],
                unit_mix=None,
                release_month=_DERIVED_CANDIDATE_MONTH,
            ),
            _own(
                "Текущий-2",
                source="current",
                lon=_CENTROID[0],
                lat=_CENTROID[1],
                unit_mix=None,
                release_month=_DERIVED_CANDIDATE_MONTH,
            ),
        ]
        card, rec_mock, _ = _gated_card(portfolio)
        # Гейт сработал: тяжёлый запрос НЕ выполнен.
        assert rec_mock.call_count == 0
        # Ось квартирографии исключена (None-not-0), но каннибализация ВСЁ РАВНО считается
        # по class+price+timing+geo (не падает, не деградирует целиком).
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.method == "own_portfolio_overlap"
        assert can.value is not None
        assert can.detail["axes_available"]["unit_mix"] == 0
        assert can.detail["top_contributors"][0]["axes"]["unit_mix"] is None
        # class+price+timing активны — каннибализация считается из остальных осей.
        assert can.detail["axes_available"]["class"] == 2
        assert can.detail["axes_available"]["price"] == 2
        assert can.detail["axes_available"]["timing"] == 2

    def test_project_with_unit_mix_calls_recommend_mix_once(self) -> None:
        # Есть future-проект с unit_mix → ось может внести вклад → recommend_mix зовётся
        # РОВНО один раз (не O(n), не дважды), и get_own_portfolio фетчится РОВНО один раз.
        portfolio = [
            _own(
                "Будущий-А",
                source="future",
                lon=_CENTROID[0],
                lat=_CENTROID[1],
                unit_mix={"studio": 1.0},
            ),
        ]
        card, rec_mock, portfolio_mock = _gated_card(portfolio)
        # Тяжёлый запрос — ровно один доп. вызов на отчёт.
        assert rec_mock.call_count == 1
        # get_own_portfolio фетчится ОДИН раз (gate + каннибализация реюзают список — нет
        # двойного запроса к БД).
        assert portfolio_mock.call_count == 1
        # Ось квартирографии активна и вносит вклад.
        can = card.indices[KEY_CANNIBALIZATION]
        assert can.detail["axes_available"]["unit_mix"] == 1
        assert can.detail["top_contributors"][0]["axes"]["unit_mix"] == pytest.approx(1.0)

    def test_get_own_portfolio_single_fetch_even_when_gate_skips(self) -> None:
        # Даже когда гейт пропускает recommend_mix, get_own_portfolio всё равно фетчится
        # РОВНО один раз (портфель нужен и для гейта, и для самой каннибализации).
        portfolio = [
            _own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix=None),
        ]
        _, rec_mock, portfolio_mock = _gated_card(portfolio)
        assert rec_mock.call_count == 0
        assert portfolio_mock.call_count == 1  # один фетч, не ноль и не два

    def test_empty_mix_dict_does_not_trigger_recommend_mix(self) -> None:
        # Пустой dict unit_mix={} (грязь) — НЕ «есть микс» → recommend_mix не зовётся.
        portfolio = [
            _own(
                "Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={}
            ),
        ]
        _, rec_mock, _ = _gated_card(portfolio)
        assert rec_mock.call_count == 0


class TestPortfolioHasUnitMix:
    """Pure-гейт _portfolio_has_unit_mix — есть ли проект с непустой квартирографией."""

    def test_true_when_any_project_has_mix(self) -> None:
        portfolio = [
            _own("Без-микса", unit_mix=None),
            _own("С-миксом", unit_mix={"studio": 0.5, "1k": 0.5}),
        ]
        assert _portfolio_has_unit_mix(portfolio) is True

    def test_false_when_all_none(self) -> None:
        portfolio = [_own("A", unit_mix=None), _own("B", unit_mix=None)]
        assert _portfolio_has_unit_mix(portfolio) is False

    def test_false_on_empty_portfolio(self) -> None:
        assert _portfolio_has_unit_mix([]) is False

    def test_empty_mix_dict_is_not_a_mix(self) -> None:
        # пустой dict (грязь/нет долей) не считается миксом (falsy).
        assert _portfolio_has_unit_mix([_own("A", unit_mix={})]) is False
