"""
SPY overnight (close→close) filters from VIX / calmness features.

Timing (no lookahead):
  * Features use data through close of day ``t``.
  * If signal is True on ``t``, buy SPY at close ``t``, sell at close ``t+1``.
  * Strategy return is booked on day ``t+1``: ``close[t+1]/close[t] - 1``.

Intent (two research objectives — they conflict empirically):

  * ``calm`` — hold only in quiet VIX regimes (lower DD, modest drift).
    Empirically these are *not* the largest overnight winners.
  * ``fear`` — hold when VIX stress / pullback features resemble historical
    *top-decile* overnight nights (elevated VIX, weak SPY). Closer to the
    “10% best days” magnitude goal; fatter left tail.

Neither is an oracle of realized best days.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Iterable

import numpy as np
import pandas as pd

from RenTech.strategy_stack.run_qs_top_ideas_backtest import _rsi


FEATURE_COLS = (
    "vix",
    "vix_rsi14",
    "vix_pct_252",
    "vix_dist_weekly_high",
    "vix_dist_monthly_high",
    "vix_below_weekly_high",
    "vix_below_monthly_high",
    "vix_chg_5d",
    "vix_term_ratio",  # VIX / VIX3M; <1 = contango / calmer
    "vvix",
    "vvix_pct_252",
    "spy_rv20",
    "spy_rv20_pct_252",
    "spy_atr_pct14",
    "spy_bb_width20",
    "spy_above_sma200",
    "spy_ret_5d",
)


def _rolling_high(s: pd.Series, n: int) -> pd.Series:
    return s.rolling(n, min_periods=max(3, n // 2)).max()


def _pct_rank(s: pd.Series, n: int = 252) -> pd.Series:
    return s.rolling(n, min_periods=max(60, n // 4)).apply(
        lambda x: pd.Series(x).rank(pct=True).iloc[-1], raw=False
    )


def build_feature_frame(
    spy: pd.DataFrame,
    vix: pd.DataFrame,
    *,
    vvix: pd.DataFrame | None = None,
    vix3m: pd.DataFrame | None = None,
) -> pd.DataFrame:
    """One row per SPY session; all columns known at that day's close."""
    idx = spy.index
    vc = vix["close"].reindex(idx).ffill()
    vh = vix["high"].reindex(idx).ffill() if "high" in vix.columns else vc

    weekly_high = _rolling_high(vh, 5)
    monthly_high = _rolling_high(vh, 21)

    spy_c = spy["close"].astype(float)
    spy_h = spy["high"].astype(float)
    spy_l = spy["low"].astype(float)
    logret = np.log(spy_c / spy_c.shift(1))
    rv20 = logret.rolling(20, min_periods=10).std() * np.sqrt(252.0)
    tr = pd.concat(
        [
            (spy_h - spy_l),
            (spy_h - spy_c.shift(1)).abs(),
            (spy_l - spy_c.shift(1)).abs(),
        ],
        axis=1,
    ).max(axis=1)
    atr14 = tr.rolling(14, min_periods=7).mean()
    mid20 = spy_c.rolling(20, min_periods=20).mean()
    std20 = spy_c.rolling(20, min_periods=20).std()

    out = pd.DataFrame(index=idx)
    out["vix"] = vc
    out["vix_rsi14"] = _rsi(vc, 14)
    out["vix_pct_252"] = _pct_rank(vc, 252)
    out["vix_dist_weekly_high"] = (weekly_high - vc) / weekly_high.replace(0, np.nan)
    out["vix_dist_monthly_high"] = (monthly_high - vc) / monthly_high.replace(0, np.nan)
    out["vix_below_weekly_high"] = (vc < weekly_high * 0.98).astype(float)
    out["vix_below_monthly_high"] = (vc < monthly_high * 0.95).astype(float)
    out["vix_chg_5d"] = vc.pct_change(5)

    if vix3m is not None and len(vix3m):
        v3 = vix3m["close"].reindex(idx).ffill()
        out["vix_term_ratio"] = vc / v3.replace(0, np.nan)
    else:
        out["vix_term_ratio"] = np.nan

    if vvix is not None and len(vvix):
        vv = vvix["close"].reindex(idx).ffill()
        out["vvix"] = vv
        out["vvix_pct_252"] = _pct_rank(vv, 252)
    else:
        out["vvix"] = np.nan
        out["vvix_pct_252"] = np.nan

    out["spy_rv20"] = rv20
    out["spy_rv20_pct_252"] = _pct_rank(rv20, 252)
    out["spy_atr_pct14"] = atr14 / spy_c
    out["spy_bb_width20"] = (2.0 * std20) / mid20.replace(0, np.nan)
    out["spy_above_sma200"] = (spy_c > spy_c.rolling(200, min_periods=100).mean()).astype(
        float
    )
    out["spy_ret_5d"] = spy_c.pct_change(5)
    return out


def next_close_to_close_return(spy: pd.DataFrame) -> pd.Series:
    """Return earned by buying at today's close and selling at tomorrow's close.

    Indexed on signal day ``t`` (same as features). Strategy equity books this
    on ``t+1`` via :func:`signal_to_strategy_returns`.
    """
    c = spy["close"].astype(float)
    return (c.shift(-1) / c - 1.0)


def signal_to_strategy_returns(signal_on_t: pd.Series, fwd_c2c: pd.Series) -> pd.Series:
    """Map signal-day decisions to exit-day returns (cash = 0)."""
    # Invest on t → PnL appears on t+1
    invested = signal_on_t.shift(1).fillna(False).astype(bool)
    ret = fwd_c2c.shift(1)  # align: fwd_c2c[t] was earned overnight t→t+1 → book on t+1
    out = pd.Series(0.0, index=signal_on_t.index, dtype=float)
    mask = invested & ret.notna()
    out.loc[mask] = ret.loc[mask].astype(float)
    return out


def oracle_top_frac_mask(fwd_c2c: pd.Series, frac: float = 0.10) -> pd.Series:
    """Lookahead ceiling: days whose *realized* forward return is in top ``frac``."""
    x = fwd_c2c.replace([np.inf, -np.inf], np.nan).dropna()
    if x.empty:
        return pd.Series(False, index=fwd_c2c.index)
    thr = float(x.quantile(1.0 - frac))
    return (fwd_c2c >= thr).fillna(False)


@dataclass(frozen=True)
class CalmScoreWeights:
    """Higher score = calmer / more attractive overnight long."""

    vix_pct: float = 1.0
    vix_rsi: float = 1.0
    dist_weekly: float = 0.75
    dist_monthly: float = 0.75
    term: float = 0.5
    vvix_pct: float = 0.5
    rv_pct: float = 0.75
    trend: float = 0.5


def calm_score(features: pd.DataFrame, w: CalmScoreWeights | None = None) -> pd.Series:
    """Composite 0–1-ish score; higher = calmer risk-on overnight candidate."""
    w = w or CalmScoreWeights()
    f = features

    def _inv_pct(col: str) -> pd.Series:
        s = f[col]
        return 1.0 - s.clip(0.0, 1.0)

    def _rsi_calm(col: str) -> pd.Series:
        # RSI mid-low (cooling) preferred over extreme panic or extreme complacency
        r = f[col]
        # map: prefer RSI in ~30–55; score peaks near 40
        return 1.0 - ((r - 40.0).abs() / 60.0).clip(0.0, 1.0)

    parts: list[pd.Series] = []
    weights: list[float] = []

    parts.append(_inv_pct("vix_pct_252"))
    weights.append(w.vix_pct)
    parts.append(_rsi_calm("vix_rsi14"))
    weights.append(w.vix_rsi)
    parts.append(f["vix_dist_weekly_high"].clip(0.0, 0.25) / 0.25)
    weights.append(w.dist_weekly)
    parts.append(f["vix_dist_monthly_high"].clip(0.0, 0.35) / 0.35)
    weights.append(w.dist_monthly)

    if f["vix_term_ratio"].notna().any():
        # Contango (ratio < 1) scores high; deep backwardation scores low
        term = (1.05 - f["vix_term_ratio"]).clip(0.0, 0.4) / 0.4
        parts.append(term)
        weights.append(w.term)

    if f["vvix_pct_252"].notna().any():
        parts.append(_inv_pct("vvix_pct_252"))
        weights.append(w.vvix_pct)

    parts.append(_inv_pct("spy_rv20_pct_252"))
    weights.append(w.rv_pct)
    parts.append(f["spy_above_sma200"])
    weights.append(w.trend)

    stack = pd.concat(parts, axis=1)
    warr = np.asarray(weights, dtype=float)
    warr = warr / warr.sum()
    scored = stack.mul(warr, axis=1).sum(axis=1, min_count=1)
    return scored


def top_frac_score_signal(
    score: pd.Series,
    *,
    frac: float = 0.10,
    lookback: int = 252,
    min_periods: int = 60,
) -> pd.Series:
    """True when today's score is in the top ``frac`` of the trailing window (excl. today)."""
    # lag 1 so threshold uses only past scores
    past = score.shift(1)
    thr = past.rolling(lookback, min_periods=min_periods).quantile(1.0 - frac)
    return (score >= thr).fillna(False)


def hard_calm_rules_signal(features: pd.DataFrame) -> pd.Series:
    """Intersecting calmness rules (often ~20–30% coverage; not top-decile nights).

    Tuned for interpretability, not max Sharpe. Requires:
      * VIX below ~40th percentile of 1y
      * VIX RSI(14) between 25 and 55 (cooling / not panicked)
      * VIX not near weekly high (≥2% below 5d high)
      * VIX ≥5% below 21d high
      * SPY above SMA200
      * Realized vol not in top quartile of 1y
    Optional when available: VIX/VIX3M < 1.05, VVIX below median.
    """
    f = features
    m = (
        (f["vix_pct_252"] <= 0.40)
        & (f["vix_rsi14"] >= 25.0)
        & (f["vix_rsi14"] <= 55.0)
        & (f["vix_dist_weekly_high"] >= 0.02)
        & (f["vix_dist_monthly_high"] >= 0.05)
        & (f["spy_above_sma200"] > 0.5)
        & (f["spy_rv20_pct_252"] <= 0.75)
    )
    if f["vix_term_ratio"].notna().mean() > 0.5:
        m = m & (f["vix_term_ratio"] < 1.05)
    if f["vvix_pct_252"].notna().mean() > 0.5:
        m = m & (f["vvix_pct_252"] <= 0.50)
    return m.fillna(False)


def fear_score(features: pd.DataFrame) -> pd.Series:
    """Higher = more like historical top-decile overnight nights (stress / pullback)."""
    f = features
    vix_pct = f["vix_pct_252"].clip(0.0, 1.0).fillna(0.5)
    rsi = (f["vix_rsi14"].fillna(50.0) / 100.0).clip(0.0, 1.0)
    near_wh = (1.0 - f["vix_dist_weekly_high"].clip(0.0, 0.2) / 0.2).fillna(0.5)
    near_mh = (1.0 - f["vix_dist_monthly_high"].clip(0.0, 0.35) / 0.35).fillna(0.5)
    spy_weak = ((-f["spy_ret_5d"]).clip(0.0, 0.05) / 0.05).fillna(0.0)
    vv = f["vvix_pct_252"].clip(0.0, 1.0).fillna(vix_pct)
    return 1.25 * vix_pct + rsi + 0.5 * near_wh + 0.5 * near_mh + spy_weak + 0.5 * vv


def hard_fear_rules_signal(features: pd.DataFrame) -> pd.Series:
    """Stress/pullback rules that historically lift mean overnight C2C (~10% coverage).

    Inspired by feature lift on oracle top-decile nights:
      * VIX ≥ 70th percentile of 1y **or** VIX RSI(14) ≥ 70
      * SPY 5d return < 0
      * Prefer not chasing VIX exactly at the weekly high (dist ≥ 0 — allow touch)
    """
    f = features
    stress = (f["vix_pct_252"] >= 0.70) | (f["vix_rsi14"] >= 70.0)
    pullback = f["spy_ret_5d"] < 0.0
    return (stress & pullback).fillna(False)


def feature_lift_table(
    features: pd.DataFrame,
    fwd_c2c: pd.Series,
    *,
    top_frac: float = 0.10,
    cols: Iterable[str] | None = None,
) -> pd.DataFrame:
    """Compare feature means on oracle top-frac nights vs all nights."""
    cols = list(cols) if cols is not None else [c for c in FEATURE_COLS if c in features.columns]
    top = oracle_top_frac_mask(fwd_c2c, top_frac)
    base = fwd_c2c.notna()
    rows = []
    for c in cols:
        s = features[c]
        rows.append(
            {
                "feature": c,
                "mean_all": float(s.loc[base].mean()),
                "mean_top": float(s.loc[top].mean()),
                "mean_bot": float(s.loc[base & ~top].mean()),
                "lift_top_minus_all": float(s.loc[top].mean() - s.loc[base].mean()),
            }
        )
    return pd.DataFrame(rows).sort_values("lift_top_minus_all", key=lambda x: x.abs(), ascending=False)


def conditional_stats(mask: pd.Series, fwd_c2c: pd.Series) -> dict[str, float]:
    x = fwd_c2c.loc[mask.fillna(False) & fwd_c2c.notna()]
    all_x = fwd_c2c.dropna()
    if len(x) < 5:
        return {
            "n": float(len(x)),
            "coverage_pct": float(mask.mean() * 100.0) if len(mask) else 0.0,
            "mean_bps": float("nan"),
            "hit_rate_pct": float("nan"),
            "mean_vs_all_bps": float("nan"),
        }
    return {
        "n": float(len(x)),
        "coverage_pct": round(float(mask.mean()) * 100.0, 2),
        "mean_bps": round(float(x.mean()) * 1e4, 2),
        "hit_rate_pct": round(float((x > 0).mean()) * 100.0, 1),
        "mean_vs_all_bps": round(float(x.mean() - all_x.mean()) * 1e4, 2),
        "median_bps": round(float(x.median()) * 1e4, 2),
    }
