"""Unit-тесты сезонной (month-of-year) нормализации спроса (#979, ТЗ §9.4).

Синтетика, без живой БД. Гейт DoD:
  • flat baseline × известный month-of-year паттерн (лето +30%, зима −30%) за 2-3
    года → после normalize_demand средние по календарным месяцам ~равны (сезонность
    снята, within tolerance);
  • плоский ряд → не меняется;
  • тонкие данные (< 2 полных лет) → возвращаются ~без изменений (факторы = 1.0, без
    blowup / деления на ноль);
  • пустой ряд → пустой (без crash);
  • factors / applied / n_full_years семантика + ортогональность прочих полей.
"""

from __future__ import annotations

import os
import statistics

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

from datetime import date

from app.services.forecasting.normalize import (
    SeasonalAdjustment,
    deseasonalize_values,
    normalize_demand,
    seasonal_factors,
)
from app.services.forecasting.sales_series import SalesSeries

# ── synthetic helpers ─────────────────────────────────────────────────────────

# Известный month-of-year паттерн: лето (июнь-авг) +30%, зима (дек-фев) −30%,
# остальное ≈ базовый уровень. Сумма множителей вокруг 1.0 → overall_mean ≈ baseline.
_SEASONAL_PATTERN: dict[int, float] = {
    1: 0.70,  # январь −30%
    2: 0.70,  # февраль −30%
    3: 1.00,
    4: 1.00,
    5: 1.00,
    6: 1.30,  # июнь +30%
    7: 1.30,  # июль +30%
    8: 1.30,  # август +30%
    9: 1.00,
    10: 1.00,
    11: 1.00,
    12: 0.70,  # декабрь −30%
}

_BASELINE_UNITS: int = 100


def _months(n_months: int, start: date = date(2021, 1, 1)) -> list[date]:
    """n_months подряд идущих 1-х чисел месяца, начиная со start."""
    out: list[date] = []
    y, m = start.year, start.month
    for _ in range(n_months):
        out.append(date(y, m, 1))
        m += 1
        if m > 12:
            m = 1
            y += 1
    return out


def _seasonal_units(months: list[date], baseline: int = _BASELINE_UNITS) -> list[int]:
    """baseline × month-of-year паттерн, округлённый к int (сырой сезонный спрос)."""
    return [round(baseline * _SEASONAL_PATTERN[d.month]) for d in months]


def _make_series(months: list[date], units: list[int]) -> SalesSeries:
    """SalesSeries-обёртка для теста (прочие поля — нейтральные заглушки)."""
    return SalesSeries(
        months=months,
        units=units,
        area_m2=[None] * len(months),
        avg_price_per_m2=[None] * len(months),
        n_months=len(months),
        source="corpus_room_month",
        segment={"obj_class": None, "room_bucket": None, "district": None, "price_bucket": None},
        confidence="high",
    )


def _month_of_year_means(months: list[date], units: list[float | None]) -> dict[int, float]:
    """Среднее значение по каждому календарному месяцу (1..12) — для assert «снято»."""
    buckets: dict[int, list[float]] = {}
    for d, v in zip(months, units, strict=False):
        if v is None:
            continue
        buckets.setdefault(d.month, []).append(float(v))
    return {m: statistics.mean(vs) for m, vs in buckets.items() if vs}


# ── seasonal_factors (pure) ───────────────────────────────────────────────────


class TestSeasonalFactors:
    def test_recovers_known_pattern_factors(self) -> None:
        # 3 полных года сезонного ряда → факторы должны восстановить паттерн.
        months = _months(36)
        units = _seasonal_units(months)
        adj = seasonal_factors(months, units)
        assert isinstance(adj, SeasonalAdjustment)
        assert adj.applied is True
        assert adj.n_full_years == 3
        # Фактор каждого месяца ≈ его множитель паттерна (overall_mean ≈ baseline).
        for m, expected in _SEASONAL_PATTERN.items():
            assert abs(adj.factors[m] - expected) < 0.05, (m, adj.factors[m], expected)

    def test_thin_data_under_two_years_neutral(self) -> None:
        # 18 мес < 2 полных лет → все факторы 1.0, applied=False (шум не ловим).
        months = _months(18)
        units = _seasonal_units(months)
        adj = seasonal_factors(months, units)
        assert adj.applied is False
        assert adj.n_full_years == 1
        assert all(f == 1.0 for f in adj.factors.values())

    def test_flat_series_factors_all_one(self) -> None:
        # Плоский ряд (без сезонности) → каждый month_mean == overall_mean → 1.0.
        months = _months(36)
        units = [_BASELINE_UNITS] * 36
        adj = seasonal_factors(months, units)
        # applied может быть False (все ровно 1.0) — сезонности нет.
        assert all(abs(f - 1.0) < 1e-9 for f in adj.factors.values())

    def test_empty_series_neutral(self) -> None:
        adj = seasonal_factors([], [])
        assert adj.applied is False
        assert adj.n_full_years == 0
        assert all(f == 1.0 for f in adj.factors.values())

    def test_all_zero_series_neutral_no_div_zero(self) -> None:
        # overall_mean == 0 → нейтраль, без деления на ноль.
        months = _months(36)
        adj = seasonal_factors(months, [0] * 36)
        assert adj.applied is False
        assert all(f == 1.0 for f in adj.factors.values())

    # ── fix #1638: zero-filled series must fail the min-years guard ───────────

    def test_zero_filled_36m_fails_min_years_guard(self) -> None:
        """Ряд 36 мес, все units=0 (zero-fill от fill_month_grid) → applied=False.

        До fix #1638 _count_full_years считал нулевые значения за наблюдения
        и возвращал n_full_years=3 → guard пропускал → overall_mean==0 спасал
        от деления, но прохождение guard'а было семантически неверным.
        После fix: 0 не считается наблюдением → n_full_years=0 → applied=False.
        """
        months = _months(36)
        adj = seasonal_factors(months, [0] * 36)
        assert (
            adj.n_full_years == 0
        ), f"expected 0 full years on all-zero series, got {adj.n_full_years}"
        assert adj.applied is False
        assert all(f == 1.0 for f in adj.factors.values())

    def test_mixed_zero_and_real_partial_years_fails_guard(self) -> None:
        """2 года месяцев, но только часть месяцев ненулевые — не 2 полных года.

        Сценарий: новый ЖК, продажи только в нескольких месяцах каждого года
        (остальные zero-fill). _count_full_years должен не считать это «полным годом».
        """
        months = _months(24)  # 2 календарных года
        # Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес.
        units = [10 if d.month <= 6 else 0 for d in months]
        adj = seasonal_factors(months, units)
        assert (
            adj.n_full_years == 0
        ), f"partial-coverage years should not count as full, got {adj.n_full_years}"
        assert adj.applied is False

    def test_real_nonzero_series_passes_guard_and_applies(self) -> None:
        """Ряд с реальными ненулевыми данными за 2+ лет проходит guard и применяется.

        Страховка: fix #1638 не должен ломать штатный путь с настоящими данными.
        """
        months = _months(36)
        units = _seasonal_units(months)  # baseline×паттерн, все > 0
        adj = seasonal_factors(months, units)
        assert adj.n_full_years == 3
        assert adj.applied is True

    def test_month_with_no_observations_factor_one(self) -> None:
        # Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
        # (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
        # ослабляем порог (min_full_years=0). Остальные месяцы получают свои факторы,
        # март — нейтраль (нет базы оценки), без KeyError / деления на пустое среднее.
        months = _months(36)
        units = _seasonal_units(months)
        vals: list[float | int | None] = [
            (None if d.month == 3 else u) for d, u in zip(months, units, strict=False)
        ]
        adj = seasonal_factors(months, vals, min_full_years=0)
        assert adj.factors[3] == 1.0  # март без наблюдений → нейтраль
        # Соседний июль наблюдается → его фактор НЕ нейтральный (паттерн +30%).
        assert abs(adj.factors[7] - 1.3) < 0.1


# ── deseasonalize_values (pure) ───────────────────────────────────────────────


class TestDeseasonalizeValues:
    def test_divides_by_factor(self) -> None:
        months = [date(2021, 1, 1), date(2021, 7, 1)]
        values: list[float | int | None] = [70, 130]
        factors = {1: 0.7, 7: 1.3}
        out = deseasonalize_values(months, values, factors)
        assert out[0] is not None and abs(out[0] - 100.0) < 1e-9
        assert out[1] is not None and abs(out[1] - 100.0) < 1e-9

    def test_none_preserved(self) -> None:
        months = [date(2021, 1, 1), date(2021, 2, 1)]
        out = deseasonalize_values(months, [None, 70], {1: 0.7, 2: 0.7})
        assert out[0] is None
        assert out[1] is not None and abs(out[1] - 100.0) < 1e-9

    def test_nonpositive_factor_falls_back_to_one(self) -> None:
        # Фактор ≤ 0 / отсутствующий месяц → нейтраль 1.0 (без деления на 0/neg).
        months = [date(2021, 1, 1), date(2021, 2, 1)]
        out = deseasonalize_values(months, [50, 60], {1: 0.0, 2: -1.0})
        assert out == [50.0, 60.0]


# ── normalize_demand (public API, SalesSeries shape) ──────────────────────────


class TestNormalizeDemand:
    def test_seasonality_removed_month_means_equalised(self) -> None:
        # DoD GATE: сезонный ряд за 3 года → после normalize month-of-year средние
        # ~равны (within tolerance), т.е. сезонность снята.
        months = _months(36)
        units = _seasonal_units(months)
        raw_means = _month_of_year_means(months, [float(u) for u in units])
        # До нормализации: лето заметно выше зимы.
        assert max(raw_means.values()) / min(raw_means.values()) > 1.5

        result = normalize_demand(_make_series(months, units))
        assert isinstance(result, SalesSeries)
        adj_means = _month_of_year_means(months, [float(u) for u in result.units])
        # После: разброс месячных средних схлопнут (≤ ~5% спред — остаток округления).
        spread = max(adj_means.values()) / min(adj_means.values())
        assert spread < 1.05, (spread, adj_means)

    def test_flat_series_unchanged(self) -> None:
        months = _months(36)
        units = [_BASELINE_UNITS] * 36
        result = normalize_demand(_make_series(months, units))
        assert result.units == units  # плоский ряд не тронут

    def test_thin_data_returned_unchanged(self) -> None:
        # < 2 полных лет → факторы 1.0 → ряд возвращается без изменений (тот же объект).
        months = _months(18)
        units = _seasonal_units(months)
        series = _make_series(months, units)
        result = normalize_demand(series)
        assert result is series  # short-circuit: applied=False → возвращаем как есть
        assert result.units == units

    def test_empty_series_empty_no_crash(self) -> None:
        empty = _make_series([], [])
        result = normalize_demand(empty)
        assert result.months == []
        assert result.units == []

    def test_other_fields_preserved(self) -> None:
        months = _months(36)
        units = _seasonal_units(months)
        series = _make_series(months, units)
        result = normalize_demand(series)
        # Дессзонивание трогает только units; прочее — без изменений.
        assert result.months == series.months
        assert result.area_m2 == series.area_m2
        assert result.avg_price_per_m2 == series.avg_price_per_m2
        assert result.source == series.source
        assert result.segment == series.segment
        assert result.confidence == series.confidence

    def test_deseasonalized_units_nonnegative_int(self) -> None:
        # Контракт SalesSeries.units = list[int] ≥ 0 сохраняется после нормализации.
        months = _months(36)
        units = _seasonal_units(months)
        result = normalize_demand(_make_series(months, units))
        assert all(isinstance(u, int) and u >= 0 for u in result.units)

    def test_zero_filled_series_returned_unchanged(self) -> None:
        """fix #1638: zero-filled SalesSeries (все units=0) возвращается без изменений.

        До fix: guard считал 3 «полных года» на нулях → adjustment.applied=True
        (пройдя через overall_mean==0 check) или мог пройти guard и вернуть
        идентичный ряд через нейтральные факторы. После fix: n_full_years=0 →
        applied=False → функция возвращает тот же объект (short-circuit).
        """
        months = _months(36)
        series = _make_series(months, [0] * 36)
        result = normalize_demand(series)
        assert result is series, "zero-filled series must be returned as-is (no adjustment)"
        assert result.units == [0] * 36
