"""Walk-forward cross-sectional crypto PRICE-factor backtest over the crypto_pit panel.
Daily rebalance, PIT universe, equal-weight construction by a pluggable price/volume score,
booking the PRICE return of the held weights minus a liquidity-scaled turnover cost.
Produces a daily net-return series per construction mode.

MIRRORS funding_backtest_runner: the only differences are the pluggable `score_fn` dimension
(callers bind lookback/window via functools.partial) and PRICE-return booking (reusing
`daily_returns_for_weights`) instead of signed funding carry."""
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Callable

import numpy as np

from fxhnt.domain.backtest import compute_stats
from fxhnt.domain.cross_sectional_funding import Panel, construction_weights, eligible_asof
from fxhnt.domain.equity_backtest import daily_returns_for_weights
from fxhnt.domain.gauntlet.core import evaluate, per_period_sharpe

_PERIODS_PER_YEAR = 365   # crypto trades daily, all year

_MODES = ("long_tilt", "market_neutral")

# score_fn(panel, eligible, asof_day) -> {symbol: score}; lookback/window bound via partial.
ScoreFn = Callable[[Panel, list[str], int], dict[str, float]]


@dataclass(frozen=True)
class FactorRunResult:
    returns_by_mode: dict[str, np.ndarray]
    n_names_avg: float
    n_rebalances: int       # active rebalances actually booked (= len of each mode's series)


def _close_panel(panel: Panel, subset: set[str]) -> dict[str, dict[int, float]]:
    """Build a close-only panel {s:{day:close}} for `subset` from the (close, qvol, funding) panel."""
    return {s: {d: panel[s][d][0] for d in panel[s]} for s in subset}


class CryptoFactorRunner:
    def __init__(self, panel: Panel, score_fn: ScoreFn, *,
                 min_qvol: float = 1e6, min_history: int = 30,
                 quantile: float = 0.2, cost_bps: float = 8.0,
                 slip_coef: float = 0.0005, slip_cap_bps: float = 50.0,
                 liq_ref: float = 1e6) -> None:
        self._p = panel
        self._score_fn = score_fn
        self._minq, self._minh, self._q = min_qvol, min_history, quantile
        self._cost = cost_bps / 1e4
        self._slip_coef, self._slip_cap = slip_coef, slip_cap_bps / 1e4
        self._liq_ref = liq_ref

    def _coin_cost(self, qvol: float) -> float:
        """Round-trip cost: taker fee both legs + liquidity-scaled slippage (thin=more)."""
        slip = min(self._slip_cap, self._slip_coef * (self._liq_ref / qvol)) if qvol > 0 else self._slip_cap
        return self._cost + slip

    def _last_qvol(self, sym: str, day: int) -> float:
        """Most recent qvol on/before `day` for `sym` (held coins that dropped out still have history)."""
        series = self._p.get(sym, {})
        days = [x for x in series if x <= day]
        return series[max(days)][1] if days else 0.0

    def run(self) -> FactorRunResult:
        all_days = sorted({d for s in self._p.values() for d in s})
        closes = _close_panel(self._p, set(self._p))
        series: dict[str, list[float]] = {m: [] for m in _MODES}
        prev_w: dict[str, dict[str, float]] = {m: {} for m in _MODES}
        names_seen = 0
        n_rebals = 0
        for i in range(len(all_days) - 1):
            d, nxt = all_days[i], all_days[i + 1]
            elig = eligible_asof(self._p, d, min_qvol=self._minq, min_history=self._minh)
            if len(elig) < 4:                       # need a cross-section to rank
                continue
            scores = self._score_fn(self._p, elig, d)
            held = set().union(*(prev_w[m] for m in _MODES)) if any(prev_w[m] for m in _MODES) else set()
            cost = {s: self._coin_cost(self._last_qvol(s, d)) for s in set(elig) | held}
            names_seen += len(elig)
            n_rebals += 1
            for m in _MODES:
                w = construction_weights(scores, m, quantile=self._q)
                series[m].append(_book(w, prev_w[m], closes, d, nxt, cost))
                prev_w[m] = w
        return FactorRunResult(
            returns_by_mode={m: np.asarray(series[m], dtype=float) for m in _MODES},
            n_names_avg=(names_seen / n_rebals) if n_rebals else 0.0,
            n_rebalances=n_rebals,
        )


def _book(w: dict[str, float], prev_w: dict[str, float],
          closes: dict[str, dict[int, float]], d: int, nxt: int,
          cost: dict[str, float]) -> float:
    """PRICE return the NEW book `w` earns over [d, nxt] (via daily_returns_for_weights, borrow=0)
    minus the round-trip rebalance cost charged on turnover between the prior book and the new one."""
    rets = daily_returns_for_weights(w, closes, [d, nxt], borrow_daily=0.0)
    realized = rets[0] if rets else 0.0
    turnover = sum(abs(w.get(s, 0.0) - prev_w.get(s, 0.0)) * cost.get(s, 0.0)
                   for s in set(w) | set(prev_w)) / 2.0
    return realized - turnover


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_factor_matrix(panel: Panel, factors: dict[str, ScoreFn], *,
                           floors: list[float], quantile: float = 0.2,
                           cost_bps: float = 8.0, slip_coef: float = 0.0005,
                           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 each factor's runner at every liquidity floor, gauntlet every (factor, mode, floor) cell.
    n_trials = total cells tested (multiple-testing correction).

    Measures CROSS-SECTIONAL PRICE-FACTOR returns: each cell's return series is the price return of
    the equal-weight construction (long_tilt / market_neutral) minus turnover cost. MIRRORS
    evaluate_funding_matrix — the only differences are the score_fn dimension and price-return booking."""
    runs = {(name, f): CryptoFactorRunner(panel, fn, min_qvol=f, quantile=quantile,
                                          cost_bps=cost_bps, slip_coef=slip_coef).run()
            for name, fn in factors.items() for f in floors}
    series_by_cell = {(name, m, f): runs[(name, f)].returns_by_mode[m]
                      for name in factors for f in floors for m in _MODES}
    n_trials = len(series_by_cell)
    is_sharpes = [per_period_sharpe(r[:int((1.0 - oos_fraction) * len(r))])
                  for r in series_by_cell.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": {
                               "price_factor": ("stats.sharpe/cagr/maxDD measure CROSS-SECTIONAL PRICE-FACTOR "
                                                "returns (equal-weight long/long-short minus turnover cost). "
                                                "The illiquidity factor's long leg is thin coins — its net edge "
                                                "is COST-FRAGILE; raise cost_bps/slip_coef to stress it."),
                               "sharpe_annualization": ("stats.sharpe uses 365/yr (crypto); verdict is_sharpe/"
                                                        "oos_sharpe use the gauntlet's 252/yr default. PASS/FAIL is "
                                                        "internally consistent; do not compare the two Sharpes numerically."),
                           },
                           "settings": {"floors": floors, "quantile": quantile, "cost_bps": cost_bps,
                                        "slip_coef": slip_coef, "oos_fraction": oos_fraction,
                                        "factors": sorted(factors)}}
    for (name, m, f), r in series_by_cell.items():
        stats = compute_stats(r, periods_per_year=_PERIODS_PER_YEAR)
        key = f"{name}/{m}@{f:.0e}"
        if len(r) < 3:
            out["cells"][key] = {"factor": name, "mode": m, "floor": float(f),
                                 "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=None)   # price factors have no a-priori structural premium
        out["cells"][key] = {"factor": name, "mode": m, "floor": float(f),
                             "stats": _json_floats(stats.model_dump()),
                             "verdict": _json_floats(v.model_dump())}
    return out
