"""Overfitting detection tests for quantitative trading strategies.

Implements five academic/practitioner tests:
1. DSR  — Deflated Sharpe Ratio (Bailey & Lopez de Prado, 2014)
2. PBO  — Probability of Backtest Overfitting via CSCV (Bailey et al., 2017)
3. SENS — Parameter Sensitivity / Plateau Analysis
4. MC   — Monte Carlo Noise Injection
5. PERM — Walk-Forward Permutation Test

Usage:
    from libs.backtest.overfit import run_overfit_check
    report = run_overfit_check("return_max_long_v7.70", initial_equity=10_000)
"""
from __future__ import annotations

import copy
import json
import math
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from itertools import combinations
from pathlib import Path
from typing import Any, Callable

import numpy as np
import pyarrow.parquet as pq
from scipy.stats import gaussian_kde, kurtosis, norm, skew


# ---------------------------------------------------------------------------
# Result models
# ---------------------------------------------------------------------------
@dataclass
class DSRResult:
    observed_sharpe: float
    expected_null_sharpe: float
    deflated_sharpe: float
    dsr_pvalue: float
    n_trials: int
    sharpe_haircut_pct: float
    min_track_record_days: float
    data_days: int
    all_sharpes: list[float] = field(default_factory=list)
    verdict: str = ""

    def __post_init__(self):
        if not self.verdict:
            if self.dsr_pvalue > 0.95:
                self.verdict = "PASS"
            elif self.dsr_pvalue > 0.50:
                self.verdict = "WARN"
            else:
                self.verdict = "FAIL"


@dataclass
class PBOResult:
    pbo_probability: float
    n_strategies: int
    n_subsets: int
    n_combinations: int
    median_oos_rank: float
    oos_above_50_pct: float
    oos_sharpe_positive_pct: float
    logit_mean: float
    verdict: str = ""

    def __post_init__(self):
        if not self.verdict:
            if self.pbo_probability < 0.10:
                self.verdict = "PASS"
            elif self.pbo_probability < 0.30:
                self.verdict = "WARN"
            else:
                self.verdict = "FAIL"


@dataclass
class ParamSensitivityResult:
    param_name: str
    values_tested: list[float]
    sharpe_values: list[float]
    plateau_score: float
    verdict: str = ""

    def __post_init__(self):
        if not self.verdict:
            if self.plateau_score > 0.70:
                self.verdict = "PASS"
            elif self.plateau_score > 0.40:
                self.verdict = "WARN"
            else:
                self.verdict = "FAIL"


@dataclass
class MonteCarloResult:
    baseline_sharpe: float
    noise_level_pct: float
    n_iterations: int
    median_noised_sharpe: float
    p05_sharpe: float
    degradation_pct: float
    all_sharpes: list[float] = field(default_factory=list)
    verdict: str = ""

    def __post_init__(self):
        if not self.verdict:
            if self.degradation_pct < 15:
                self.verdict = "PASS"
            elif self.degradation_pct < 35:
                self.verdict = "WARN"
            else:
                self.verdict = "FAIL"


@dataclass
class PermutationResult:
    observed_sharpe: float
    n_permutations: int
    p_value: float
    null_median_sharpe: float
    null_p95_sharpe: float
    verdict: str = ""

    def __post_init__(self):
        if not self.verdict:
            if self.p_value < 0.01:
                self.verdict = "PASS"
            elif self.p_value < 0.05:
                self.verdict = "WARN"
            else:
                self.verdict = "FAIL"


@dataclass
class OverfitReport:
    experiment_name: str
    experiment_id: int | None
    overall_verdict: str
    overall_score: float
    dsr: DSRResult | None = None
    pbo: PBOResult | None = None
    param_sensitivity: list[ParamSensitivityResult] = field(default_factory=list)
    monte_carlo: MonteCarloResult | None = None
    permutation: PermutationResult | None = None
    timestamp: str = ""
    elapsed_seconds: float = 0

    def __post_init__(self):
        if not self.timestamp:
            self.timestamp = datetime.now(timezone.utc).isoformat()


# ---------------------------------------------------------------------------
# Test 1: Deflated Sharpe Ratio
# ---------------------------------------------------------------------------
def compute_dsr(
    daily_returns: np.ndarray,
    observed_sharpe: float,
    n_trials: int,
    all_sharpes: list[float] | None = None,
) -> DSRResult:
    T = len(daily_returns)
    g3 = float(skew(daily_returns))
    g4 = float(kurtosis(daily_returns, fisher=False))
    gamma = 0.5772156649

    if all_sharpes and len(all_sharpes) > 1:
        var_sr = float(np.var(all_sharpes, ddof=1))
    else:
        var_sr = 1.0 / T

    sr0 = math.sqrt(var_sr) * (
        (1 - gamma) * norm.ppf(1 - 1.0 / max(n_trials, 2))
        + gamma * norm.ppf(1 - 1.0 / (max(n_trials, 2) * math.e))
    )

    numer = (observed_sharpe - sr0) * math.sqrt(T - 1)
    denom = math.sqrt(max(1e-10, 1 - g3 * observed_sharpe + (g4 - 1) / 4 * observed_sharpe**2))
    dsr = float(norm.cdf(numer / denom))

    haircut = sr0 / observed_sharpe * 100 if observed_sharpe > 0 else 100.0
    surviving_sr = max(0, observed_sharpe - sr0)

    min_trl = float("inf")
    if surviving_sr > 0:
        min_trl = 1 + (1 - g3 * surviving_sr + (g4 - 1) / 4 * surviving_sr**2) * (
            norm.ppf(0.95) / surviving_sr
        ) ** 2

    return DSRResult(
        observed_sharpe=observed_sharpe,
        expected_null_sharpe=sr0,
        deflated_sharpe=surviving_sr,
        dsr_pvalue=dsr,
        n_trials=n_trials,
        sharpe_haircut_pct=haircut,
        min_track_record_days=min_trl,
        data_days=T,
        all_sharpes=all_sharpes or [],
    )


# ---------------------------------------------------------------------------
# Test 2: PBO via CSCV
# ---------------------------------------------------------------------------
def compute_pbo(
    returns_matrix: np.ndarray,
    n_subsets: int = 10,
) -> PBOResult:
    T, N = returns_matrix.shape
    chunk = T // n_subsets
    blocks = [returns_matrix[i * chunk : (i + 1) * chunk] for i in range(n_subsets)]
    if T > n_subsets * chunk:
        blocks[-1] = returns_matrix[(n_subsets - 1) * chunk :]

    combos = list(combinations(range(n_subsets), n_subsets // 2))

    lambdas = []
    oos_ranks = []
    oos_sharpes = []

    for combo in combos:
        test_idx = sorted(set(range(n_subsets)) - set(combo))
        train = np.concatenate([blocks[i] for i in combo])
        test = np.concatenate([blocks[i] for i in test_idx])

        train_std = train.std(axis=0, ddof=1)
        train_std[train_std == 0] = 1e-10
        train_sr = train.mean(axis=0) / train_std

        test_std = test.std(axis=0, ddof=1)
        test_std[test_std == 0] = 1e-10
        test_sr = test.mean(axis=0) / test_std

        best_is = int(np.argmax(train_sr))
        rank = float((test_sr < test_sr[best_is]).sum()) / N
        rank = max(0.01, min(0.99, rank))
        oos_ranks.append(rank)
        lambdas.append(math.log(rank / (1 - rank)))
        oos_sharpes.append(float(test_sr[best_is]))

    lambdas_arr = np.array(lambdas)
    oos_ranks_arr = np.array(oos_ranks)

    kde = gaussian_kde(lambdas_arr)
    pbo = float(kde.integrate_box_1d(-np.inf, 0))

    return PBOResult(
        pbo_probability=pbo,
        n_strategies=N,
        n_subsets=n_subsets,
        n_combinations=len(combos),
        median_oos_rank=float(np.median(oos_ranks_arr)),
        oos_above_50_pct=float((oos_ranks_arr > 0.5).mean() * 100),
        oos_sharpe_positive_pct=float((np.array(oos_sharpes) > 0).mean() * 100),
        logit_mean=float(lambdas_arr.mean()),
    )


# ---------------------------------------------------------------------------
# Test 3: Parameter Sensitivity
# ---------------------------------------------------------------------------
def compute_param_sensitivity(
    run_backtest_fn: Callable[[dict[str, Any]], float],
    base_config: dict[str, Any],
    param_path: str,
    values: list[float],
) -> ParamSensitivityResult:
    sharpes = []
    for val in values:
        cfg = copy.deepcopy(base_config)
        _set_nested(cfg, param_path, val)
        sr = run_backtest_fn(cfg)
        sharpes.append(sr)

    sharpes_arr = np.array(sharpes)
    if sharpes_arr.mean() > 0:
        cv = float(sharpes_arr.std(ddof=1) / sharpes_arr.mean())
        plateau = max(0, 1 - cv)
    else:
        plateau = 0.0

    return ParamSensitivityResult(
        param_name=param_path,
        values_tested=values,
        sharpe_values=sharpes,
        plateau_score=plateau,
    )


# ---------------------------------------------------------------------------
# Test 4: Monte Carlo Noise Injection
# ---------------------------------------------------------------------------
def compute_monte_carlo(
    run_backtest_fn: Callable[[float | None], float],
    baseline_sharpe: float,
    noise_pct: float = 0.5,
    n_iterations: int = 50,
) -> MonteCarloResult:
    noised_sharpes = []
    for i in range(n_iterations):
        sr = run_backtest_fn(noise_pct / 100.0)
        noised_sharpes.append(sr)

    arr = np.array(noised_sharpes)
    median_sr = float(np.median(arr))
    p05 = float(np.percentile(arr, 5))
    degradation = (1 - median_sr / baseline_sharpe) * 100 if baseline_sharpe > 0 else 100

    return MonteCarloResult(
        baseline_sharpe=baseline_sharpe,
        noise_level_pct=noise_pct,
        n_iterations=n_iterations,
        median_noised_sharpe=median_sr,
        p05_sharpe=p05,
        degradation_pct=max(0, degradation),
        all_sharpes=noised_sharpes,
    )


# ---------------------------------------------------------------------------
# Test 5: Walk-Forward Permutation
# ---------------------------------------------------------------------------
def compute_permutation(
    run_wfv_fn: Callable[[bool], float],
    observed_sharpe: float,
    n_permutations: int = 200,
) -> PermutationResult:
    null_sharpes = []
    for i in range(n_permutations):
        sr = run_wfv_fn(True)
        null_sharpes.append(sr)

    arr = np.array(null_sharpes)
    p_value = float((arr >= observed_sharpe).mean())

    return PermutationResult(
        observed_sharpe=observed_sharpe,
        n_permutations=n_permutations,
        p_value=p_value,
        null_median_sharpe=float(np.median(arr)),
        null_p95_sharpe=float(np.percentile(arr, 95)),
    )


# ---------------------------------------------------------------------------
# Overall scoring
# ---------------------------------------------------------------------------
WEIGHTS = {
    "dsr": 0.25,
    "pbo": 0.25,
    "sensitivity": 0.15,
    "mc": 0.20,
    "permutation": 0.15,
}


def _test_score(result: Any) -> float:
    if result is None:
        return -1
    if isinstance(result, list):
        if not result:
            return -1
        return float(np.mean([_test_score(r) for r in result]))
    v = result.verdict
    if v == "PASS":
        return 100
    elif v == "WARN":
        return 55
    else:
        return 15


def compute_overall_score(report: OverfitReport) -> tuple[float, str]:
    scores = {
        "dsr": _test_score(report.dsr),
        "pbo": _test_score(report.pbo),
        "sensitivity": _test_score(report.param_sensitivity),
        "mc": _test_score(report.monte_carlo),
        "permutation": _test_score(report.permutation),
    }

    total_weight = sum(w for k, w in WEIGHTS.items() if scores[k] >= 0)
    if total_weight == 0:
        return 0, "UNKNOWN"

    weighted = sum(scores[k] * WEIGHTS[k] for k in WEIGHTS if scores[k] >= 0)
    overall = weighted / total_weight

    if overall >= 70:
        verdict = "PASS"
    elif overall >= 40:
        verdict = "WARN"
    else:
        verdict = "FAIL"

    return overall, verdict


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _set_nested(d: dict, path: str, value: Any) -> None:
    keys = path.split(".")
    for k in keys[:-1]:
        d = d.setdefault(k, {})
    d[keys[-1]] = value


def load_equity_curve_returns(run_dir: Path) -> np.ndarray | None:
    eq_path = run_dir / "artifacts" / "daily_equity_curve.parquet"
    if not eq_path.exists():
        return None
    eq = pq.read_table(str(eq_path))
    equities = eq.column("equity").to_pylist()
    if len(equities) < 2:
        return None
    returns = np.diff(equities) / np.array(equities[:-1])
    return returns


def load_sibling_returns_matrix(
    experiment_name: str,
    runs_root: Path = Path("runs"),
) -> tuple[np.ndarray | None, list[str]]:
    """Load daily return series from all sibling CW runs into a (T, N) matrix."""
    strats: dict[str, np.ndarray] = {}

    for d in sorted(runs_root.glob("v7.*_10k_cw")):
        name = d.name.replace("_10k_cw", "")
        for r in d.glob("bt_*"):
            rets = load_equity_curve_returns(r)
            if rets is not None and len(rets) > 100:
                strats[name] = rets

    if len(strats) < 3:
        return None, []

    min_len = min(len(r) for r in strats.values())
    names = sorted(strats.keys())
    matrix = np.column_stack([strats[n][:min_len] for n in names])
    return matrix, names
