"""Time-series trend-following (TSMOM) walk-forward backtest over a price panel.

A directional (long/short) harness: per rebalance each asset takes a multi-horizon trend
sign (`ts_trend_signal`), scaled to a per-asset vol target, held to the next rebalance, and
booked as the realized price return minus a turnover cost. Long in uptrends, short in
downtrends (crisis-alpha) when `long_short=True`. Reusable for crypto + ETF close panels.

A "price panel" = dict[str, dict[int, float]] = {symbol: {epoch_day: close}}.
"""
from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Any

import numpy as np

from fxhnt.domain.backtest import compute_stats
from fxhnt.domain.gauntlet.core import evaluate, per_period_sharpe
from fxhnt.domain.strategies.equity_factor import realized_vol_from_closes
from fxhnt.domain.ts_trend import ts_trend_signal


@dataclass(frozen=True)
class TrendRunResult:
    returns: np.ndarray
    n_assets_avg: float
    n_rebalances: int
    dates: list[int]       # epoch-day each booked return is REALIZED on (`nxt`), parallel to returns


class TrendRunner:
    def __init__(self, prices: dict[str, dict[int, float]], *,
                 lookbacks: tuple[int, ...] = (20, 60, 120), vol_window: int = 30,
                 target_vol: float = 0.10, min_history: int = 130, cost_bps: float = 10.0,
                 long_short: bool = True, gross_cap: float = 1.0,
                 periods_per_year: int = 252,
                 funding: dict[str, dict[int, float]] | None = None) -> None:
        self._p = prices
        self._lb = list(lookbacks)
        self._vol_window = vol_window
        self._target_vol = target_vol
        self._min_history = min_history
        self._cost = cost_bps / 1e4
        self._long_short = long_short
        self._gross_cap = gross_cap
        self._ppy = periods_per_year
        # {symbol: {epoch_day: daily_funding}} — daily perp funding (8h rates already summed per day).
        # When supplied, each position pays `weight·funding` over the holding day it is realized on: a long
        # (weight>0) PAYS positive funding (a drag), a short RECEIVES it. None → pure price-trend P&L.
        self._funding = funding

    def _closes_asof(self, sym: str, day: int) -> list[float]:
        series = self._p[sym]
        return [series[d] for d in sorted(series) if d <= day]

    def _positions_for_day(self, d: int, target_vol_daily: float) -> dict[str, float]:
        """The gross-capped per-symbol position vector formed ON day `d` — the EXACT signal+sizing logic
        `run()`'s loop uses, factored out so `run()` and `weights_and_contributions()` share one source of
        truth (no drift between the booked return and the surfaced weights)."""
        pos: dict[str, float] = {}
        for sym, series in self._p.items():
            if d not in series:
                continue
            closes = self._closes_asof(sym, d)
            if len(closes) < self._min_history:
                continue
            sig = ts_trend_signal(closes, self._lb)
            if sig is None:
                continue
            rv = realized_vol_from_closes(closes, window=self._vol_window)
            if rv is None or rv <= 0:
                continue
            p = sig * (target_vol_daily / rv)
            if not self._long_short:
                p = max(0.0, p)
            if p != 0.0:
                pos[sym] = p
        # cap gross leverage: scale down so sum(|pos|) <= gross_cap
        gross = sum(abs(v) for v in pos.values())
        if gross > self._gross_cap and gross > 0:
            scale = self._gross_cap / gross
            pos = {s: v * scale for s, v in pos.items()}
        return pos

    def run(self) -> TrendRunResult:
        all_days = sorted({d for s in self._p.values() for d in s})
        target_vol_daily = self._target_vol / math.sqrt(self._ppy)
        rets: list[float] = []
        dates: list[int] = []
        prev_pos: dict[str, float] = {}
        assets_seen = 0
        n_rebals = 0
        for i in range(len(all_days) - 1):
            d, nxt = all_days[i], all_days[i + 1]
            pos = self._positions_for_day(d, target_vol_daily)
            # book realized return over [d, nxt] minus turnover cost
            realized = 0.0
            for sym, p in pos.items():
                series = self._p[sym]
                if nxt in series and series[d] > 0 and series[nxt] > 0:
                    realized += p * (series[nxt] / series[d] - 1.0)
                # perp funding accrued over the holding day the return is realized on (`nxt`): a long
                # (p>0) PAYS positive funding, a short receives it. Absent for a symbol/day → no charge.
                if self._funding is not None:
                    realized -= p * self._funding.get(sym, {}).get(nxt, 0.0)
            turnover = sum(abs(pos.get(s, 0.0) - prev_pos.get(s, 0.0))
                           for s in set(pos) | set(prev_pos)) * self._cost / 2.0
            rets.append(realized - turnover)
            dates.append(int(nxt))           # return is realized on the next day
            assets_seen += len(pos)
            n_rebals += 1
            prev_pos = pos
        return TrendRunResult(
            returns=np.asarray(rets, dtype=float),
            n_assets_avg=(assets_seen / n_rebals) if n_rebals else 0.0,
            n_rebalances=n_rebals,
            dates=dates,
        )

    def weights_and_contributions(self) -> dict[int, dict[str, tuple[float, float]]]:
        """`{realized_day(nxt): {symbol: (weight, symbol_return)}}` reusing `run()`'s EXACT per-day position
        vector + the same `close[nxt]/close[d]−1` symbol return. The weight is the signed, gross-capped
        position `p` formed on day d; `Σ_symbol weight·symbol_return` equals `run()`'s GROSS realized return
        for that day (the turnover cost is a separate book-level haircut, applied identically by the live
        book's `trading_cost`). Keyed by `nxt` (the day the return is realized), matching `run().dates`. Pure."""
        all_days = sorted({d for s in self._p.values() for d in s})
        target_vol_daily = self._target_vol / math.sqrt(self._ppy)
        out: dict[int, dict[str, tuple[float, float]]] = {}
        for i in range(len(all_days) - 1):
            d, nxt = all_days[i], all_days[i + 1]
            pos = self._positions_for_day(d, target_vol_daily)
            row: dict[str, tuple[float, float]] = {}
            for sym, p in pos.items():
                series = self._p[sym]
                if nxt in series and series[d] > 0 and series[nxt] > 0:
                    ret = series[nxt] / series[d] - 1.0
                    # net the funding this position pays into the per-symbol return, so Σ weight·return
                    # still equals run()'s booked (cost-free) return when a funding panel is set.
                    if self._funding is not None:
                        ret -= self._funding.get(sym, {}).get(nxt, 0.0)
                    row[sym] = (p, ret)
            out[int(nxt)] = row
        return out


def _json_floats(d: dict[str, Any]) -> dict[str, Any]:
    """Coerce model_dump scalars to JSON-native: numpy → python float, NaN/inf → 0.0."""
    out: dict[str, Any] = {}
    for k, v in d.items():
        if isinstance(v, (np.floating, float)):
            fv = float(v)
            out[k] = fv if np.isfinite(fv) else 0.0
        elif isinstance(v, (np.integer,)):
            out[k] = int(v)
        else:
            out[k] = v
    return out


def evaluate_trend(prices: dict[str, dict[int, float]], *, periods_per_year: int,
                   configs: list[dict[str, Any]], oos_fraction: float = 0.40,
                   dsr_min: float = 0.95, oos_min_sharpe: float = 0.0,
                   max_is_oos_decay: float = 0.50) -> dict[str, Any]:
    """Run the TrendRunner for each config and gauntlet every cell.

    Each config is a dict of runner kwargs (e.g. different `lookbacks` / `long_short`), plus an
    optional `label`. n_trials = number of configs (multiple-testing correction). sr_variance is
    the cross-config variance of IS per-period Sharpe. JSON-NaN-safe; mirrors
    evaluate_funding_matrix's structure.
    """
    results: dict[str, np.ndarray] = {}
    labels: list[str] = []
    for idx, cfg in enumerate(configs):
        kwargs = {k: v for k, v in cfg.items() if k != "label"}
        label = str(cfg.get("label", f"cfg{idx}"))
        labels.append(label)
        results[label] = TrendRunner(prices, periods_per_year=periods_per_year,
                                     **kwargs).run().returns
    n_trials = len(configs)
    is_sharpes = [per_period_sharpe(r[:int((1.0 - oos_fraction) * len(r))])
                  for r in results.values() if len(r) > 3]
    sr_variance = float(np.var(is_sharpes)) if len(is_sharpes) > 1 else 0.0
    out: dict[str, Any] = {
        "cells": {},
        "n_trials": n_trials,
        "caveats": {
            "directional": ("stats describe a vol-scaled directional (long/short) TSMOM book; "
                            "execution slippage beyond the flat cost_bps turnover charge is NOT "
                            "modelled. Crypto = continuous; ETF/futures = calendar-day rebalances."),
            "sharpe_annualization": ("stats.sharpe uses the supplied periods_per_year; verdict "
                                     "is_sharpe/oos_sharpe use the gauntlet's 252/yr default. "
                                     "Do not compare the two Sharpes numerically."),
        },
        "settings": {"configs": [{"label": lbl, **{k: v for k, v in cfg.items() if k != "label"}}
                                 for lbl, cfg in zip(labels, configs)],
                     "oos_fraction": oos_fraction, "periods_per_year": periods_per_year},
    }
    for label in labels:
        r = results[label]
        stats = compute_stats(r, periods_per_year=periods_per_year)
        if len(r) < 3:
            out["cells"][label] = {
                "config_label": label,
                "stats": _json_floats(stats.model_dump()),
                "verdict": {"passed": False, "dsr": 0.0, "is_sharpe": 0.0, "oos_sharpe": 0.0,
                            "n_trials": n_trials, "reasons": ["insufficient data"], "pvalue": 1.0},
            }
            continue
        split = int((1.0 - oos_fraction) * len(r))
        v = evaluate(r[:split], r[split:], n_trials=n_trials, sr_variance=sr_variance,
                     dsr_min=dsr_min, oos_min_sharpe=oos_min_sharpe,
                     max_is_oos_decay=max_is_oos_decay,
                     has_economic_rationale=True)  # time-series momentum is a documented premium
        out["cells"][label] = {
            "config_label": label,
            "stats": _json_floats(stats.model_dump()),
            "verdict": _json_floats(v.model_dump()),
        }
    return out
