"""Unit-тесты для reader макропоказателей (#945 PR A, ТЗ §8.8).

Чистые тесты (без живой БД):
  • classify_cbr_series — pure mapping cbr series_id → indicator_type (+ skip unmapped).
  • Консистентность Python-зеркала с SQL-миграцией 123_macro_indicator.sql.
  • Форма SQL reader-а (CAST(:x AS type), ORDER BY, params) через MagicMock-сессию,
    как в test_best_layouts.py — без подключения к Postgres.

psycopg v3 правило проверяется явно: bind-параметры должны быть CAST(:x AS type),
а не :x::type.
"""

from __future__ import annotations

import datetime as dt
import pathlib
from unittest.mock import MagicMock

from app.services.site_finder.macro import (
    _CBR_SERIES_MAP,
    _canonical_region,
    classify_cbr_series,
    get_latest_macro,
    get_macro_series,
)

# Путь к миграции — для теста-консистентности зеркала.
_MIGRATION = (
    pathlib.Path(__file__).resolve().parents[4] / "data" / "sql" / "123_macro_indicator.sql"
)


# ── pure: classify_cbr_series ─────────────────────────────────────────────────


class TestClassifyCbrSeries:
    def test_weighted_rate(self) -> None:
        assert classify_cbr_series("02_13_Rates_mortgage_ставка_в_рублях") == (
            "mortgage_rate_weighted",
            "%",
        )

    def test_term(self) -> None:
        assert classify_cbr_series("02_13_Rates_mortgage_срок_в_руб") == (
            "mortgage_term",
            "мес",
        )

    def test_issued_count(self) -> None:
        assert classify_cbr_series("02_10_Quantity_mortgage_в_рублях") == (
            "mortgage_issued_count",
            "шт",
        )

    def test_issued_volume(self) -> None:
        assert classify_cbr_series("02_11_New_loans_mortgage_в_рублях") == (
            "mortgage_issued_volume",
            "млн руб",
        )

    def test_debt(self) -> None:
        assert classify_cbr_series("02_14_Debt_mortgage_в_рублях") == (
            "mortgage_debt",
            "млн руб",
        )

    def test_overdue(self) -> None:
        assert classify_cbr_series("02_14_Debt_mortgage_в_т.ч._просроч._в_рублях") == (
            "mortgage_overdue",
            "млн руб",
        )

    def test_none_and_empty(self) -> None:
        assert classify_cbr_series(None) is None
        assert classify_cbr_series("") is None

    def test_itogo_variants_excluded(self) -> None:
        """_итого-варианты НЕ маппятся (иначе коллизия по PK с _в_рублях)."""
        assert classify_cbr_series("02_13_Rates_mortgage_ставка_итого") is None
        assert classify_cbr_series("02_10_Quantity_mortgage_итого") is None
        assert classify_cbr_series("02_14_Debt_mortgage_итого") is None

    def test_other_families_excluded(self) -> None:
        """_housing_ / _scpa_mortgage_ / _Share_ — шире/иное, чем _mortgage_ ИЖК-семья."""
        assert classify_cbr_series("02_06_Quantity_housing_в_рублях") is None
        assert classify_cbr_series("02_15_Rates_scpa_mortgage_ставка_в_рублях") is None
        assert classify_cbr_series("02_44_Share_mortgage_в_рублях") is None

    def test_unknown_series_id_skipped(self) -> None:
        """Неизвестный / посторонний series_id → None (в backfill не попадает)."""
        assert classify_cbr_series("Доля ипотечных кредитов на ИЖС, %") is None
        assert classify_cbr_series("02_99_unknown_series") is None


# ── canonical region resolution (#945 footgun) ────────────────────────────────


class TestCanonicalRegion:
    """Indicator-aware дефолт региона: prod-layout = CBR-ипотека под 'sverdl',
    key_rate / прочее под 'rf' (см. 123_macro_indicator.sql §2)."""

    def test_all_six_mortgage_indicators_resolve_sverdl(self) -> None:
        # Источник истины — _CBR_SERIES_MAP (зеркало миграции): все 6 mortgage_*.
        mortgage_types = {itype for itype, _unit in _CBR_SERIES_MAP.values()}
        assert len(mortgage_types) == 6  # guard: набор не «усох»
        for itype in mortgage_types:
            assert _canonical_region(itype) == "sverdl", itype

    def test_key_rate_resolves_rf(self) -> None:
        assert _canonical_region("key_rate") == "rf"

    def test_unknown_indicator_defaults_rf(self) -> None:
        # Любой не-ипотечный / неизвестный тип → 'rf' (национальный дефолт).
        assert _canonical_region("cpi") == "rf"
        assert _canonical_region("mortgage_rate_primary_domrf") == "rf"  # это region='rf'
        assert _canonical_region("totally_unknown") == "rf"


# ── reader region resolution end-to-end (mock Session) ─────────────────────────


class TestReaderRegionResolution:
    """Какой region реально биндится в SQL ридером при/без явного region.

    Покрывает контракт #945: дефолт indicator-aware, явный region — выигрывает.
    """

    def _bound_region(self, db: MagicMock) -> str:
        _args, _kwargs = db.execute.call_args
        return _args[1]["region"]

    def test_series_mortgage_no_region_binds_sverdl(self) -> None:
        db = _mock_db([])
        get_macro_series(db, "mortgage_rate_weighted")
        assert self._bound_region(db) == "sverdl"

    def test_series_key_rate_no_region_binds_rf(self) -> None:
        db = _mock_db([])
        get_macro_series(db, "key_rate")
        assert self._bound_region(db) == "rf"

    def test_series_explicit_region_overrides_default(self) -> None:
        # Явный 'rf' для ипотечного типа — caller override выигрывает над 'sverdl'.
        db = _mock_db([])
        get_macro_series(db, "mortgage_debt", region="rf")
        assert self._bound_region(db) == "rf"

    def test_series_existing_sverdl_caller_unchanged(self) -> None:
        # Регресс на текущего вызывателя (macro_series.py:372 region="sverdl").
        db = _mock_db([])
        get_macro_series(db, "mortgage_issued_count", region="sverdl")
        assert self._bound_region(db) == "sverdl"

    def test_latest_mortgage_no_region_binds_sverdl(self) -> None:
        db = _mock_db([])
        get_latest_macro(db, "mortgage_overdue")
        assert self._bound_region(db) == "sverdl"

    def test_latest_key_rate_no_region_binds_rf(self) -> None:
        db = _mock_db([])
        get_latest_macro(db, "key_rate")
        assert self._bound_region(db) == "rf"

    def test_latest_explicit_region_overrides_default(self) -> None:
        db = _mock_db([])
        get_latest_macro(db, "mortgage_term", region="rf")
        assert self._bound_region(db) == "rf"


# ── консистентность Python-зеркала с SQL-миграцией ────────────────────────────


class TestMirrorConsistency:
    def test_migration_file_exists(self) -> None:
        assert _MIGRATION.is_file(), f"migration not found: {_MIGRATION}"

    def test_all_indicator_types_present_in_migration(self) -> None:
        """Каждый indicator_type из Python-зеркала встречается в тексте миграции."""
        sql = _MIGRATION.read_text(encoding="utf-8")
        for indicator_type, _unit in _CBR_SERIES_MAP.values():
            assert indicator_type in sql, (
                f"indicator_type {indicator_type!r} есть в Python-зеркале, "
                f"но отсутствует в миграции 123_macro_indicator.sql"
            )

    def test_all_series_ids_present_in_migration(self) -> None:
        """Каждый series_id-ключ Python-зеркала присутствует в CASE миграции."""
        sql = _MIGRATION.read_text(encoding="utf-8")
        for series_id in _CBR_SERIES_MAP:
            assert series_id in sql, (
                f"series_id {series_id!r} есть в Python-зеркале, "
                f"но отсутствует в миграции 123_macro_indicator.sql"
            )

    def test_domrf_primary_type_present(self) -> None:
        sql = _MIGRATION.read_text(encoding="utf-8")
        assert "mortgage_rate_primary_domrf" in sql

    def test_migration_balanced_transaction(self) -> None:
        sql = _MIGRATION.read_text(encoding="utf-8")
        assert sql.count("BEGIN;") == 1
        assert sql.count("COMMIT;") == 1
        assert "ON CONFLICT" in sql
        assert "macro_indicator" in sql


# ── reader SQL shape (mock Session, без БД) ───────────────────────────────────


def _mock_db(rows: list) -> MagicMock:
    """MagicMock-сессия: db.execute(...).all() и .first() возвращают подготовленное."""
    db = MagicMock()
    result = MagicMock()
    result.all.return_value = rows
    result.first.return_value = rows[0] if rows else None
    db.execute.return_value = result
    return db


def _executed_sql(db: MagicMock) -> str:
    """Текст SQL первого db.execute (TextClause → str)."""
    args, _kwargs = db.execute.call_args
    return str(args[0])


class TestGetMacroSeries:
    def test_returns_ordered_float_tuples(self) -> None:
        rows = [(dt.date(2019, 2, 1), 9.5), (dt.date(2019, 3, 1), 9.4)]
        db = _mock_db(rows)
        out = get_macro_series(db, "mortgage_rate_weighted")
        assert out == [(dt.date(2019, 2, 1), 9.5), (dt.date(2019, 3, 1), 9.4)]
        assert all(isinstance(v, float) for _d, v in out)

    def test_empty(self) -> None:
        db = _mock_db([])
        assert get_macro_series(db, "key_rate") == []

    def test_default_region_is_indicator_aware(self) -> None:
        """No region → mortgage_* резолвится в 'sverdl' (его prod-layout), не 'rf'.

        Регресс на #945-footgun: раньше дефолт был жёстко 'rf' → mortgage_*-ряд
        молча возвращал пусто. Проверяем РЕАЛЬНО забинженный в запрос region.
        """
        db = _mock_db([])
        get_macro_series(db, "mortgage_debt")
        _args, _kwargs = db.execute.call_args
        params = _args[1]
        assert params["itype"] == "mortgage_debt"
        assert params["region"] == "sverdl"
        assert params["since"] is None

    def test_since_passed_through(self) -> None:
        db = _mock_db([])
        since = dt.date(2024, 1, 1)
        get_macro_series(db, "mortgage_debt", region="sverdl", since=since)
        _args, _kwargs = db.execute.call_args
        params = _args[1]
        assert params["region"] == "sverdl"
        assert params["since"] == since

    def test_sql_uses_cast_not_double_colon(self) -> None:
        db = _mock_db([])
        get_macro_series(db, "mortgage_debt")
        sql = _executed_sql(db)
        assert "CAST(:itype AS text)" in sql
        assert "CAST(:region AS text)" in sql
        assert "CAST(:since AS date)" in sql
        # psycopg v3 trap: никаких :name::type
        assert ":itype::" not in sql
        assert ":region::" not in sql
        assert ":since::" not in sql

    def test_sql_orders_ascending(self) -> None:
        db = _mock_db([])
        get_macro_series(db, "mortgage_debt")
        assert "ORDER BY obs_date ASC" in _executed_sql(db)


class TestGetLatestMacro:
    def test_returns_tuple(self) -> None:
        db = _mock_db([(dt.date(2026, 2, 1), 11.0)])
        assert get_latest_macro(db, "mortgage_rate_weighted") == (dt.date(2026, 2, 1), 11.0)

    def test_none_when_empty(self) -> None:
        db = _mock_db([])
        assert get_latest_macro(db, "key_rate") is None

    def test_value_coerced_to_float(self) -> None:
        from decimal import Decimal

        db = _mock_db([(dt.date(2026, 2, 1), Decimal("11.0"))])
        result = get_latest_macro(db, "mortgage_rate_weighted")
        assert result is not None
        _d, v = result
        assert isinstance(v, float)
        assert v == 11.0

    def test_sql_orders_desc_limit_1(self) -> None:
        db = _mock_db([])
        get_latest_macro(db, "mortgage_debt")
        sql = _executed_sql(db)
        assert "ORDER BY obs_date DESC" in sql
        assert "LIMIT 1" in sql
        assert "CAST(:itype AS text)" in sql
