"""Unit-тесты monthly ряда продаж по сегменту (#951c, ТЗ §9.6, Y-ось регрессии).

Чистые тесты (без живой БД):
  • price_bucket_of — границы band'ов (включительно слева), None/≤0 → 'unknown'.
  • room_area_bucket_of — rooms→bucket, area≥80 override, unknown-кейсы.
  • log_diff — Δln, [0]=None всегда, ноль/None/neg → None (не −inf), длина.
  • fill_month_grid — zero-fill месяцев (units=0 НАСТОЯЩИЙ, area/price=None).
  • SalesSeries.as_dict / SegmentSpec.as_dict — округление + None survive.
  • build_sales_series через MagicMock-сессию: правильная таблица (Source A vs B),
    GROUP BY date_trunc для Source B, CAST(:x AS type) не :x::type, case-handling
    класса (LOWER=LOWER), zero-fill месяцев, тиры confidence, graceful empty → low.

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

from __future__ import annotations

import contextlib
import datetime as dt
import math
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.services.forecasting.macro_series import _month_grid, _month_start, _shift_months
from app.services.forecasting.sales_series import (
    PRICE_BUCKET_BUSINESS,
    PRICE_BUCKET_COMFORT,
    PRICE_BUCKET_ECONOMY,
    PRICE_BUCKET_PREMIUM,
    PRICE_BUCKET_UNKNOWN,
    ROOM_AREA_BUCKET_1K,
    ROOM_AREA_BUCKET_2K,
    ROOM_AREA_BUCKET_3K,
    ROOM_AREA_BUCKET_LARGE,
    ROOM_AREA_BUCKET_STUDIO,
    ROOM_AREA_BUCKET_UNKNOWN,
    SalesSeries,
    SegmentSpec,
    _confidence,
    build_sales_series,
    fill_month_grid,
    log_diff,
    price_bucket_of,
    room_area_bucket_of,
)

# Резолвер admin→micro (Step 2). Обе SQL-ветки (Source A crm + Source B objective_lots)
# теперь резолвят spec.district → набор микро. Патчим на identity raw-микро, чтобы
# db.execute не получал лишний resolver-запрос; отдельный класс TestDistrictResolution
# проверяет, что резолвнутые микро реально идут в SQL-параметры.
_RESOLVE = "app.services.forecasting.sales_series.resolve_objective_districts"


@pytest.fixture(autouse=True)
def _patch_resolver() -> Iterator[MagicMock]:
    """По умолчанию резолвер = identity raw-микро (district→[district], None→None)."""
    with patch(_RESOLVE) as m:
        m.side_effect = lambda _db, d: [d] if d is not None else None
        yield m


# ── pure: price_bucket_of ─────────────────────────────────────────────────────


class TestPriceBucketOf:
    def test_economy_below_120k(self) -> None:
        assert price_bucket_of(90_000) == PRICE_BUCKET_ECONOMY
        assert price_bucket_of(119_999) == PRICE_BUCKET_ECONOMY

    def test_comfort_band(self) -> None:
        # Граница 120k включительна слева (lo ≤ x < hi).
        assert price_bucket_of(120_000) == PRICE_BUCKET_COMFORT
        assert price_bucket_of(159_999) == PRICE_BUCKET_COMFORT

    def test_business_band(self) -> None:
        assert price_bucket_of(160_000) == PRICE_BUCKET_BUSINESS
        assert price_bucket_of(219_999) == PRICE_BUCKET_BUSINESS

    def test_premium_at_and_above_220k(self) -> None:
        assert price_bucket_of(220_000) == PRICE_BUCKET_PREMIUM
        assert price_bucket_of(500_000) == PRICE_BUCKET_PREMIUM

    def test_none_is_unknown(self) -> None:
        assert price_bucket_of(None) == PRICE_BUCKET_UNKNOWN

    def test_zero_and_negative_unknown(self) -> None:
        # Цена ≤ 0 бессмысленна → unknown (не подмешиваем в реальные band'ы).
        assert price_bucket_of(0) == PRICE_BUCKET_UNKNOWN
        assert price_bucket_of(-5) == PRICE_BUCKET_UNKNOWN

    def test_float_input(self) -> None:
        assert price_bucket_of(155_500.75) == PRICE_BUCKET_COMFORT


# ── pure: room_area_bucket_of ─────────────────────────────────────────────────


class TestRoomAreaBucketOf:
    def test_studio_zero_rooms(self) -> None:
        # objective: 0 = студия.
        assert room_area_bucket_of(0, 25.0) == ROOM_AREA_BUCKET_STUDIO

    def test_studio_negative_treated_as_studio(self) -> None:
        assert room_area_bucket_of(-1, 22.0) == ROOM_AREA_BUCKET_STUDIO

    def test_one_room(self) -> None:
        assert room_area_bucket_of(1, 38.0) == ROOM_AREA_BUCKET_1K

    def test_two_rooms(self) -> None:
        assert room_area_bucket_of(2, 55.0) == ROOM_AREA_BUCKET_2K

    def test_three_rooms(self) -> None:
        assert room_area_bucket_of(3, 70.0) == ROOM_AREA_BUCKET_3K

    def test_four_plus_rooms_large(self) -> None:
        assert room_area_bucket_of(4, 95.0) == ROOM_AREA_BUCKET_LARGE
        assert room_area_bucket_of(5, 120.0) == ROOM_AREA_BUCKET_LARGE

    def test_area_override_pushes_small_rooms_to_large(self) -> None:
        # Площадь ≥ 80 м² → '80+' независимо от комнатности (зеркало _BUCKET_PRETTY).
        assert room_area_bucket_of(1, 85.0) == ROOM_AREA_BUCKET_LARGE
        assert room_area_bucket_of(2, 80.0) == ROOM_AREA_BUCKET_LARGE

    def test_area_just_below_threshold_keeps_room_bucket(self) -> None:
        # 79.9 < 80 → решаем по комнатности.
        assert room_area_bucket_of(2, 79.9) == ROOM_AREA_BUCKET_2K

    def test_rooms_none_area_none_unknown(self) -> None:
        assert room_area_bucket_of(None, None) == ROOM_AREA_BUCKET_UNKNOWN

    def test_rooms_none_large_area_is_large(self) -> None:
        # Комнат нет, но площадь ≥ 80 → большой (area override срабатывает первым).
        assert room_area_bucket_of(None, 90.0) == ROOM_AREA_BUCKET_LARGE

    def test_rooms_none_small_area_unknown(self) -> None:
        # Без комнатности тонкий формат не определить → unknown.
        assert room_area_bucket_of(None, 40.0) == ROOM_AREA_BUCKET_UNKNOWN

    def test_rooms_known_area_none(self) -> None:
        # Площадь неизвестна → решаем чисто по комнатности.
        assert room_area_bucket_of(1, None) == ROOM_AREA_BUCKET_1K


# ── pure: log_diff ────────────────────────────────────────────────────────────


class TestLogDiff:
    def test_first_element_always_none(self) -> None:
        assert log_diff([10, 20, 30])[0] is None

    def test_basic_log_difference(self) -> None:
        out = log_diff([10, 20])
        assert out[0] is None
        assert out[1] is not None
        assert math.isclose(out[1], math.log(20) - math.log(10))

    def test_length_matches_input(self) -> None:
        assert len(log_diff([1, 2, 3, 4, 5])) == 5

    def test_zero_current_is_none(self) -> None:
        # ln(0) = −inf → помечаем None (0 продаж — валидный уровень, не Δln).
        out = log_diff([10, 0, 10])
        assert out[1] is None  # cur=0
        assert out[2] is None  # prev=0

    def test_none_in_series_yields_none(self) -> None:
        out = log_diff([10, None, 30])
        assert out[1] is None  # cur=None
        assert out[2] is None  # prev=None

    def test_negative_yields_none(self) -> None:
        # ln(neg) не определён → None.
        out = log_diff([10, -5])
        assert out[1] is None

    def test_empty(self) -> None:
        assert log_diff([]) == []

    def test_single_element(self) -> None:
        assert log_diff([42]) == [None]

    def test_no_minus_inf_anywhere(self) -> None:
        # Гарантия: ни одна точка не −inf/nan (главная цель zero-handling).
        out = log_diff([0, 5, 0, 8, None, 3])
        for v in out:
            assert v is None or (math.isfinite(v))


# ── pure: fill_month_grid ─────────────────────────────────────────────────────


class TestFillMonthGrid:
    def test_zero_fill_missing_months(self) -> None:
        grid = _month_grid(dt.date(2024, 1, 1), dt.date(2024, 3, 1))
        by_month = {dt.date(2024, 2, 1): (5, 250.0, 150_000.0)}
        units, area, price = fill_month_grid(by_month, grid)
        # Январь и март без сделок → units=0 (НАСТОЯЩИЙ ноль), area/price=None.
        assert units == [0, 5, 0]
        assert area == [None, 250.0, None]
        assert price == [None, 150_000.0, None]

    def test_zero_is_real_not_none(self) -> None:
        # Ключевое отличие: пропущенный месяц = 0 units (не None) — 0 это данные.
        grid = _month_grid(dt.date(2024, 1, 1), dt.date(2024, 1, 1))
        units, _area, _price = fill_month_grid({}, grid)
        assert units == [0]
        assert units[0] == 0 and units[0] is not None

    def test_present_month_passes_through(self) -> None:
        grid = [dt.date(2024, 5, 1)]
        by_month = {dt.date(2024, 5, 1): (12, 600.0, 140_000.0)}
        units, area, price = fill_month_grid(by_month, grid)
        assert (units, area, price) == ([12], [600.0], [140_000.0])

    def test_area_price_none_when_units_present_but_value_missing(self) -> None:
        # Сделки есть, но area/price NULL в источнике → None сохраняется.
        grid = [dt.date(2024, 5, 1)]
        by_month = {dt.date(2024, 5, 1): (3, None, None)}
        units, area, price = fill_month_grid(by_month, grid)
        assert units == [3]
        assert area == [None]
        assert price == [None]

    def test_keys_normalised_to_first_of_month(self) -> None:
        # Ключ-середина месяца нормализуется к 1-му числу.
        grid = [dt.date(2024, 5, 1)]
        by_month = {dt.date(2024, 5, 17): (7, 350.0, 130_000.0)}
        units, area, price = fill_month_grid(by_month, grid)
        assert units == [7]
        assert area == [350.0]
        assert price == [130_000.0]

    def test_does_not_mutate_input(self) -> None:
        by_month = {dt.date(2024, 5, 1): (1, 50.0, 100_000.0)}
        fill_month_grid(by_month, [dt.date(2024, 5, 1)])
        assert by_month == {dt.date(2024, 5, 1): (1, 50.0, 100_000.0)}


# ── pure: _confidence ─────────────────────────────────────────────────────────


class TestConfidence:
    def test_high_at_24_nonzero(self) -> None:
        assert _confidence([1] * 24) == "high"

    def test_medium_at_12_nonzero(self) -> None:
        assert _confidence([1] * 12) == "medium"

    def test_low_below_12_nonzero(self) -> None:
        assert _confidence([1] * 11) == "low"

    def test_zeros_do_not_count(self) -> None:
        # 30 месяцев, но только 5 ненулевых → low (хвост нулей не информативен).
        units = [1] * 5 + [0] * 25
        assert _confidence(units) == "low"

    def test_high_with_zeros_mixed(self) -> None:
        # 24 ненулевых + сколько угодно нулей → high (порог по ненулевым).
        units = [1] * 24 + [0] * 10
        assert _confidence(units) == "high"

    def test_empty_is_low(self) -> None:
        assert _confidence([]) == "low"


# ── SalesSeries / SegmentSpec as_dict ─────────────────────────────────────────


class TestAsDict:
    def test_sales_series_rounds_and_serialises(self) -> None:
        s = SalesSeries(
            months=[dt.date(2024, 1, 1), dt.date(2024, 2, 1)],
            units=[5, 0],
            area_m2=[250.456, None],
            avg_price_per_m2=[150_123.7, None],
            n_months=2,
            source="objective_lots",
            segment={
                "obj_class": "комфорт",
                "room_bucket": None,
                "district": None,
                "price_bucket": None,
            },
            confidence="low",
        )
        d = s.as_dict()
        assert d["months"] == ["2024-01-01", "2024-02-01"]
        assert d["units"] == [5, 0]
        assert d["area_m2"] == [250.5, None]
        assert d["avg_price_per_m2"] == [150_124, None]
        assert d["n_months"] == 2
        assert d["source"] == "objective_lots"
        assert d["confidence"] == "low"
        assert d["segment"]["obj_class"] == "комфорт"

    def test_units_zero_survives_as_zero(self) -> None:
        # as_dict не должен превращать 0 в None.
        s = SalesSeries(
            months=[dt.date(2024, 1, 1)],
            units=[0],
            area_m2=[None],
            avg_price_per_m2=[None],
            n_months=1,
            source="corpus_room_month",
            segment={},
            confidence="low",
        )
        assert s.as_dict()["units"] == [0]

    def test_segment_spec_as_dict_subset(self) -> None:
        spec = SegmentSpec(obj_class="Комфорт", district="Автовокзал")
        assert spec.as_dict() == {
            "obj_class": "Комфорт",
            "room_bucket": None,
            "district": "Автовокзал",
            "price_bucket": None,
        }


# ── build_sales_series: MagicMock-сессия (форма SQL + zero-fill + graceful) ────


def _result(rows: list[dict]) -> MagicMock:
    """Результат db.execute(...).mappings().all() → rows (list of dict-like)."""
    res = MagicMock()
    res.mappings.return_value.all.return_value = rows
    return res


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


def _params_of(db: MagicMock, call_idx: int = 0) -> dict:
    return db.execute.call_args_list[call_idx].args[1]


class TestBuildSalesSeriesSourceShape:
    def test_source_a_queries_corpus_table(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(obj_class="Комфорт"),
            source="corpus_room_month",
            months_back=3,
        )
        sql = _sql_of(db)
        assert "objective_corpus_room_month" in sql
        assert "deals_total_count" in sql
        assert "deals_total_vol_m2" in sql
        assert "deals_total_avg_price_thousand_rub_per_m2" in sql

    def test_source_b_queries_lots_with_date_trunc_groupby(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=3,
        )
        sql = _sql_of(db)
        assert "objective_lots" in sql
        # Source B группирует по месяцу РЕГИСТРАЦИИ через date_trunc.
        assert "date_trunc('month', ol.registration_date)" in sql
        assert "GROUP BY month" in sql
        assert "COUNT(*)" in sql

    def test_source_a_uses_cast_not_double_colon(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(obj_class="Бизнес", district="Центр", room_bucket="2"),
            source="corpus_room_month",
            months_back=3,
        )
        sql = _sql_of(db)
        assert "CAST(:since AS date)" in sql
        assert "CAST(:cls AS text)" in sql
        # district теперь резолвится в набор микро → ANY(CAST(:districts AS text[])).
        assert "CAST(:districts AS text[])" in sql
        assert "CAST(:room_bucket AS text)" in sql
        # psycopg v3 trap: никаких :name::type.
        assert "::" not in sql

    def test_source_b_uses_cast_not_double_colon(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(price_bucket="комфорт"),
            source="objective_lots",
            months_back=3,
        )
        sql = _sql_of(db)
        assert "CAST(:since AS date)" in sql
        assert "CAST(:premise_kind AS text)" in sql
        assert "CAST(:large_area AS numeric)" in sql
        assert "CAST(:price_bucket AS text)" in sql
        assert "::" not in sql

    def test_class_case_insensitive_match_both_sources(self) -> None:
        # Source A — Title-case в БД, Source B — lowercase: оба матчат LOWER=LOWER.
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(obj_class="Комфорт"),
            source="corpus_room_month",
            months_back=1,
        )
        assert "LOWER(crm.class) = LOWER(CAST(:cls AS text))" in _sql_of(db)

        db2 = MagicMock()
        db2.execute.return_value = _result([])
        build_sales_series(
            db2,
            spec=SegmentSpec(obj_class="комфорт"),
            source="objective_lots",
            months_back=1,
        )
        assert "LOWER(ol.class) = LOWER(CAST(:cls AS text))" in _sql_of(db2)

    def test_source_a_params_pass_spec(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(obj_class="Комфорт", district="Уралмаш", room_bucket="1"),
            source="corpus_room_month",
            months_back=12,
        )
        params = _params_of(db)
        assert params["cls"] == "Комфорт"
        # district резолвится в набор микро (identity → ['Уралмаш']) → ANY(:districts).
        assert params["has_district"] is True
        assert params["districts"] == ["Уралмаш"]
        assert "district" not in params
        assert params["room_bucket"] == "1"
        assert isinstance(params["since"], dt.date)

    def test_source_b_passes_bucket_thresholds_and_labels(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=1,
        )
        params = _params_of(db)
        # Пороги/метки bucket'ов передаются параметрами (зеркало pure-helpers).
        assert params["large_area"] == 80.0
        assert params["p_economy_max"] == 120_000.0
        assert params["b_studio"] == ROOM_AREA_BUCKET_STUDIO
        assert params["p_premium"] == PRICE_BUCKET_PREMIUM
        assert params["premise_kind"] == "квартира"

    def test_source_b_custom_premise_kind(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=1,
            premise_kind="нежилое",
        )
        assert _params_of(db)["premise_kind"] == "нежилое"


class TestBuildSalesSeriesLogic:
    def test_continuous_grid_with_zero_fill(self) -> None:
        today = dt.date.today()
        target = _shift_months(today, -1)
        db = MagicMock()
        db.execute.return_value = _result(
            [{"month": target, "units": 7, "area_m2": 350.0, "avg_price_per_m2": 145_000.0}]
        )
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=3,
        )
        # Непрерывная сетка 4 месяца (-3..0).
        assert out.n_months == 4
        assert len(out.months) == 4
        assert out.months == sorted(out.months)
        idx = out.months.index(target)
        assert out.units[idx] == 7
        assert out.area_m2[idx] == 350.0
        assert out.avg_price_per_m2[idx] == 145_000.0
        # Месяцы без сделок → units=0 (настоящий), area/price=None.
        other = [i for i in range(out.n_months) if i != idx]
        for i in other:
            assert out.units[i] == 0
            assert out.area_m2[i] is None
            assert out.avg_price_per_m2[i] is None

    def test_source_a_price_scaled_to_rub_per_m2(self) -> None:
        # Source A SQL умножает тыс.₽/м² на 1000 — проверяем наличие масштаба в SQL.
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(),
            source="corpus_room_month",
            months_back=1,
        )
        assert "* 1000.0" in _sql_of(db)

    def test_confidence_high_with_24_nonzero(self) -> None:
        today = dt.date.today()
        rows = [
            {
                "month": _shift_months(today, -k),
                "units": 3,
                "area_m2": 100.0,
                "avg_price_per_m2": 130_000.0,
            }
            for k in range(24)
        ]
        db = MagicMock()
        db.execute.return_value = _result(rows)
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=30,
        )
        assert out.confidence == "high"

    def test_confidence_medium_with_12_nonzero(self) -> None:
        today = dt.date.today()
        rows = [
            {
                "month": _shift_months(today, -k),
                "units": 2,
                "area_m2": 80.0,
                "avg_price_per_m2": 120_000.0,
            }
            for k in range(12)
        ]
        db = MagicMock()
        db.execute.return_value = _result(rows)
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=20,
        )
        assert out.confidence == "medium"

    def test_confidence_low_thin_data(self) -> None:
        today = dt.date.today()
        rows = [
            {
                "month": _shift_months(today, -k),
                "units": 1,
                "area_m2": 40.0,
                "avg_price_per_m2": 110_000.0,
            }
            for k in range(3)
        ]
        db = MagicMock()
        db.execute.return_value = _result(rows)
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=12,
        )
        assert out.confidence == "low"

    def test_segment_recorded_in_result(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        spec = SegmentSpec(
            obj_class="Комфорт", room_bucket="2", district="Центр", price_bucket="бизнес"
        )
        out = build_sales_series(
            db,
            spec=spec,
            source="objective_lots",
            months_back=1,
        )
        assert out.segment == spec.as_dict()
        assert out.source == "objective_lots"


class TestBuildSalesSeriesGraceful:
    def test_empty_data_returns_zero_filled_low(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=2,
        )
        assert out.n_months == 3  # -2..0
        assert out.units == [0, 0, 0]
        assert out.area_m2 == [None, None, None]
        assert out.avg_price_per_m2 == [None, None, None]
        assert out.confidence == "low"

    def test_db_exception_graceful_source_a(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("db down")
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="corpus_room_month",
            months_back=2,
        )
        # Ряд по сетке всё равно, zero-filled, low (НЕ crash).
        assert out.n_months == 3
        assert out.units == [0, 0, 0]
        assert out.confidence == "low"

    def test_db_exception_graceful_source_b(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("db down")
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=1,
        )
        assert out.n_months == 2
        assert out.units == [0, 0]
        assert out.confidence == "low"

    def test_negative_months_back_clamps_to_single_month(self) -> None:
        # months_back<0 клампится к 0 (как PR2: -max(0, months_back)) → один
        # текущий месяц, валидный объект, low (НЕ crash, НЕ отрицательная сетка).
        db = MagicMock()
        db.execute.return_value = _result([])
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=-5,
        )
        assert out.n_months == 1
        assert out.months == [_month_start(dt.date.today())]
        assert out.units == [0]
        assert out.confidence == "low"

    def test_month_back_zero_single_month(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        out = build_sales_series(
            db,
            spec=SegmentSpec(),
            source="objective_lots",
            months_back=0,
        )
        assert out.n_months == 1  # только текущий месяц
        assert out.units == [0]


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


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

    def mappings(self) -> _FakeSalesResult:
        return self

    def all(self) -> list[Any]:
        return []


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

    `execute()` бросает РОВНО один раз и, как реальный 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) -> _FakeSalesResult:
        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 sales query failure")
        return _FakeSalesResult()

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


class TestSourceQuerySavepointDoesNotPoisonSession:
    """`_query_source_a`/`_query_source_b` оборачивают db.execute в SAVEPOINT."""

    def test_source_a_failure_leaves_session_usable(self) -> None:
        db = _PoisonableSession()
        out = build_sales_series(db, spec=SegmentSpec(), source="corpus_room_month", months_back=1)
        # сбой запроса → zero-filled сетка, low confidence (fallback, НЕ crash).
        assert out.confidence == "low"
        assert out.units == [0, 0]
        # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
        # сбоя проходит без 'current transaction aborted' (не poisoned).
        db.execute("SELECT 1")

    def test_source_b_failure_leaves_session_usable(self) -> None:
        db = _PoisonableSession()
        out = build_sales_series(db, spec=SegmentSpec(), source="objective_lots", months_back=1)
        assert out.confidence == "low"
        assert out.units == [0, 0]
        db.execute("SELECT 1")


class TestDistrictResolution:
    """Step 2: spec.district (админ-имя ЕКБ) резолвится в МИКРО-набор в SQL-фильтре.

    Оба источника используют МИКРО-вокабуляр: objective_corpus_room_month.district и
    objective_lots.district несут одни и те же 35 informal-микро (verified на prod) →
    админ-имя по ним давало 0 строк. Резолвер разворачивает его в чистые микро.
    """

    def test_source_a_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None:
        _patch_resolver.side_effect = lambda _db, d: (
            ["Втузгородок", "ЖБИ"] if d == "Кировский" else None
        )
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(district="Кировский"),
            source="corpus_room_month",
            months_back=1,
        )
        assert _patch_resolver.call_args.args[1] == "Кировский"
        p = _params_of(db)
        assert p["has_district"] is True
        assert p["districts"] == ["Втузгородок", "ЖБИ"]
        assert "Кировский" not in p["districts"]

    def test_source_b_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None:
        _patch_resolver.side_effect = lambda _db, d: (
            ["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None
        )
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(district="Орджоникидзевский"),
            source="objective_lots",
            months_back=1,
        )
        p = _params_of(db)
        assert p["has_district"] is True
        assert p["districts"] == ["Уралмаш", "Эльмаш"]
        assert "Орджоникидзевский" not in p["districts"]

    def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None:
        _patch_resolver.side_effect = lambda _db, _d: None
        db = MagicMock()
        db.execute.return_value = _result([])
        build_sales_series(
            db,
            spec=SegmentSpec(district="не определён"),
            source="objective_lots",
            months_back=1,
        )
        p = _params_of(db)
        assert p["has_district"] is False
        assert p["districts"] == []
