"""G8 — short-horizon indicator forecasting.

Implements a small, dependency-light projection over verified DataPoints
using sklearn's ``LinearRegression``. The output drives the dashed forecast
overlay on the indicator detail trend chart partial.

Design choices:

* **Linear regression**, not Holt-Winters. Most RUFORUM indicators have <12
  observations and no clear seasonality. Linear is interpretable and avoids
  over-fitting tiny series.
* **Confidence band** uses residual standard error × t-stat (z=1.96 for 95%)
  with a simple fan widening per step ahead — enough to communicate
  uncertainty without pulling in scipy.
* **Gate**: returns ``None`` when fewer than 4 verified data points exist.
* **Period labels** for the projected steps reuse
  :func:`apps.mel.indicators.services.current_period_label` semantics so the
  chart x-axis stays aligned with the historical labels.
"""
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Iterable

logger = logging.getLogger(__name__)

MIN_HISTORY_POINTS = 4
DEFAULT_HORIZON = 4
Z_95 = 1.96


@dataclass
class ForecastPoint:
    period_label: str
    value: float
    lower: float
    upper: float


@dataclass
class ForecastResult:
    points: list[ForecastPoint] = field(default_factory=list)
    history: list[dict] = field(default_factory=list)
    slope: float = 0.0
    intercept: float = 0.0
    residual_std: float = 0.0
    has_data: bool = False
    history_count: int = 0
    horizon: int = DEFAULT_HORIZON


def _next_period_label(frequency: str, after: date, steps_ahead: int) -> str:
    """Return the period label N steps ahead of ``after`` for the given frequency."""
    from apps.mel.indicators.models import IndicatorFrequency
    from apps.mel.indicators.services import current_period_label

    if frequency == IndicatorFrequency.DAILY:
        target = after + timedelta(days=steps_ahead)
    elif frequency == IndicatorFrequency.WEEKLY:
        target = after + timedelta(weeks=steps_ahead)
    elif frequency == IndicatorFrequency.MONTHLY:
        # Add steps_ahead months by date arithmetic (cap to month-28 to avoid edge cases).
        month = after.month + steps_ahead
        year = after.year + (month - 1) // 12
        month = ((month - 1) % 12) + 1
        target = date(year, month, min(28, after.day))
    elif frequency == IndicatorFrequency.QUARTERLY:
        month = after.month + 3 * steps_ahead
        year = after.year + (month - 1) // 12
        month = ((month - 1) % 12) + 1
        target = date(year, month, min(28, after.day))
    elif frequency == IndicatorFrequency.SEMI_ANNUAL:
        month = after.month + 6 * steps_ahead
        year = after.year + (month - 1) // 12
        month = ((month - 1) % 12) + 1
        target = date(year, month, min(28, after.day))
    elif frequency == IndicatorFrequency.ANNUAL:
        target = date(after.year + steps_ahead, after.month, min(28, after.day))
    else:
        target = after + timedelta(days=steps_ahead)
    return current_period_label(frequency, when=target)


def forecast_indicator(
    indicator,
    *,
    periods: int = DEFAULT_HORIZON,
) -> ForecastResult:
    """Return a :class:`ForecastResult` projecting ``periods`` steps ahead.

    When the indicator has fewer than :data:`MIN_HISTORY_POINTS` verified
    data points, returns a result with ``has_data=False`` so the template
    can render a "not enough data" hint instead of a misleading line.
    """
    from apps.mel.indicators.models import DataPoint

    history_qs = (
        DataPoint.objects.filter(
            indicator=indicator,
            status=DataPoint.Status.VERIFIED,
        )
        .order_by("reported_at")
        .values_list("reported_at", "value", "period_label")
    )
    points = list(history_qs)
    history = [
        {
            "period_label": label,
            "value": float(value),
            "reported_at": rep,
        }
        for rep, value, label in points
    ]
    if len(points) < MIN_HISTORY_POINTS:
        return ForecastResult(
            points=[], history=history, has_data=False,
            history_count=len(points), horizon=periods,
        )

    try:
        from sklearn.linear_model import LinearRegression
    except ImportError:  # pragma: no cover — sklearn is a hard dep
        logger.exception("sklearn missing — forecast_indicator unavailable")
        return ForecastResult(
            points=[], history=history, has_data=False,
            history_count=len(points), horizon=periods,
        )

    xs = [[i] for i in range(len(points))]
    ys = [float(v) for _, v, _ in points]

    model = LinearRegression()
    model.fit(xs, ys)

    # Residuals → standard error of the regression. Guard against perfect
    # fits (zero residuals) — produce a tight but non-zero band so the chart
    # still draws a confidence area.
    predictions = model.predict(xs)
    n = len(ys)
    if n > 2:
        residuals = [ys[i] - predictions[i] for i in range(n)]
        rss = sum(r * r for r in residuals)
        residual_std = (rss / (n - 2)) ** 0.5
    else:
        residual_std = 0.0
    if residual_std == 0.0:
        residual_std = max(1e-3, abs(float(model.coef_[0])) * 0.05)

    last_date = points[-1][0].date() if hasattr(points[-1][0], "date") else points[-1][0]

    forecast: list[ForecastPoint] = []
    for step in range(1, periods + 1):
        idx = len(points) - 1 + step
        predicted = float(model.predict([[idx]])[0])
        # Widen the band as we project further out — 1.0× residual_std at
        # step 1, growing by sqrt(step) (matches a basic prediction-interval
        # heuristic without invoking scipy).
        widen = (step ** 0.5)
        margin = Z_95 * residual_std * widen
        label = _next_period_label(indicator.frequency, last_date, step)
        forecast.append(
            ForecastPoint(
                period_label=label,
                value=round(predicted, 2),
                lower=round(predicted - margin, 2),
                upper=round(predicted + margin, 2),
            )
        )

    return ForecastResult(
        points=forecast,
        history=history,
        slope=float(model.coef_[0]),
        intercept=float(model.intercept_),
        residual_std=residual_std,
        has_data=True,
        history_count=len(points),
        horizon=periods,
    )


def build_forecast_chart(result: ForecastResult, *, width: int = 560, height: int = 140, pad: int = 10) -> dict:
    """Render an SVG-friendly payload combining history + forecast + band.

    Returns a dict structured for the trend_chart.html partial. When
    ``result.has_data`` is False, returns an empty payload so the template
    can show its existing empty state.
    """
    if not result.has_data or not result.history or not result.points:
        return {
            "has_data": False,
            "history_count": result.history_count,
            "min_history": MIN_HISTORY_POINTS,
        }

    hist_values = [h["value"] for h in result.history]
    forecast_values = [p.value for p in result.points]
    forecast_lows = [p.lower for p in result.points]
    forecast_highs = [p.upper for p in result.points]

    all_values = hist_values + forecast_values + forecast_lows + forecast_highs
    lo, hi = min(all_values), max(all_values)
    if hi == lo:
        hi = lo + 1.0

    total_steps = len(hist_values) + len(forecast_values) - 1
    if total_steps == 0:
        return {"has_data": False, "history_count": result.history_count, "min_history": MIN_HISTORY_POINTS}

    def _x(i: int) -> float:
        return pad + (i / total_steps) * (width - 2 * pad)

    def _y(v: float) -> float:
        # Higher value → higher up on the SVG (smaller y).
        return height - pad - ((v - lo) / (hi - lo)) * (height - 2 * pad)

    history_pts = [(i, v) for i, v in enumerate(hist_values)]
    forecast_pts = [(len(hist_values) + i, v) for i, v in enumerate(forecast_values)]
    band_pts = [
        (len(hist_values) + i, p.lower, p.upper) for i, p in enumerate(result.points)
    ]
    # Connect the last history point to the first forecast point so the
    # dashed segment doesn't visually float.
    forecast_join = [history_pts[-1], *forecast_pts]
    band_join = [(history_pts[-1][0], hist_values[-1], hist_values[-1]), *band_pts]

    history_str = " ".join(f"{_x(i):.2f},{_y(v):.2f}" for i, v in history_pts)
    forecast_str = " ".join(f"{_x(i):.2f},{_y(v):.2f}" for i, v in forecast_join)
    upper_str = " ".join(f"{_x(i):.2f},{_y(u):.2f}" for i, _l, u in band_join)
    lower_str = " ".join(f"{_x(i):.2f},{_y(l):.2f}" for i, l, _u in reversed(band_join))
    band_polygon = f"{upper_str} {lower_str}"

    return {
        "has_data": True,
        "width": width,
        "height": height,
        "history_points": history_str,
        "forecast_points": forecast_str,
        "band_polygon": band_polygon,
        "first_label": result.history[0]["period_label"],
        "last_label": result.points[-1].period_label,
        "horizon": len(forecast_values),
        "history_count": result.history_count,
        "lo": round(lo, 2),
        "hi": round(hi, 2),
        "min_history": MIN_HISTORY_POINTS,
    }
