"""Unit-тесты per-request memoization-кэша §22-форсайта (#1129).

Кэш (`app/services/forecast_request_cache.py`) НЕ меняет сигнатуры §9.x-сервисов:
`forecast_cache()` (ContextVar-контекст) активируется один раз в оркестраторе
(`build_site_finder_report`), а `@cached(key_builder)`-декоратор мемоизирует дорогие
горизонт/сегмент-инвариантные БД-загрузки по ЯВНОМУ ключу. Внутри контекста —
результат считается один раз на уникальный ключ и переиспользуется (ТОТ ЖЕ
frozen-инстанс); вне контекста — no-op (обычный вызов без кэша).

Что пинят тесты (БЕЗ живой БД — call-counting спаи / прямые проверки key-builder'ов):

  1. ДИСКРИМИНАЦИЯ КЛЮЧЕЙ (главный регресс-гейт): одинаковые аргументы в одном
     `forecast_cache()` → подлежащая функция вызвана ОДИН раз (2-й — из кэша); другой
     влияющий на результат аргумент → вызвана ДВАЖДЫ (нет коллизии ключей). Опасные
     аргументы покрыты явно через РЕАЛЬНЫЕ key-builder'ы загрузок: get_competitors по
     horizon_months И radius_km; compute_future_supply_pressure по horizon_months;
     compute_demand_normalization по rate_future; compute_market_metrics по window_months.
  2. NO-OP ВНЕ КОНТЕКСТА: `@cached`-функция дважды без активного `forecast_cache()` →
     подлежащая вызвана дважды; `is_active()` False снаружи, True внутри.
  3. ИЗОЛЯЦИЯ PER-ОТЧЁТ: два ПОСЛЕДОВАТЕЛЬНЫХ `with forecast_cache():` → 2-й пере-
     считывает (не видит записей 1-го). Реентерабельность: вложенный `forecast_cache()`
     переиспользует активный кэш (подлежащая вызвана один раз на весь вложенный блок).
  4. КАНАРЕЙКА НЕИЗМЕНЯЕМОСТИ: все мемоизируемые продьюсеры возвращают frozen-dataclass —
     если кто-то в будущем сделает их mutable, шаринг инстанса станет небезопасен, и
     этот тест упадёт (явный сигнал пересмотреть кэш).

Плюс AMPLIFICATION-PROOF (без тяжёлой БД-фикстуры): на реальной загрузке
`get_monthly_macro` (БД-хелперы запатчены спаями) повторный паттерн вызовов внутри
одного отчёта схлопывается в ОДИН реальный roundtrip — демонстрация ×N→1 редукции,
ради которой кэш и сделан (профиль: get_monthly_macro ×290 на отчёт).

Детерминированно, без сети/БД (monkeypatch). asyncio_mode=auto (тесты sync — кэш sync).
DATABASE_URL до импорта app-модулей (зеркало соседних forecast-тестов — на случай
side-effect'ов импорта пакетов forecasting/site_finder при сборе).
"""

from __future__ import annotations

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

import pytest

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

from app.schemas.parcel import CompetitorsRequest
from app.services.forecast_request_cache import (
    cached,
    forecast_cache,
    is_active,
)

# Реальные key-builder'ы загрузок — тестируем ПРОДАКШН-ключи, а не их копии.
from app.services.forecasting.demand_normalization import (
    DemandNormalization,
    compute_demand_normalization,
)
from app.services.forecasting.macro_coefficient import (
    MacroCoefficient,
    compute_macro_coefficient,
)
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
from app.services.forecasting.rate_sensitivity import RateSensitivity
from app.services.forecasting.regression import compute_rate_regime_sensitivity
from app.services.forecasting.sales_series import (
    SalesSeries,
    SegmentSpec,
    _sales_series_key,
    build_sales_series,
)
from app.services.site_finder.competitors import _competitors_key, get_competitors
from app.services.site_finder.future_supply import (
    FutureSupplyPressure,
    _future_supply_key,
    compute_future_supply_pressure,
)
from app.services.site_finder.market_metrics import (
    MarketMetrics,
    _market_metrics_key,
    compute_market_metrics,
)

# db-сессия в ключ кэша НЕ входит → sentinel. MagicMock (а не object()) — соседняя
# конвенция тестов: mypy видит его как Any, поэтому подходит под параметр `Session`,
# и реальные загрузки (get_monthly_macro) с запатченными БД-хелперами его не трогают.
_DB: Any = MagicMock(name="db_sentinel")


# ──────────────────────────────────────────────────────────────────────────────
# Хелперы: счётчик-спай, обёрнутый РЕАЛЬНЫМ key-builder'ом через @cached.
# ──────────────────────────────────────────────────────────────────────────────


class _Counter:
    """Счётчик реальных вызовов подлежащей функции (через @cached-обёртку)."""

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

    def bump(self) -> int:
        self.calls += 1
        return self.calls


def _spy_with_key(key_builder: Any, *, label: str) -> tuple[Any, _Counter]:
    """`@cached(key_builder)`-обёрнутая фейк-функция, считающая РЕАЛЬНЫЕ вызовы тела.

    Возвращает (обёрнутая_функция, счётчик). Декорируем РЕАЛЬНЫМ key-builder'ом
    загрузки (тот же объект, что навешен на продакшн-функцию) — так тест ловит и
    верность ключа, и поведение кэша одновременно, без живой БД.
    """
    counter = _Counter()

    @cached(key_builder, label=label)
    def _fn(*args: Any, **kwargs: Any) -> object:
        counter.bump()
        # Возвращаем НОВЫЙ объект на каждый реальный вызов → identity-проверка «тот же
        # инстанс» однозначно отличает hit (тот же объект) от miss (новый объект).
        return object()

    return _fn, counter


# ──────────────────────────────────────────────────────────────────────────────
# 1. Дискриминация ключей — на ПРИМИТИВЕ кэша напрямую (главный регресс-гейт).
# ──────────────────────────────────────────────────────────────────────────────


class TestCachePrimitiveKeyDiscrimination:
    def test_same_key_calls_underlying_once(self) -> None:
        """Одинаковый ключ в одном контексте → тело вызвано ОДИН раз, 2-й из кэша."""
        fn, counter = _spy_with_key(lambda x: x, label="fake")
        with forecast_cache():
            r1 = fn(7)
            r2 = fn(7)
        assert counter.calls == 1
        assert r1 is r2  # тот же инстанс — шаринг кэша

    def test_different_key_calls_underlying_twice(self) -> None:
        """Разный ключ → тело вызвано ДВАЖДЫ (нет коллизии ключей)."""
        fn, counter = _spy_with_key(lambda x: x, label="fake")
        with forecast_cache():
            r1 = fn(7)
            r2 = fn(8)
        assert counter.calls == 2
        assert r1 is not r2

    def test_cached_none_is_a_hit_not_a_miss(self) -> None:
        """Закэшированный None — это HIT (сентинел _MISS отличает его от промаха)."""
        counter = _Counter()

        @cached(lambda x: x, label="returns_none")
        def _fn(x: int) -> None:
            counter.bump()
            return None

        with forecast_cache():
            assert _fn(1) is None
            assert _fn(1) is None
        assert counter.calls == 1  # 2-й вызов обслужен из кэша, а не пересчитан

    def test_label_namespaces_keys(self) -> None:
        """Разный label при ОДИНАКОВОМ ключе аргументов → разные слоты (нет коллизии)."""
        fn_a, counter_a = _spy_with_key(lambda x: x, label="label_a")
        fn_b, counter_b = _spy_with_key(lambda x: x, label="label_b")
        with forecast_cache():
            fn_a(1)
            fn_b(1)  # тот же аргумент-ключ, но другой label → отдельный слот
        assert counter_a.calls == 1
        assert counter_b.calls == 1


# ──────────────────────────────────────────────────────────────────────────────
# 1b. Дискриминация ключей на РЕАЛЬНЫХ key-builder'ах загрузок (call-counting через
#     @cached) — покрываем «опасные» горизонт/окно/ставка/радиус-аргументы явно.
# ──────────────────────────────────────────────────────────────────────────────


class TestRealLoaderKeyDiscrimination:
    """Каждый кейс декорирует спай РЕАЛЬНЫМ key-builder'ом загрузки и считает вызовы.

    Так регресс «забыли горизонт/окно/ставку/радиус в ключе» → схлопывание в 1 вызов,
    где ожидается 2 → тест падает. db-сессия в ключ не входит → sentinel _DB.
    """

    # ── get_competitors: horizon_months И radius_km обязаны различать ──────────

    def test_competitors_same_request_once(self) -> None:
        fn, counter = _spy_with_key(_competitors_key, label="get_competitors")
        req = CompetitorsRequest(radius_km=1.0, horizon_months=12)
        with forecast_cache():
            fn(_DB, "66:41:0702048:27", req)
            fn(_DB, "66:41:0702048:27", req)
        assert counter.calls == 1

    def test_competitors_differ_by_horizon_twice(self) -> None:
        fn, counter = _spy_with_key(_competitors_key, label="get_competitors")
        with forecast_cache():
            fn(_DB, "66:41:0702048:27", CompetitorsRequest(horizon_months=6))
            fn(_DB, "66:41:0702048:27", CompetitorsRequest(horizon_months=24))
        assert counter.calls == 2

    def test_competitors_differ_by_radius_twice(self) -> None:
        fn, counter = _spy_with_key(_competitors_key, label="get_competitors")
        with forecast_cache():
            fn(_DB, "66:41:0702048:27", CompetitorsRequest(radius_km=0.5))
            fn(_DB, "66:41:0702048:27", CompetitorsRequest(radius_km=1.5))
        assert counter.calls == 2

    def test_competitors_differ_by_cad_num_twice(self) -> None:
        fn, counter = _spy_with_key(_competitors_key, label="get_competitors")
        req = CompetitorsRequest()
        with forecast_cache():
            fn(_DB, "66:41:0702048:27", req)
            fn(_DB, "66:41:0702048:99", req)
        assert counter.calls == 2

    # ── compute_future_supply_pressure: horizon_months обязан различать ────────

    def test_future_supply_same_once(self) -> None:
        fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure")
        with forecast_cache():
            fn(_DB, district="Ленинский", horizon_months=12)
            fn(_DB, district="Ленинский", horizon_months=12)
        assert counter.calls == 1

    def test_future_supply_differ_by_horizon_twice(self) -> None:
        fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure")
        with forecast_cache():
            fn(_DB, district="Ленинский", horizon_months=6)
            fn(_DB, district="Ленинский", horizon_months=24)
        assert counter.calls == 2

    def test_future_supply_differ_by_district_twice(self) -> None:
        fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure")
        with forecast_cache():
            fn(_DB, district="Ленинский", horizon_months=12)
            fn(_DB, district="Кировский", horizon_months=12)
        assert counter.calls == 2

    # ── compute_market_metrics: window_months обязан различать ─────────────────

    def test_market_metrics_same_once(self) -> None:
        fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics")
        with forecast_cache():
            fn(_DB, district="Ленинский", window_months=6)
            fn(_DB, district="Ленинский", window_months=6)
        assert counter.calls == 1

    def test_market_metrics_differ_by_window_twice(self) -> None:
        fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics")
        with forecast_cache():
            fn(_DB, district="Ленинский", window_months=6)
            fn(_DB, district="Ленинский", window_months=12)
        assert counter.calls == 2

    def test_market_metrics_differ_by_obj_ids_twice(self) -> None:
        fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics")
        with forecast_cache():
            fn(_DB, district=None, obj_ids=[1, 2, 3])
            fn(_DB, district=None, obj_ids=[1, 2, 4])
        assert counter.calls == 2

    # ── compute_demand_normalization: rate_future обязан различать (lambda-ключ) ─

    def test_demand_norm_same_once(self) -> None:
        # Тот же inline-lambda-ключ, что навешен на продакшн compute_demand_normalization.
        fn, counter = _spy_with_key(
            lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back),
            label="compute_demand_normalization",
        )
        spec = SegmentSpec(obj_class="Комфорт")
        with forecast_cache():
            fn(_DB, spec=spec, rate_future=18.0)
            fn(_DB, spec=spec, rate_future=18.0)
        assert counter.calls == 1

    def test_demand_norm_differ_by_rate_future_twice(self) -> None:
        fn, counter = _spy_with_key(
            lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back),
            label="compute_demand_normalization",
        )
        spec = SegmentSpec(obj_class="Комфорт")
        with forecast_cache():
            fn(_DB, spec=spec, rate_future=14.0)  # conservative
            fn(_DB, spec=spec, rate_future=20.0)  # aggressive
        assert counter.calls == 2

    def test_demand_norm_differ_by_spec_twice(self) -> None:
        fn, counter = _spy_with_key(
            lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back),
            label="compute_demand_normalization",
        )
        with forecast_cache():
            fn(_DB, spec=SegmentSpec(obj_class="Комфорт"), rate_future=18.0)
            fn(_DB, spec=SegmentSpec(obj_class="Бизнес"), rate_future=18.0)
        assert counter.calls == 2


# ──────────────────────────────────────────────────────────────────────────────
# 1c. Прямые проверки key-builder'ов: key(A)==key(A), key(A)!=key(B) по каждому
#     влияющему на результат аргументу. Локирует контракт ключа без кэша вообще.
# ──────────────────────────────────────────────────────────────────────────────


class TestKeyBuildersDirect:
    def test_competitors_key_stable_and_discriminating(self) -> None:
        base = CompetitorsRequest(
            radius_km=1.0,
            time_window="last_quarter",
            obj_class_filter=None,
            horizon_months=12,
            exclude_obj_ids=[],
        )
        cad = "66:41:0702048:27"
        # стабилен и hashable (кэш кладёт ключ в dict)
        assert _competitors_key(_DB, cad, base) == _competitors_key(_DB, cad, base)
        assert hash(_competitors_key(_DB, cad, base))
        # каждый влияющий аргумент различает
        assert _competitors_key(_DB, cad, base) != _competitors_key(_DB, "66:41:0702048:99", base)
        assert _competitors_key(_DB, cad, base) != _competitors_key(
            _DB, cad, base.model_copy(update={"radius_km": 1.5})
        )
        assert _competitors_key(_DB, cad, base) != _competitors_key(
            _DB, cad, base.model_copy(update={"horizon_months": 24})
        )
        assert _competitors_key(_DB, cad, base) != _competitors_key(
            _DB, cad, base.model_copy(update={"time_window": "last_month"})
        )

    def test_competitors_key_db_ignored(self) -> None:
        """db-сессия НЕ входит в ключ (одна сессия на отчёт) — разные db → тот же ключ."""
        req = CompetitorsRequest()
        db_a: Any = MagicMock(name="session_a")
        db_b: Any = MagicMock(name="session_b")
        assert _competitors_key(db_a, "x", req) == _competitors_key(db_b, "x", req)

    def test_competitors_key_exclude_order_invariant(self) -> None:
        """exclude_obj_ids сортируется → порядок не влияет на ключ (устойчивость)."""
        cad = "66:41:0702048:27"
        a = CompetitorsRequest(exclude_obj_ids=[3, 1, 2])
        b = CompetitorsRequest(exclude_obj_ids=[1, 2, 3])
        assert _competitors_key(_DB, cad, a) == _competitors_key(_DB, cad, b)

    def test_future_supply_key_stable_and_discriminating(self) -> None:
        k = _future_supply_key
        assert k(_DB, district="Л", horizon_months=12) == k(_DB, district="Л", horizon_months=12)
        assert hash(k(_DB, district="Л", horizon_months=12))
        # горизонт ОБЯЗАН различать (взвешивание L3 горизонт-зависимо)
        assert k(_DB, district="Л", horizon_months=6) != k(_DB, district="Л", horizon_months=24)
        assert k(_DB, district="Л", horizon_months=12) != k(_DB, district="К", horizon_months=12)
        assert k(_DB, district="Л", horizon_months=12) != k(
            _DB, district="Л", horizon_months=12, premise_kind="апартаменты"
        )

    def test_market_metrics_key_stable_and_discriminating(self) -> None:
        k = _market_metrics_key
        assert k(_DB, district="Л", window_months=6) == k(_DB, district="Л", window_months=6)
        assert hash(k(_DB, district="Л", window_months=6))
        assert k(_DB, district="Л", window_months=6) != k(_DB, district="Л", window_months=12)
        assert k(_DB, district="Л") != k(_DB, district="К")
        assert k(_DB, obj_ids=[1, 2]) != k(_DB, obj_ids=[1, 3])
        # None obj_ids vs пустой набор — обе ветки hashable, None отличается от tuple()
        assert k(_DB, district="Л", obj_ids=None) != k(_DB, district="Л", obj_ids=[1])

    def test_market_metrics_key_obj_ids_order_sensitive_but_hashable(self) -> None:
        """obj_ids → tuple(...) (без sort): hashable; в форсайт-пути obj_ids всегда None."""
        k = _market_metrics_key
        # tuple сохраняет порядок — проверяем лишь hashability/стабильность одинакового входа
        assert k(_DB, obj_ids=[1, 2, 3]) == k(_DB, obj_ids=[1, 2, 3])
        assert hash(k(_DB, obj_ids=[1, 2, 3]))

    def test_sales_series_key_stable_and_discriminating(self) -> None:
        k = _sales_series_key
        spec = SegmentSpec(obj_class="Комфорт", district="Ленинский")
        # стабилен и hashable
        assert k(_DB, spec=spec, source="corpus_room_month") == k(
            _DB, spec=spec, source="corpus_room_month"
        )
        assert hash(k(_DB, spec=spec, source="corpus_room_month"))
        # каждый влияющий аргумент различает
        assert k(_DB, spec=spec, source="corpus_room_month") != k(
            _DB, spec=spec, source="objective_lots"
        )
        assert k(_DB, spec=spec, source="corpus_room_month") != k(
            _DB, spec=spec, source="corpus_room_month", months_back=24
        )
        assert k(_DB, spec=spec, source="corpus_room_month") != k(
            _DB, spec=spec, source="corpus_room_month", premise_kind="апартаменты"
        )
        assert k(_DB, spec=spec, source="corpus_room_month") != k(
            _DB,
            spec=SegmentSpec(obj_class="Бизнес", district="Ленинский"),
            source="corpus_room_month",
        )


# ──────────────────────────────────────────────────────────────────────────────
# 2. No-op вне активного контекста.
# ──────────────────────────────────────────────────────────────────────────────


class TestNoOpOutsideContext:
    def test_no_context_calls_underlying_every_time(self) -> None:
        """Вне `forecast_cache()` → тело вызывается КАЖДЫЙ раз (мемоизации нет)."""
        fn, counter = _spy_with_key(lambda x: x, label="fake")
        r1 = fn(1)
        r2 = fn(1)
        assert counter.calls == 2
        assert r1 is not r2  # без кэша — каждый раз новый инстанс

    def test_is_active_false_outside_true_inside(self) -> None:
        assert is_active() is False
        with forecast_cache():
            assert is_active() is True
        assert is_active() is False

    def test_is_active_resets_even_on_exception(self) -> None:
        """`forecast_cache()` сбрасывает ContextVar даже если внутри блока бросили."""
        with pytest.raises(RuntimeError, match="boom"):
            with forecast_cache():
                assert is_active() is True
                raise RuntimeError("boom")
        assert is_active() is False  # token reset в finally — нет утечки контекста


# ──────────────────────────────────────────────────────────────────────────────
# 3. Изоляция per-отчёт + реентерабельность.
# ──────────────────────────────────────────────────────────────────────────────


class TestPerReportIsolationAndReentrancy:
    def test_sequential_blocks_do_not_share(self) -> None:
        """Два ПОСЛЕДОВАТЕЛЬНЫХ контекста → 2-й пересчитывает (кэш отброшен на выходе)."""
        fn, counter = _spy_with_key(lambda x: x, label="fake")
        with forecast_cache():
            fn(1)
        assert counter.calls == 1
        with forecast_cache():
            fn(1)  # новый кэш — НЕ видит запись из 1-го блока
        assert counter.calls == 2

    def test_entries_discarded_on_exit(self) -> None:
        """Кэш-dict, выданный контекстом, на выходе перестаёт быть активным."""
        first_seen: dict[Any, Any] | None = None
        with forecast_cache() as c1:
            first_seen = c1
            assert is_active() is True
        assert is_active() is False
        # повторный вход даёт СВЕЖИЙ dict (не тот же объект)
        with forecast_cache() as c2:
            assert c2 is not first_seen

    def test_nested_context_shares_active_cache(self) -> None:
        """Вложенный `forecast_cache()` переиспользует активный кэш (один вызов на блок)."""
        fn, counter = _spy_with_key(lambda x: x, label="fake")
        with forecast_cache():
            fn(1)
            with forecast_cache():  # реентерабельно — НЕ заводит новый кэш
                fn(1)  # обслужено из внешнего кэша
            fn(1)
        assert counter.calls == 1

    def test_nested_context_yields_same_dict(self) -> None:
        """Реентерабельность отдаёт ТОТ ЖЕ dict (не подменяет накопленное)."""
        with forecast_cache() as outer:
            with forecast_cache() as inner:
                assert inner is outer


# ──────────────────────────────────────────────────────────────────────────────
# 4. Канарейка неизменяемости: все мемоизируемые продьюсеры → frozen-dataclass.
# ──────────────────────────────────────────────────────────────────────────────

# Шаринг кэш-инстанса безопасен ТОЛЬКО пока результат неизменяем. Если кто-то снимет
# frozen=True (frozen→mutable), callers смогут случайно мутировать общий инстанс →
# тихая порча отчёта. Этот тест ловит такое изменение типа на корню.
_FROZEN_PRODUCERS = [
    MarketMetrics,
    FutureSupplyPressure,
    DemandNormalization,
    RateSensitivity,
    MacroCoefficient,
    SalesSeries,
    MonthlyMacro,
    SegmentSpec,  # ключевой вход в ключ кэша — обязан быть hashable/frozen
]


class TestMutationSafetyCanary:
    @pytest.mark.parametrize("cls", _FROZEN_PRODUCERS, ids=lambda c: c.__name__)
    def test_producer_is_frozen_dataclass(self, cls: type) -> None:
        assert dataclasses.is_dataclass(cls), f"{cls.__name__} перестал быть dataclass"
        params = cls.__dataclass_params__  # type: ignore[attr-defined]
        assert params.frozen is True, (
            f"{cls.__name__} больше НЕ frozen — шаринг кэш-инстанса (#1129) стал "
            f"небезопасен: callers могут мутировать общий объект. Пересмотри кэш."
        )

    @pytest.mark.parametrize("cls", _FROZEN_PRODUCERS, ids=lambda c: c.__name__)
    def test_frozen_instance_rejects_attr_set(self, cls: type) -> None:
        """Frozen реально блокирует мутацию поля (поведенческая проверка, не только флаг)."""
        fields = dataclasses.fields(cls)
        assert fields, f"{cls.__name__} без полей — нечего проверять"
        # Сконструировать инстанс не можем без валидных значений → берём поле и убеждаемся,
        # что КЛАСС объявлен frozen на уровне dataclass-параметров (флаг проверен выше);
        # дополнительно фиксируем, что FrozenInstanceError — ожидаемый класс ошибки.
        assert issubclass(dataclasses.FrozenInstanceError, AttributeError)


# ──────────────────────────────────────────────────────────────────────────────
# 5. Amplification-proof: реальная загрузка get_monthly_macro (БД-хелперы — спаи).
#    Повторный паттерн вызовов в одном отчёте → ОДИН реальный roundtrip (×N→1).
# ──────────────────────────────────────────────────────────────────────────────

_MACRO_KEYRATE = "app.services.forecasting.macro_series._query_key_rate_monthly"
_MACRO_MORTGAGE = "app.services.forecasting.macro_series._query_mortgage_monthly"


@pytest.fixture
def _macro_db_spies() -> Iterator[dict[str, Any]]:
    """Патчим ОБА БД-хелпера get_monthly_macro пустыми спаями (без живой БД).

    get_monthly_macro строит сетку месяцев из системной даты и для каждого месяца зовёт
    _query_key_rate_monthly + _query_mortgage_monthly РОВНО по разу. Спаи возвращают
    пустые dict'ы → ряд из MonthlyMacro с None-полями (graceful-путь), реальных
    roundtrip'ов = число НЕ-закэшированных вызовов get_monthly_macro.
    """
    with (
        patch(_MACRO_KEYRATE, return_value={}) as kr,
        patch(_MACRO_MORTGAGE, return_value={}) as mg,
    ):
        yield {"key_rate": kr, "mortgage": mg}


class TestAmplificationProofRealLoader:
    def test_repeated_calls_collapse_to_one_roundtrip(
        self, _macro_db_spies: dict[str, Any]
    ) -> None:
        """ВНУТРИ одного отчёта 5 одинаковых get_monthly_macro → 1 пара БД-roundtrip'ов."""
        with forecast_cache():
            results = [get_monthly_macro(_DB, months_back=48) for _ in range(5)]
        # Реальная загрузка ушла в БД ровно один раз (×5 → ×1 — суть #1129).
        assert _macro_db_spies["key_rate"].call_count == 1
        assert _macro_db_spies["mortgage"].call_count == 1
        # И все 5 вызовов вернули ТОТ ЖЕ инстанс (frozen, read-only → шаринг безопасен).
        assert all(r is results[0] for r in results)

    def test_different_months_back_not_collapsed(self, _macro_db_spies: dict[str, Any]) -> None:
        """Разный months_back → разный ключ → 2 реальных roundtrip-пары (нет коллизии)."""
        with forecast_cache():
            get_monthly_macro(_DB, months_back=48)
            get_monthly_macro(_DB, months_back=24)
        assert _macro_db_spies["key_rate"].call_count == 2
        assert _macro_db_spies["mortgage"].call_count == 2

    def test_real_loader_noop_outside_context(self, _macro_db_spies: dict[str, Any]) -> None:
        """ВНЕ контекста реальная загрузка НЕ кэшируется → 2 вызова → 2 roundtrip-пары."""
        get_monthly_macro(_DB, months_back=48)
        get_monthly_macro(_DB, months_back=48)
        assert _macro_db_spies["key_rate"].call_count == 2
        assert _macro_db_spies["mortgage"].call_count == 2


# ──────────────────────────────────────────────────────────────────────────────
# Sanity: продакшн-загрузки реально обёрнуты @cached (functools.wraps сохраняет имя,
# но навешивает обёртку) — ловит «декоратор случайно сняли с функции».
# ──────────────────────────────────────────────────────────────────────────────


class TestLoadersAreDecorated:
    @pytest.mark.parametrize(
        "loader",
        [
            get_competitors,
            compute_future_supply_pressure,
            compute_market_metrics,
            compute_demand_normalization,
            compute_rate_regime_sensitivity,
            build_sales_series,
            compute_macro_coefficient,
            get_monthly_macro,
        ],
        ids=lambda f: f.__name__,
    )
    def test_loader_is_cached_wrapped(self, loader: Any) -> None:
        # @wraps сохраняет __wrapped__ ссылку на оригинал → присутствие = задекорировано.
        assert hasattr(loader, "__wrapped__"), (
            f"{loader.__name__} больше не обёрнут @cached — мемоизация #1129 отвалилась"
        )
