"""Tests for recommend_mix per-bucket velocity (fix #574).

Проверяет:
1. Velocity varies per bucket based on MARKET rosreestr deals (static mix bug fixed).
2. Срок продажи реалистичный (12-24 мес) при rosreestr fallback —
   нормировка по district+class competitors, НЕ region-wide ЖК.
3. Per-bucket velocities are independent constants (не производные от share_pct).
4. Rosreestr fallback uses district+class competitors_weighted (NOT ~442 region-wide).
5. Objective per-bucket path correctly applies per-bucket medians.

Mock-based — не требуют живой БД. Тесты работают через patch() helper-функций
analytics_queries + прямые unit-тесты helper-функций.
"""

from __future__ import annotations

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

import pytest

# Путь к тестируемому модулю
_MOD = "app.services.analytics_queries"


# ── Константы тестовых данных ────────────────────────────────────────────────

# Примерные district+class rosreestr данные: 5 бакетов, ~3800 сделок за 24 мес.
_CITY_BUCKET_DEALS = {
    "1-Студия": 710,
    "2-1-к": 1306,
    "3-2-к": 980,
    "4-3-к": 560,
    "5-80+ м²": 244,
}
_TOTAL_DEALS = sum(_CITY_BUCKET_DEALS.values())  # 3800


def _make_bucket_row(bucket_id: str, deals: int, area_avg: float = 40.0) -> MagicMock:
    r = MagicMock()
    data = {
        "bucket": bucket_id,
        "deals": deals,
        "area_avg": area_avg,
        "area_median": area_avg * 0.95,
        "price_median": 110_000.0,
        "price_p25": 100_000.0,
        "price_p75": 120_000.0,
    }
    r.__getitem__ = lambda self, k: data[k]
    return r


def _city_bucket_rows() -> list[MagicMock]:
    area_by_bucket = {
        "1-Студия": 27.0,
        "2-1-к": 38.0,
        "3-2-к": 55.0,
        "4-3-к": 72.0,
        "5-80+ м²": 95.0,
    }
    return [
        _make_bucket_row(bid, deals, area_by_bucket.get(bid, 40.0))
        for bid, deals in _CITY_BUCKET_DEALS.items()
    ]


# ── Helpers для unit-tests helper-функций ───────────────────────────────────


def _make_scalar_result(value: Any) -> MagicMock:
    r = MagicMock()
    r.scalar.return_value = value
    return r


def _make_mapping_result(rows: list) -> MagicMock:
    r = MagicMock()
    r.mappings.return_value.all.return_value = rows
    r.mappings.return_value.first.return_value = rows[0] if rows else None
    return r


# ── Tests: helper-функции ───────────────────────────────────────────────────


class TestVelocityBaselinePerBucket:
    """Unit tests для _velocity_baseline_per_bucket."""

    def test_returns_none_when_no_rows(self) -> None:
        from app.services.analytics_queries import _velocity_baseline_per_bucket

        db = MagicMock()
        db.execute.return_value.mappings.return_value.all.return_value = []
        result = _velocity_baseline_per_bucket(
            db, region_code=66, district_name="Ленинский", target_class=None
        )
        assert result is None

    def test_returns_per_bucket_velocities(self) -> None:
        from app.services.analytics_queries import _velocity_baseline_per_bucket

        db = MagicMock()
        rows = []
        for bid, median_pm, obs in [
            ("1-Студия", 2.5, 10),
            ("2-1-к", 4.8, 15),
            ("3-2-к", 3.2, 12),
        ]:
            r = MagicMock()
            r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: {
                "bucket_id": _bid,
                "median_pm": _med,
                "observations": _obs,
            }[k]
            rows.append(r)
        db.execute.return_value.mappings.return_value.all.return_value = rows

        result = _velocity_baseline_per_bucket(
            db, region_code=66, district_name="Ленинский", target_class=None
        )
        assert result is not None
        assert "1-Студия" in result
        assert result["1-Студия"] == pytest.approx(2.5, rel=0.01)
        assert result["2-1-к"] == pytest.approx(4.8, rel=0.01)

    def test_skips_buckets_with_few_observations(self) -> None:
        """Бакеты с < 3 наблюдениями пропускаются."""
        from app.services.analytics_queries import _velocity_baseline_per_bucket

        db = MagicMock()
        rows = []
        for bid, median_pm, obs in [
            ("1-Студия", 3.0, 2),  # < 3 наблюдений → пропускаем
            ("2-1-к", 5.0, 10),  # OK
        ]:
            r = MagicMock()
            r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: {
                "bucket_id": _bid,
                "median_pm": _med,
                "observations": _obs,
            }[k]
            rows.append(r)
        db.execute.return_value.mappings.return_value.all.return_value = rows

        result = _velocity_baseline_per_bucket(
            db, region_code=66, district_name="Ленинский", target_class=None
        )
        assert result is not None
        assert "1-Студия" not in result, "Бакет с < 3 наблюдениями должен быть пропущен"
        assert "2-1-к" in result

    def test_returns_none_when_all_too_few(self) -> None:
        """Если все бакеты с < 3 obs — возвращает None."""
        from app.services.analytics_queries import _velocity_baseline_per_bucket

        db = MagicMock()
        rows = []
        for bid, obs in [("1-Студия", 1), ("2-1-к", 2)]:
            r = MagicMock()
            r.__getitem__ = lambda self, k, _bid=bid, _obs=obs: {
                "bucket_id": _bid,
                "median_pm": 3.0,
                "observations": _obs,
            }[k]
            rows.append(r)
        db.execute.return_value.mappings.return_value.all.return_value = rows

        result = _velocity_baseline_per_bucket(
            db, region_code=66, district_name="Ленинский", target_class=None
        )
        assert result is None


# ── Tests: bucket_market_velocities через rosreestr fallback ─────────────────


class TestRosreestrFallbackPerBucketVelocity:
    """Формула bucket_v = bucket_deals / months / n_comp (district+class competitors)."""

    def _compute_expected_bucket_v(
        self, bucket_id: str, months: int = 24, n_comp: int = 10
    ) -> float:
        deals = _CITY_BUCKET_DEALS[bucket_id]
        return deals / months / n_comp

    def test_studio_velocity_correct(self) -> None:
        """Студии: 710 сделок / 24 мес / 10 ЖК = 2.96 кв/мес."""
        expected = self._compute_expected_bucket_v("1-Студия", n_comp=10)
        assert expected == pytest.approx(710 / 24 / 10, rel=0.01)

    def test_studio_less_than_one_k(self) -> None:
        """Студии имеют меньше сделок чем 1к → меньше velocity."""
        v_studio = self._compute_expected_bucket_v("1-Студия", n_comp=10)
        v_one_k = self._compute_expected_bucket_v("2-1-к", n_comp=10)
        assert v_studio < v_one_k

    def test_velocity_independent_of_share(self) -> None:
        """Velocity бакета НЕ зависит от share_pct пользователя (static-mix fix).

        Суть fix'а #574: per-bucket velocity = MARKET bucket_deals / months / n_comp.
        bucket_deals берётся из рыночного распределения (rosreestr), а НЕ из доли
        бакета в миксе пользователя. Поэтому сдвиг слайдера mix меняет units_planned
        бакета, но НЕ его velocity → aggregate срок реально меняется при изменении mix.
        """
        months, n_comp = 24, 10
        v_studio = 710 / months / n_comp
        v_one_k = 1306 / months / n_comp

        # v_studio/v_one_k == deals_studio/deals_one_k (market ratio, не user share)
        assert v_studio / v_one_k == pytest.approx(710 / 1306, rel=0.01)

    def test_velocity_scales_with_competitor_count(self) -> None:
        """При большем n_comp velocity одного проекта меньше (делим рынок на больше ЖК)."""
        v_few = 710 / 24 / 5  # 5 конкурентов
        v_many = 710 / 24 / 15  # 15 конкурентов
        assert v_few > v_many
        assert v_few == pytest.approx(710 / 24 / 5, rel=0.001)
        assert v_many == pytest.approx(710 / 24 / 15, rel=0.001)


# ── Tests: полный recommend_mix с минимальными моками ───────────────────────


def _make_full_mock_db(has_class_data: bool = False) -> MagicMock:
    """DB mock с разумными ответами на все прямые db.execute() вызовы.

    Все helper-функции (_velocity_baseline, _bucket_distribution, etc.)
    патчатся снаружи через patch(). Этот mock покрывает только ПРЯМЫЕ
    db.execute вызовы внутри recommend_mix:
    1. district_row query
    2. city_median scalar
    3. has_class_data scalar
    4. comparables query (большой → возвращаем пустой список)
    """
    db = MagicMock()

    # district_row
    dr = MagicMock()
    dr.__getitem__ = lambda self, k: {
        "district_name": "Ленинский",
        "zk_count": 12,
        "flat_count": 5000,
        "median_price_per_m2": 110_000.0,
        "mean_price_per_m2": 112_000.0,
    }[k]

    # Сигнатурный диспетчер вместо позиционной последовательности — устойчив к
    # доп. запросам class-пути (#1955: class_multiplier yandex_realty_zk
    # выполняется ТОЛЬКО когда target_class задан → лишний db.execute, который
    # ломал бы жёсткую нумерацию).
    def _dispatch(stmt: Any, params: Any = None) -> MagicMock:
        sql = " ".join(str(stmt).split())
        r = MagicMock()
        # district_row
        if "FROM ekb_districts" in sql and "district_name ILIKE" in sql:
            r.mappings.return_value.first.return_value = dr
        # has_class_data
        elif "FROM domrf_kn_objects" in sql and "obj_class_fallback" in sql:
            r.scalar.return_value = 1 if has_class_data else None
        # class_multiplier (yandex_realty_zk) — только class-путь
        elif "comfort_avg" in sql or "yandex_realty_zk" in sql:
            cr = MagicMock()
            cr.__getitem__ = lambda self, k: {"class_avg": None, "comfort_avg": None}[k]
            r.mappings.return_value.first.return_value = cr
        # comparables → пусто
        elif "latest_agg" in sql or "domrf_kn_sales_agg" in sql:
            r.mappings.return_value.all.return_value = []
        # city_median (любой оставшийся scalar)
        else:
            r.scalar.return_value = 110_000.0
            r.mappings.return_value.all.return_value = []
            r.mappings.return_value.first.return_value = None
        return r

    db.execute.side_effect = _dispatch
    return db


def _run_recommend_mix_full(
    *,
    objective_per_bucket: dict[str, float] | None,
    n_competitors: float = 10.0,
    sale_graph_vel_pm: float | None = None,
    area_total_m2: float = 12_000.0,
    horizon_months: int | None = None,
    cad_num: str | None = None,
    target_class: str | None = None,
    patch_success_ranking: bool = True,
) -> dict[str, Any]:
    """Запускает recommend_mix с правильным набором моков.

    n_competitors — district+class competitors_weighted, который возвращает
    _competitors_two_dim и используется как знаменатель в rosreestr fallback.

    horizon_months/cad_num — #982 forecast-overlay opt-in (по умолчанию None →
    живой микс БАЙТ-в-БАЙТ как раньше; существующие вызовы не затронуты).

    target_class — #1955: проверка перевода english→русский перед success-ranking.
    has_class_data=True когда target_class задан, чтобы class-путь вернул строки.

    patch_success_ranking — если False, НЕ патчим _bucket_success_ranking (даём
    реальной функции/внешнему spy отработать; #1955 translation-тест).
    """
    from app.services.analytics_queries import recommend_mix

    db = _make_full_mock_db(has_class_data=target_class is not None)

    patches = [
        patch(f"{_MOD}._bucket_distribution", return_value=_city_bucket_rows()),
        patch(
            f"{_MOD}._velocity_baseline",
            return_value={
                "realised_per_month_median": sale_graph_vel_pm,
                "realised_per_month_avg": sale_graph_vel_pm,
                "objects_count": 5 if sale_graph_vel_pm else 0,
                "observations": 20 if sale_graph_vel_pm else 0,
            },
        ),
        patch(f"{_MOD}._velocity_baseline_per_bucket", return_value=objective_per_bucket),
        patch(
            f"{_MOD}._elasticity_coef",
            return_value={"elasticity": -1.5, "r2": 0.0, "n": 0, "source": "fallback"},
        ),
        patch(f"{_MOD}._elasticity_per_bucket_coef", return_value={}),
        # competitors_weighted = n_competitors → знаменатель rosreestr fallback
        patch(
            f"{_MOD}._competitors_two_dim",
            return_value=(int(n_competitors), 5, float(n_competitors), "district_2d"),
        ),
        patch(f"{_MOD}._district_market_saturation", return_value=(50.0, 8)),
        patch(f"{_MOD}._district_velocity_trend", return_value=(1.0, 100, 100)),
        patch(f"{_MOD}._district_poi_score", return_value=None),
        patch(f"{_MOD}._city_avg_poi_score", return_value=None),
        patch(
            f"{_MOD}._district_cadastre_baseline",
            return_value={"median_per_m2": None, "buildings_n": 0},
        ),
        patch(f"{_MOD}._current_mortgage_rate", return_value=(None, None)),
        patch(f"{_MOD}._noise_penalty_factor", return_value=(1.0, [])),
        patch(f"{_MOD}._recommend_data_last_updated", return_value=None),
    ]
    if patch_success_ranking:
        patches.append(patch(f"{_MOD}._bucket_success_ranking", return_value=[]))

    with contextlib.ExitStack() as stack:
        for p in patches:
            stack.enter_context(p)
        return recommend_mix(
            db,
            district_name="Ленинский",
            area_total_m2=area_total_m2,
            target_class=target_class,
            months_window=24,
            region_code=66,
            horizon_months=horizon_months,
            cad_num=cad_num,
        )


class TestRealisticSrokFallback:
    """Bug #574 Bug_Velocity_Unrealistic: rosreestr fallback даёт реалистичный срок."""

    def test_market_vel_pm_normalized_by_competitors(self) -> None:
        """scope.market_velocity_per_month = total_deals / months / n_comp.

        n_comp — district+class competitors_weighted (~5-15), НЕ region-wide ЖК (~442).
        """
        n_comp = 10
        months = 24
        result = _run_recommend_mix_full(
            objective_per_bucket=None,
            n_competitors=n_comp,
            sale_graph_vel_pm=None,
        )
        scope = result["scope"]
        total_deals = scope["total_deals"]
        actual_vel = scope["market_velocity_per_month"]
        expected_vel = total_deals / months / n_comp
        assert actual_vel == pytest.approx(expected_vel, rel=0.02), (
            f"market_vel_pm={actual_vel:.4f}, ожидалось {expected_vel:.4f}. "
            "Fallback должен делить на n_comp (district+class competitors)."
        )

    def test_headline_srok_is_realistic_12_to_24_months(self) -> None:
        """Headline срок продажи лежит в реалистичном диапазоне 12-24 мес.

        Это ядро fix'а #574: делим market velocity на district+class competitors
        (5-15 ЖК), а НЕ на region-wide ~442 ЖК. Старый (region-wide) знаменатель
        давал срок 379-1180 мес — заведомо нереалистично для одного проекта.

        Setup: area_total=12000 м², n_comp=10, 3800 рыночных сделок за 24 мес.
        Ожидаемый срок ≈ 18 мес.
        """
        result = _run_recommend_mix_full(
            objective_per_bucket=None,
            n_competitors=10,
            sale_graph_vel_pm=None,
            area_total_m2=12_000.0,
        )
        srok = result["summary"]["months_to_sellout_total"]
        assert srok is not None, "months_to_sellout_total не должен быть None"
        assert 12 <= srok <= 24, (
            f"Срок продажи {srok:.1f} мес вне реалистичного диапазона 12-24. "
            "При region-wide знаменателе (~442 ЖК) срок был бы ~379+ мес (баг #574)."
        )

    def test_srok_realistic_across_competitor_range(self) -> None:
        """Срок остаётся в реалистичном диапазоне при n_comp 5-15."""
        # n_comp=5 (мало конкурентов) → срок ниже; n_comp=15 → выше.
        # Подбираем area так чтобы оба укладывались в реалистичный коридор.
        for n_comp, area, lo, hi in [
            (5, 24_000.0, 12, 24),
            (15, 8_000.0, 12, 24),
        ]:
            result = _run_recommend_mix_full(
                objective_per_bucket=None,
                n_competitors=n_comp,
                sale_graph_vel_pm=None,
                area_total_m2=area,
            )
            srok = result["summary"]["months_to_sellout_total"]
            assert srok is not None
            assert (
                lo <= srok <= hi
            ), f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"

    def test_scope_has_n_competitors(self) -> None:
        """scope.n_competitors присутствует и равен district+class competitors."""
        result = _run_recommend_mix_full(
            objective_per_bucket=None,
            n_competitors=12,
            sale_graph_vel_pm=None,
        )
        assert "n_competitors" in result["scope"]
        assert result["scope"]["n_competitors"] == 12

    def test_velocity_source_is_rosreestr_fallback(self) -> None:
        """velocity_source = rosreestr_fallback когда нет objective данных."""
        result = _run_recommend_mix_full(
            objective_per_bucket=None,
            n_competitors=10,
            sale_graph_vel_pm=None,
        )
        assert result["scope"]["velocity_source"] == "rosreestr_fallback"


class TestPerBucketVelocityVariesByBucket:
    """Bug #574 Bug_Velocity_Mix_Static: velocities per bucket — независимые константы."""

    def test_bucket_velocities_proportional_to_market_deals(self) -> None:
        """Velocity бакета пропорциональна числу РЫНОЧНЫХ сделок в этом бакете.

        Студии (710 сделок) < 1к (1306 сделок) по velocity.
        """
        result = _run_recommend_mix_full(
            objective_per_bucket=None,
            n_competitors=10,
            sale_graph_vel_pm=None,
            area_total_m2=12_000.0,
        )
        buckets_by_name = {b["bucket"]: b for b in result["buckets"]}
        studio_v = buckets_by_name["Студии 15-30"]["velocity_per_month"]
        one_k_v = buckets_by_name["1-к 30-45"]["velocity_per_month"]
        assert studio_v < one_k_v, (
            f"Студии: {studio_v:.4f} кв/мес, 1-к: {one_k_v:.4f} кв/мес. "
            "1-к должны быть быстрее студий (больше сделок на рынке)."
        )

    def test_bucket_velocities_not_all_equal(self) -> None:
        """Velocities бакетов не одинаковы — подтверждает исправление static mix bug."""
        result = _run_recommend_mix_full(
            objective_per_bucket=None,
            n_competitors=10,
            sale_graph_vel_pm=None,
            area_total_m2=12_000.0,
        )
        velocities = [round(b["velocity_per_month"], 6) for b in result["buckets"]]
        unique_velocities = set(velocities)
        assert len(unique_velocities) > 1, (
            f"Все bucket velocities одинаковые ({velocities[0]:.6f}) — "
            "static mix bug не исправлен! Velocities должны отличаться."
        )

    def test_velocity_source_on_each_bucket(self) -> None:
        """Каждый bucket содержит velocity_source."""
        result = _run_recommend_mix_full(
            objective_per_bucket=None,
            n_competitors=10,
            sale_graph_vel_pm=None,
        )
        for b in result["buckets"]:
            assert "velocity_source" in b, f"Бакет '{b['bucket']}' не имеет velocity_source"
            assert b["velocity_source"] in (
                "rosreestr_fallback",
                "objective_per_bucket",
            ), f"Неожиданное velocity_source='{b['velocity_source']}'"


class TestObjectivePerBucketPath:
    """Objective per-bucket path: velocities из objective_corpus_room_month."""

    def test_objective_velocities_applied(self) -> None:
        """Bucket velocities соответствуют per-bucket objective данным × macro_mult.

        sat_factor=1.0 (50% saturation), trend_factor=1.0 → macro_mult=1.0.
        """
        per_bucket = {
            "1-Студия": 3.5,
            "2-1-к": 5.2,
            "3-2-к": 4.1,
            "4-3-к": 2.8,
            "5-80+ м²": 1.2,
        }
        result = _run_recommend_mix_full(
            objective_per_bucket=per_bucket,
            n_competitors=10,
            sale_graph_vel_pm=5.0,
        )
        bkt_map = {b["bucket"]: b for b in result["buckets"]}
        # Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0
        studio = bkt_map.get("Студии 15-30")
        assert studio is not None
        assert studio["velocity_per_month"] == pytest.approx(
            3.5, rel=0.01
        ), f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
        assert studio.get("velocity_source") == "objective_per_bucket"

    def test_objective_velocities_vary(self) -> None:
        """С objective per-bucket данными скорости бакетов разные (проверяем 5 бакетов)."""
        per_bucket = {
            "1-Студия": 2.0,
            "2-1-к": 6.0,
            "3-2-к": 4.5,
            "4-3-к": 3.0,
            "5-80+ м²": 1.5,
        }
        result = _run_recommend_mix_full(
            objective_per_bucket=per_bucket,
            n_competitors=10,
            sale_graph_vel_pm=5.0,
        )
        velocities = [b["velocity_per_month"] for b in result["buckets"]]
        unique = set(round(v, 4) for v in velocities)
        assert len(unique) > 1, "Все objective velocities одинаковые — ошибка маппинга"


_FORECAST_PER_BUCKET = {
    "1-Студия": 2.0,
    "2-1-к": 6.0,
    "3-2-к": 4.5,
    "4-3-к": 3.0,
    "5-80+ м²": 1.5,
}


class TestForecastOverlayOptIn:
    """#982 (953-A): forecast-overlay — ADDITIVE OPT-IN + live-endpoint safety."""

    def test_invariance_no_horizon_no_forecast_key(self) -> None:
        """horizon_months=None → НЕТ scope['forecast'] (живой микс не тронут)."""
        result = _run_recommend_mix_full(
            objective_per_bucket=_FORECAST_PER_BUCKET,
            n_competitors=10,
            sale_graph_vel_pm=5.0,
        )
        assert "forecast" not in result["scope"]

    def test_invariance_top_level_keys_unchanged(self) -> None:
        """Топ-уровневые ключи ответа неизменны (4 поля контракта) без horizon."""
        result = _run_recommend_mix_full(
            objective_per_bucket=_FORECAST_PER_BUCKET,
            n_competitors=10,
            sale_graph_vel_pm=5.0,
        )
        assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}

    def test_invariance_byte_identical_scope_when_off(self) -> None:
        """scope БАЙТ-в-БАЙТ совпадает с/без передачи horizon_months=None."""
        base = _run_recommend_mix_full(
            objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0
        )
        again = _run_recommend_mix_full(
            objective_per_bucket=_FORECAST_PER_BUCKET,
            n_competitors=10,
            sale_graph_vel_pm=5.0,
            horizon_months=None,
        )
        assert base["scope"] == again["scope"]
        assert base["buckets"] == again["buckets"]
        assert base["summary"] == again["summary"]

    def test_opt_in_attaches_overlay_under_scope(self) -> None:
        """horizon_months задан → scope['forecast'] = результат build_forecast_overlay."""
        sentinel = {"horizon_months": 12, "mode": "demand_only", "advisory": True}
        with patch(
            "app.services.forecasting.recommendation.build_forecast_overlay",
            return_value=sentinel,
        ) as mock_overlay:
            result = _run_recommend_mix_full(
                objective_per_bucket=_FORECAST_PER_BUCKET,
                n_competitors=10,
                sale_graph_vel_pm=5.0,
                horizon_months=12,
            )
        assert result["scope"]["forecast"] is sentinel
        assert mock_overlay.call_count == 1
        # district проброшен (resolved district_name), horizon/cad — из payload.
        assert mock_overlay.call_args.kwargs["horizon_months"] == 12
        assert mock_overlay.call_args.kwargs["cad_num"] is None

    def test_crash_isolation_overlay_failure_does_not_break_live_mix(self) -> None:
        """build_forecast_overlay бросает → живой микс ВСЁ РАВНО возвращается."""
        with patch(
            "app.services.forecasting.recommendation.build_forecast_overlay",
            side_effect=RuntimeError("boom"),
        ):
            result = _run_recommend_mix_full(
                objective_per_bucket=_FORECAST_PER_BUCKET,
                n_competitors=10,
                sale_graph_vel_pm=5.0,
                horizon_months=12,
            )
        # Микс жив; overlay = {error, advisory}, остальные поля на месте.
        assert result["buckets"]
        assert result["scope"]["forecast"]["advisory"] is True
        assert "boom" in result["scope"]["forecast"]["error"]
        assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}


# ── #1955: _bucket_success_ranking использует русский default (НЕ english 'Comfort')


def _make_ranking_row(bucket: str, score: float, n: int) -> MagicMock:
    r = MagicMock()
    data = {
        "bucket": bucket,
        "success_score": score,
        "n_deals": n,
        "velocity_z": 0.1,
        "price_z": 0.2,
        "area_z": -0.1,
    }
    r.__getitem__ = lambda self, k: data[k]
    return r


class TestBucketSuccessRankingDefault:
    """#1955: после миграции 172 view хранит только русские классы — default
    в _bucket_success_ranking ДОЛЖЕН быть 'Комфорт', не english 'Comfort'
    (иначе obj_class = 'Comfort' матчит 0 строк → success-boost тихо пустеет)."""

    def test_default_class_is_russian_komfort_in_sql(self) -> None:
        """SQL использует COALESCE(:cls, 'Комфорт'), НЕ 'Comfort'."""
        from app.services.analytics_queries import _bucket_success_ranking

        captured: dict[str, Any] = {}

        def _exec(stmt: Any, params: Any) -> MagicMock:
            captured["sql"] = " ".join(str(stmt).split())
            captured["params"] = params
            # Реальный запрос отрабатывает и возвращает строки (как было бы с
            # русским default-классом в обновлённом view).
            return _make_mapping_result([_make_ranking_row("1-к 30-45", 1.2, 1029)])

        db = MagicMock()
        db.execute.side_effect = _exec

        # target_class=None → должен сработать default-путь.
        result = _bucket_success_ranking(db, "Железнодорожный", None)

        assert "'Комфорт'" in captured["sql"], captured["sql"]
        assert "'Comfort'" not in captured["sql"], "english 'Comfort' default — баг #1955"
        # Реальный запрос (не замокан в []) вернул непустой ranking.
        assert result, "success_ranking пуст — default-класс не матчит view (#1955)"
        assert result[0]["bucket"] == "1-к 30-45"
        assert result[0]["n_deals"] == 1029

    def test_explicit_russian_class_passed_through(self) -> None:
        """Явный русский класс уходит в bind как есть (caller уже перевёл)."""
        from app.services.analytics_queries import _bucket_success_ranking

        captured: dict[str, Any] = {}

        def _exec(stmt: Any, params: Any) -> MagicMock:
            captured["params"] = params
            return _make_mapping_result([_make_ranking_row("Студии 15-30", 0.9, 4857)])

        db = MagicMock()
        db.execute.side_effect = _exec

        result = _bucket_success_ranking(db, "Академический", "Комфорт")
        assert captured["params"]["cls"] == "Комфорт"
        assert result and result[0]["n_deals"] == 4857


class TestRecommendMixTranslatesClassForSuccessRanking:
    """#1955: recommend_mix ДОЛЖЕН передавать переведённый русский класс
    (target_class_db) в _bucket_success_ranking, а не сырой english target_class
    из UI ('Comfort' != 'Комфорт' → 0 строк)."""

    def test_english_ui_class_translated_before_ranking_query(self) -> None:
        from app.services.analytics_queries import _class_to_db_vocab

        captured: dict[str, Any] = {}

        def _spy_ranking(db: Any, dn: str | None, cls: str | None) -> list[dict]:
            captured["cls"] = cls
            return []

        with patch(f"{_MOD}._bucket_success_ranking", side_effect=_spy_ranking):
            # UI шлёт english 'Business' → recommend_mix должен передать русский 'Бизнес'.
            # patch_success_ranking=False → helper НЕ перепатчивает наш spy на [].
            _run_recommend_mix_full(
                objective_per_bucket=None,
                target_class="Business",
                patch_success_ranking=False,
            )

        assert captured["cls"] == _class_to_db_vocab("Business") == "Бизнес"
        assert captured["cls"] != "Business", "сырой english класс утёк в ranking-запрос (#1955)"
