"""§9.4 сезонная (month-of-year) нормализация сырого месячного спроса перед регрессией.

#979 (951-C, ТЗ §9.4), EPIC 7 «Чувствительность к ключевой ставке». DoD: УБРАТЬ
сезонность календарного месяца (month-of-year) из СЫРОГО месячного ряда спроса
ДО регрессии/прогноза §9.6/§9.8. Это ОТДЕЛЬНАЯ от `demand_normalization.py` вещь:
там — rate-regime дисконт «бумного» темпа под смену ключевой ставки (β·Δrate); здесь
— чисто КАЛЕНДАРНОЕ дессзонивание (январь систематически слабее июля и т.п.), не
зависящее от ставки. Оба нужны и ортогональны: сначала снимаем календарный паттерн
(этот модуль), потом — режимный дисконт (demand_normalization).

РАЦИОНАЛ «не переносить sell-through при низкой эфф.ставке в будущее по рыночной»
(scope #979): сырой месячный спрос несёт ДВА смешанных сигнала — (1) календарную
сезонность (устойчивый внутригодовой паттерн: весенний/осенний всплеск, новогодний
провал) и (2) режимный отклик на ставку/льготную ипотеку. Регрессия §9.6 (продажи ↔
ставка) ищет ВТОРОЕ; если не снять ПЕРВОЕ, сезонный пик, случайно совпавший с окном
низкой ставки, читается как «эффект ставки» и завышает перенос sell-through вперёд.
Дессзонивание изолирует календарный паттерн, чтобы регрессия видела режимный сигнал
чище — а не тащила сезонно-раздутый низкоставочный темп в будущее наивно.

МЕТОД — классическая МУЛЬТИПЛИКАТИВНАЯ month-of-year сезонная корректировка
(stable-seasonal / ratio-to-mean, тот же дух, что seasonal-индексы X-11/decompose):

  1. Группируем значения ряда по календарному месяцу m ∈ 1..12.
  2. month_mean[m] = среднее наблюдений месяца m; overall_mean = среднее всех.
  3. seasonal_factor[m] = month_mean[m] / overall_mean (мультипликативно: >1 —
     месяц систематически выше года, <1 — ниже).
  4. Дессзонированное значение точки t = raw_t / seasonal_factor[month_of(t)].
  Так среднее по каждому календарному месяцу выравнивается к общему — устойчивый
  внутригодовой паттерн снят, режимная/трендовая динамика сохранена.

ПОЧЕМУ МУЛЬТИПЛИКАТИВНО (а не аддитивно): спрос на жильё растёт/падает в %-шкале
(сезонная амплитуда пропорциональна уровню — в «жирный» год и пик, и провал крупнее
в абсолюте). Это та же логлинейная/процентная шкала, на которой работает §9.6
(Y = Δln(units), x_pct = 100·(exp(β)−1)) — дессзонивание делением согласовано с ней.

МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных):
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
случайным шумом одного-двух наблюдений:
  • нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — считаем только по
    NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не
    несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность»
    неотличима от шума → ВСЕ факторы = 1.0 (ряд возвращается без изменений);
  • месяц без наблюдений → его фактор = 1.0 (нет базы для оценки);
  • overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и
    защита от деления на ноль);
  • месяц со средним ≤ 0 (все наблюдения нули) → фактор = 1.0 (не раздуваем нули).
Деградация всегда к НЕЙТРАЛИ (factor=1.0 = «не корректируем»), НИКОГДА не делим на
ноль и НИКОГДА не усиливаем шум тонких данных. Дух market_metrics / rate_sensitivity.

PURE, детерминированно, без БД, без LLM. numpy используется для среднего (уже dep).
Возвращаем тот же shape входа с дессзонированными значениями + сами факторы (для
explainability / следующего слоя нормализации).
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, replace
from datetime import date

import numpy as np

from app.services.forecasting.sales_series import SalesSeries

logger = logging.getLogger(__name__)

# ── Named-константы ───────────────────────────────────────────────────────────

# Нейтральный сезонный фактор: 1.0 = «месяц не корректируем» (деление на 1.0).
_NEUTRAL_FACTOR: float = 1.0

# Минимум ПОЛНЫХ лет наблюдений, чтобы вообще оценивать сезонность. < 2 лет → у
# каждого месяца < 2 точек, ratio-to-mean ловит случайный шум, а не устойчивый
# внутригодовой паттерн → дессзонивание отключаем целиком (все факторы 1.0). 2 —
# абсолютный минимум, при котором у месяца есть ≥2 наблюдения для усреднения
# (классический порог стабильности сезонного индекса). Уточняется бэктестом.
_MIN_FULL_YEARS: int = 2

# Месяцев в году — размер группировки month-of-year (1..12).
_MONTHS_IN_YEAR: int = 12


@dataclass(frozen=True)
class SeasonalAdjustment:
    """Результат сезонной (month-of-year) нормализации месячного ряда спроса.

    Детерминированный. `factors` — {месяц 1..12: мультипликативный сезонный фактор}
    (>1 месяц систематически выше года, <1 ниже, ровно 1.0 = не корректировали:
    тонкие данные / нет наблюдений / нулевой месяц). `applied` = True, только если
    хотя бы один фактор ≠ 1.0 (реально сняли сезонность); False = деградация к
    нейтрали (< _MIN_FULL_YEARS лет / пустой ряд) — ряд вернулся без изменений.
    `n_full_years` — сколько полных лет покрывает ряд (для explainability порога).
    """

    factors: dict[int, float]  # {month 1..12: seasonal_factor} (1.0 = не трогали)
    applied: bool  # True = снята реальная сезонность; False = деградация к нейтрали
    n_full_years: int  # полных лет в ряду (база для _MIN_FULL_YEARS guard)


# ──────────────────────────────────────────────────────────────────────────────
# Pure-логика — без БД, полностью юнит-тестируемо (numpy-среднее на синтетике).
# ──────────────────────────────────────────────────────────────────────────────


def seasonal_factors(
    months: list[date],
    values: list[float | int | None],
    *,
    min_full_years: int = _MIN_FULL_YEARS,
) -> SeasonalAdjustment:
    """Оценить мультипликативные month-of-year сезонные факторы ряда. PURE.

    seasonal_factor[m] = month_mean[m] / overall_mean по календарному месяцу m∈1..12
    (см. метод в module docstring). months[i] и values[i] выровнены по индексу;
    None-значения в values пропускаются (нет наблюдения — не подмешиваем 0).

    GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума):
      • полных лет (по NON-ZERO месяцам) < min_full_years → ВСЕ факторы 1.0,
        applied=False (fix #1638: zero-filled месяцы не считаются за наблюдения);
      • overall_mean ≤ 0 → ВСЕ факторы 1.0 (нет масштаба / защита от /0);
      • месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0.

    Args:
        months: список дат точек (любой день месяца — берём .month), index-aligned.
        values: значения спроса (units), None = нет наблюдения; index-aligned.
        min_full_years: минимум полных лет для оценки сезонности (_MIN_FULL_YEARS).

    Returns:
        SeasonalAdjustment с factors {1..12: фактор}, applied, n_full_years.
    """
    # Собираем наблюдения по календарному месяцу (None пропускаем — не нули).
    by_month: dict[int, list[float]] = {m: [] for m in range(1, _MONTHS_IN_YEAR + 1)}
    all_values: list[float] = []
    for d, v in zip(months, values, strict=False):
        if v is None:
            continue
        fv = float(v)
        by_month[d.month].append(fv)
        all_values.append(fv)

    n_full_years = _count_full_years(months, values)
    neutral = {m: _NEUTRAL_FACTOR for m in range(1, _MONTHS_IN_YEAR + 1)}

    # Тонкие данные: < min_full_years полных лет → сезонность неотличима от шума.
    if n_full_years < min_full_years:
        return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years)

    # Нет масштаба / непозитивный overall_mean → нейтраль (и защита от деления /0).
    if not all_values:
        return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years)
    overall_mean = float(np.mean(all_values))
    if overall_mean <= 0.0:
        return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years)

    factors: dict[int, float] = {}
    for m in range(1, _MONTHS_IN_YEAR + 1):
        obs = by_month[m]
        if not obs:
            factors[m] = _NEUTRAL_FACTOR  # нет наблюдений месяца → не корректируем
            continue
        month_mean = float(np.mean(obs))
        if month_mean <= 0.0:
            factors[m] = _NEUTRAL_FACTOR  # все нули в месяце → не раздуваем нули
            continue
        factors[m] = month_mean / overall_mean

    applied = any(f != _NEUTRAL_FACTOR for f in factors.values())
    return SeasonalAdjustment(factors=factors, applied=applied, n_full_years=n_full_years)


def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
    """Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение).

    Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев — это
    «полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки
    и нулевые значения (zero-filled месяцы из fill_month_grid) наблюдением НЕ
    считаются: у ряда с units=0 нет сезонного сигнала для оценки факторов. PURE.

    Это исправляет баг #1638: fill_month_grid выставляет units=0 для отсутствующих
    месяцев (не None), поэтому старый guard, пропускавший только None, насчитывал
    «полные годы» на фактически пустом ряду.
    """
    months_seen: dict[int, set[int]] = {}
    for d, v in zip(months, values, strict=False):
        if v is None or float(v) == 0.0:
            continue
        months_seen.setdefault(d.year, set()).add(d.month)
    return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR)


def deseasonalize_values(
    months: list[date],
    values: list[float | int | None],
    factors: dict[int, float],
) -> list[float | None]:
    """Поделить значения на сезонный фактор их месяца: out[t] = raw_t / factor[m]. PURE.

    None-значение остаётся None (нет наблюдения). Фактор ≤ 0 или отсутствующий месяц
    → нейтраль 1.0 (страховка: деления на ноль/отрицательное не делаем). Длина выхода
    = длине входа, index-aligned.

    Args:
        months: даты точек (берём .month), index-aligned с values.
        values: сырые значения (units), None = нет наблюдения.
        factors: {month 1..12: seasonal_factor} из seasonal_factors().

    Returns:
        Дессзонированные значения той же длины (None там, где был None).
    """
    out: list[float | None] = []
    for d, v in zip(months, values, strict=False):
        if v is None:
            out.append(None)
            continue
        factor = factors.get(d.month, _NEUTRAL_FACTOR)
        if factor <= 0.0:
            factor = _NEUTRAL_FACTOR
        out.append(float(v) / factor)
    return out


# ──────────────────────────────────────────────────────────────────────────────
# Публичный API — дроп-ин в demand→regression путь (§9.6 rate_sensitivity).
# ──────────────────────────────────────────────────────────────────────────────


def normalize_demand(series: SalesSeries) -> SalesSeries:
    """Снять month-of-year сезонность с сырого месячного ряда спроса (DoD #979).

    Принимает и возвращает `SalesSeries` (тот самый объект, что течёт в регрессию
    §9.6: build_sales_series → normalize_demand → log_diff(units)). Дессзонирует
    `units` мультипликативной month-of-year корректировкой (см. module docstring),
    остальные поля (area_m2, avg_price, source, segment, confidence) сохраняет.

    ⚠️ Это КАЛЕНДАРНОЕ дессзонивание, ОРТОГОНАЛЬНОЕ rate-regime дисконту
    `demand_normalization.compute_demand_normalization` (β·Δrate). Применять ПЕРЕД
    регрессией §9.6, чтобы сезонный пик не читался как «эффект ставки» и не тащил
    низкоставочный sell-through вперёд (рационал — module docstring).

    GUARD (graceful): < _MIN_FULL_YEARS полных лет / пустой / нулевой ряд → факторы
    все 1.0 → ряд возвращается ФАКТИЧЕСКИ без изменений (deseasonalized units равны
    исходным, с точностью до float). НИКОГДА не делит на ноль, не усиливает шум.

    Дессзонированные units — float (деление на фактор), но `SalesSeries.units`
    типизирован как list[int]: спрос дессзонивания идёт в log_diff (float-математика),
    поэтому округляем к int ТОЛЬКО для сохранения контракта dataclass; для регрессии
    важна форма паттерна, а не целочисленность. Если нужны точные float-значения —
    бери deseasonalize_values(...) напрямую (см. ниже helper). Детерминированно.

    Args:
        series: сырой месячный ряд продаж сегмента (build_sales_series, §9.6 Y-ось).

    Returns:
        Новый SalesSeries с дессзонированными units (тот же shape; прочие поля как у
        входа). Пустой ряд → возвращается как есть.
    """
    if not series.months:
        return series

    adjustment = seasonal_factors(series.months, series.units)
    if not adjustment.applied:
        logger.info(
            "normalize_demand: no seasonal adjustment applied (segment=%s n_full_years=%d "
            "< min) → series unchanged",
            series.segment,
            adjustment.n_full_years,
        )
        return series

    deseasonalized = deseasonalize_values(series.months, series.units, adjustment.factors)
    # Контракт SalesSeries.units = list[int]: округляем дессзонированные значения
    # (None в units не бывает — units всегда ≥0 int; страховка max(0, …)).
    new_units = [max(0, round(v)) if v is not None else 0 for v in deseasonalized]

    logger.info(
        "normalize_demand: deseasonalized segment=%s n_full_years=%d factors=%s",
        series.segment,
        adjustment.n_full_years,
        {m: round(f, 3) for m, f in adjustment.factors.items()},
    )
    return replace(series, units=new_units)
