"""forecast_seasonal_median — forecast diario por mediana estacional + tendencia.

Funcion PURA (sin I/O, sin datetime.now(), determinista). Predice el valor futuro
de una o varias series temporales diarias combinando dos senales:

  1. Base estacional: la mediana del valor en las ultimas `dow_weeks` fechas con el
     MISMO dia de semana que la fecha objetivo (dias ausentes = 0, para series
     intermitentes donde "sin fila" significa "sin venta").
  2. Factor de tendencia por serie: cuanto ha crecido/caido la actividad reciente
     respecto al periodo inmediatamente anterior (razon de sumas), acotado a un
     rango para no amplificar ruido.

Disenada para el forecast de ventas diarias de Aurgi (dia x centro x subcategoria
CGQ): cada serie es un par centro|subcategoria y el patron semanal domina la
demanda (los sabados venden distinto que los martes). Solo usa stdlib
(datetime, statistics).
"""

from datetime import date, datetime, timedelta
from statistics import median


def _to_date(value: str) -> date:
    """Convierte una fecha ISO 'YYYY-MM-DD' (o datetime.date) a datetime.date."""
    if isinstance(value, date) and not isinstance(value, datetime):
        return value
    if isinstance(value, datetime):
        return value.date()
    return datetime.strptime(value[:10], "%Y-%m-%d").date()


def forecast_seasonal_median(
    history: list[dict],
    horizon_dates: list[str],
    as_of: str,
    dow_weeks: int = 8,
    trend_recent_weeks: int = 4,
    trend_clip: tuple = (0.5, 2.0),
) -> list[dict]:
    """Predice el valor de cada serie para cada fecha del horizonte.

    Para cada serie presente en `history` y cada fecha objetivo del horizonte:

    1. Base estacional = mediana del valor en las ultimas `dow_weeks` fechas con el
       MISMO dia de semana que la fecha objetivo, todas <= `as_of`. Se toman las
       fechas EXACTAS del calendario (la mas reciente <= as_of con ese dia de
       semana, y de ahi 7 dias hacia atras por punto); una fecha ausente en la
       historia cuenta como 0 (series intermitentes).
    2. Factor de tendencia por serie = suma de los valores de las ultimas
       `trend_recent_weeks` semanas (desde `as_of` hacia atras) dividida entre la
       suma de las `trend_recent_weeks` semanas anteriores a esas. Si el
       denominador es 0 el factor es 1.0. Se acota a `trend_clip`.
    3. y_pred = max(0.0, base * factor).

    Args:
        history: observaciones {"series_id": str, "date": "YYYY-MM-DD",
            "value": float}. Filas duplicadas (misma serie y fecha) se suman. Los
            dias sin fila dentro de las ventanas se tratan como valor 0.
        horizon_dates: fechas futuras a predecir (strings ISO 'YYYY-MM-DD').
        as_of: fecha de corte (ultimo dia de historia utilizable, inclusive).
        dow_weeks: numero de fechas del mismo dia de semana a promediar para la
            base estacional. Default 8.
        trend_recent_weeks: tamano (en semanas) de cada una de las dos ventanas de
            tendencia (reciente y anterior). Default 4.
        trend_clip: (min, max) al que se acota el factor de tendencia. Default
            (0.5, 2.0): la prediccion no puede menos que caer a la mitad ni mas
            que duplicarse por tendencia.

    Returns:
        Lista de {"series_id": str, "date": str, "y_pred": float}, una fila por
        cada serie presente en `history` y cada fecha del horizonte. Ordenada por
        series_id (asc) y luego por el orden de `horizon_dates`.
    """
    as_of_d = _to_date(as_of)
    lo_clip, hi_clip = trend_clip

    # Mapa (series_id, date) -> valor acumulado + conjunto de series presentes.
    values: dict[tuple[str, date], float] = {}
    series_ids: set[str] = set()
    for obs in history:
        sid = obs["series_id"]
        d = _to_date(obs["date"])
        v = float(obs.get("value", 0.0) or 0.0)
        series_ids.add(sid)
        values[(sid, d)] = values.get((sid, d), 0.0) + v

    # Ventanas de tendencia (en dias) relativas a as_of.
    span = 7 * trend_recent_weeks
    recent_lo = as_of_d - timedelta(days=span)      # reciente: recent_lo < d <= as_of
    prior_lo = as_of_d - timedelta(days=2 * span)   # anterior: prior_lo  < d <= recent_lo

    # Factor de tendencia por serie (una sola vez por serie, no depende del horizonte).
    trend_factor: dict[str, float] = {}
    for sid in series_ids:
        recent_sum = 0.0
        prior_sum = 0.0
        for (s, d), v in values.items():
            if s != sid:
                continue
            if recent_lo < d <= as_of_d:
                recent_sum += v
            elif prior_lo < d <= recent_lo:
                prior_sum += v
        if prior_sum == 0.0:
            factor = 1.0
        else:
            factor = recent_sum / prior_sum
        trend_factor[sid] = min(hi_clip, max(lo_clip, factor))

    horizon = [_to_date(h) for h in horizon_dates]
    out: list[dict] = []
    for sid in sorted(series_ids):
        factor = trend_factor[sid]
        for h_str, h_d in zip(horizon_dates, horizon):
            # Fecha mas reciente <= as_of con el mismo dia de semana que la objetivo.
            back = (as_of_d.weekday() - h_d.weekday()) % 7
            anchor = as_of_d - timedelta(days=back)
            dow_values = [
                values.get((sid, anchor - timedelta(days=7 * i)), 0.0)
                for i in range(dow_weeks)
            ]
            base = median(dow_values)
            y_pred = max(0.0, base * factor)
            out.append({"series_id": sid, "date": h_str, "y_pred": y_pred})

    return out
