"""Forecast diagnostics utilities (outside the core model package).

This file intentionally avoids heavyweight dependencies. If SciPy is available,
it is used for a Student-t p-value; otherwise a normal approximation is used.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional, Sequence, Tuple

import math

import numpy as np


@dataclass(frozen=True)
class DMResult:
    """Diebold–Mariano test result for squared-error loss."""

    T: int
    mean_d: float
    var_d: float
    DM: float
    DM_star: float
    p_value_two_sided: float

    def to_json(self) -> dict:
        return {
            "T": self.T,
            "mean_d": self.mean_d,
            "var_d": self.var_d,
            "DM": self.DM,
            "DM_star": self.DM_star,
            "p_value_two_sided": self.p_value_two_sided,
        }


def rolling_mean_forecast(
    actual: Sequence[float], *, window: int
) -> Tuple[np.ndarray, np.ndarray]:
    """Rolling mean (RM) one-step-ahead forecast."""
    x = np.asarray(actual, dtype=float).reshape(-1)
    if window <= 0:
        raise ValueError("window must be > 0")
    if x.size <= window:
        raise ValueError("Need len(actual) > window to compute rolling forecasts")

    idx = np.arange(window, x.size)
    f = np.array([np.mean(x[i - window : i]) for i in idx], dtype=float)
    return idx, f


def _two_sided_p_value_t_or_normal(stat: float, *, df: int) -> float:
    """Two-sided p-value for a t-statistic; SciPy if available else normal approx."""
    try:
        from scipy.stats import t as student_t  # type: ignore

        return float(2.0 * student_t.sf(abs(stat), df=int(df)))
    except Exception:
        # Normal approximation: p = 2 * (1 - Phi(|z|)) = erfc(|z| / sqrt(2)).
        return float(math.erfc(abs(stat) / math.sqrt(2.0)))


def diebold_mariano_squared_error(
    actual: Sequence[float],
    forecast_sc: Sequence[float],
    forecast_rm: Sequence[float],
    *,
    df: Optional[int] = None,
) -> DMResult:
    """DM test for one-step forecasts with squared-error loss."""
    y = np.asarray(actual, dtype=float).reshape(-1)
    f_sc = np.asarray(forecast_sc, dtype=float).reshape(-1)
    f_rm = np.asarray(forecast_rm, dtype=float).reshape(-1)

    if not (y.size == f_sc.size == f_rm.size):
        raise ValueError("actual, forecast_sc, forecast_rm must have equal length")

    Tn = int(y.size)
    if Tn < 3:
        raise ValueError("Need at least 3 points for DM test")

    e_sc = y - f_sc
    e_rm = y - f_rm
    d = e_sc**2 - e_rm**2

    mean_d = float(np.mean(d))
    var_d = float(np.mean((d - mean_d) ** 2))
    DM = 0.0 if var_d <= 0.0 else float(mean_d / float(np.sqrt(var_d / Tn)))
    DM_star = float(DM * np.sqrt((Tn - 1) / Tn))

    df_use = int(df) if df is not None else (Tn - 1)
    p = _two_sided_p_value_t_or_normal(DM_star, df=df_use)

    return DMResult(
        T=Tn,
        mean_d=mean_d,
        var_d=var_d,
        DM=float(DM),
        DM_star=DM_star,
        p_value_two_sided=float(p),
    )


def rolling_rmse(
    actual: Sequence[float], forecast: Sequence[float], *, window: int
) -> Tuple[np.ndarray, np.ndarray]:
    """Rolling RMSE over a fixed window."""
    y = np.asarray(actual, dtype=float).reshape(-1)
    f = np.asarray(forecast, dtype=float).reshape(-1)
    if y.size != f.size:
        raise ValueError("actual and forecast must have same length")
    if window <= 1 or y.size < window:
        raise ValueError("window must be >=2 and <= len(series)")

    idx = np.arange(window - 1, y.size)
    out = []
    for i in idx:
        err = y[i - window + 1 : i + 1] - f[i - window + 1 : i + 1]
        out.append(float(np.sqrt(np.mean(err**2))))
    return idx, np.asarray(out, dtype=float)

