//! Epoch-level financial metrics computed from GPU trade statistics
//! and action counts. These are pushed to Prometheus at the end of each epoch.

use crate::cuda_pipeline::gpu_experience_collector::TradeStats;

/// Financial metrics summary for a single training epoch.
#[derive(Debug, Clone, Default)]
pub(crate) struct EpochFinancials {
    pub sharpe: f64,
    pub sharpe_raw: f64,
    pub sortino: f64,
    pub win_rate: f64,
    pub max_drawdown: f64,
    pub profit_factor: f64,
    pub total_return: f64,
    pub avg_return: f64,
    pub total_trades: usize,
    pub buy_pct: f64,
    pub sell_pct: f64,
    pub hold_pct: f64,
}

/// Compute financial metrics from GPU-sourced trade statistics and action counts.
///
/// - `trade_stats`: per-epoch trade statistics from `collector.collect_trade_stats()`
/// - `action_counts`: 12-bin `dir*3 + mag` layout matching the monitoring kernel.
///   dir ∈ {Short=0, Hold=1, Long=2, Flat=3}, mag ∈ {Small=0, Half=1, Full=2}.
///   Grouped into BUY/SELL/HOLD (see below).
/// - `initial_capital`: starting equity for return calculation (default 100_000)
/// - `bars_per_day`: number of bars in one trading day (390 for 1-min, 78 for 5-min, etc.)
///   Used for Sharpe/Sortino annualization and return scaling. Pass from data pipeline's BarSize.
pub(crate) fn compute_epoch_financials(
    trade_stats: &TradeStats,
    action_counts: &[usize; 12],
    initial_capital: f64,
    bars_per_day: f64,
) -> EpochFinancials {
    if trade_stats.total_trades == 0 && trade_stats.step_returns.is_empty() {
        return EpochFinancials::default();
    }

    let total_trades = trade_stats.total_trades;
    let winning_trades = trade_stats.winning_trades;

    // Win rate from actual trade completions
    let win_rate = if total_trades > 0 {
        winning_trades as f64 / total_trades as f64
    } else {
        0.0
    };

    // Profit factor from GPU-accumulated sums
    let profit_factor = if trade_stats.sum_losses > 1e-10 {
        trade_stats.sum_wins / trade_stats.sum_losses
    } else if trade_stats.sum_wins > 0.0 {
        f64::INFINITY
    } else {
        0.0
    };

    // Total return: ANNUALIZED compounded portfolio return (CAGR-style).
    //
    // **Inflation fix (2026-05-11)** — the prior commit's "total compounded
    // return over the rollout" formula produced absurd magnitudes for long
    // training rollouts. With ~4M step_returns per epoch and even tiny
    // positive per-bar mean (~1e-5), the compounded product grew to e19
    // and beyond (smoke v1 cycle 3: `Return=+8.730e19%`). Math was correct
    // but the metric had no operational meaning — no real trader holds
    // 4M-bar positions, so "compounded total return over 4M bars" is a
    // label artefact.
    //
    // The annualized formula below normalizes by bars_per_year, producing
    // a per-year compounded growth rate (CAGR):
    //   annualized = exp(log_growth × bars_per_year / n_returns) − 1
    //
    // This is the standard portfolio-management metric: regardless of
    // rollout length, the value answers "what annualized return is the
    // policy producing?" For the smoke v1 cycle 3 numbers (log_growth ≈
    // 45.93, n_returns = 4_096_000, bars_per_year ≈ 19_656 for 5-min
    // bars): annualized = exp(45.93 × 19_656 / 4_096_000) − 1 ≈ 24.7% —
    // a reasonable, interpretable number.
    //
    // History (do not regress to any earlier form):
    //   v1 (pre-2026-04): mean_per_bar × bars_per_year (arithmetic
    //       annualization) — produced -51,234% on negative per-bar means.
    //   v2 (2026-04..2026-05-11): log_growth.exp() − 1 (total compounded,
    //       no annualization) — produced +e19% on long rollouts.
    //   v3 (2026-05-11): annualized compounded for n_returns >= bars_per_year,
    //       fallback to v2 below it — but the fallback subsumed all production
    //       runs on volume bars (bars_per_year ≈ 8.7M > n_returns ≈ 4.1M).
    //   v3.1 (2026-05-11): unconditional CAGR — the clamp handles edge cases.
    //
    // Lower bound: -100% (log_growth.exp() − 1 ≥ -1 when log_growth ≥
    // log(1e-10) ≈ -23 per the per-bar clamp). Upper bound: practically
    // capped by the annualization exponent ÷ rollout-length normalization.
    let bars_per_year = bars_per_day * 252.0;
    let returns_slice = &trade_stats.step_returns;
    let n_returns = returns_slice.len();
    let total_return = if n_returns > 0 {
        // Stable log-space cumulative: log(prod(1+r)) = sum(log(1+r)).
        // Clamp 1+r to >= 1e-10 so a -100% bar gives a finite -23 log
        // contribution rather than -inf; result then bounded above -100%.
        let log_growth: f64 = returns_slice.iter()
            .map(|&r| {
                let g = (1.0_f64 + r).max(1e-10_f64);
                if g.is_finite() { g.ln() } else { 0.0 }
            })
            .sum();
        // Annualization factor: bars_per_year / n_returns. For an N-bar
        // rollout this scales log_growth to an equivalent per-year
        // log-growth, then exp() gives the CAGR.
        //
        // Short-rollout guard: when n_returns < bars_per_year the
        // annualization factor exceeds 1, which would extrapolate a
        // brief positive run to absurd annualized magnitudes (a 5-bar
        // test with 1.5% gain would annualize to e8% on 1-min bars).
        // **v3.1 (2026-05-11)**: dropped the `n_returns >= bars_per_year`
        // short-rollout guard. The guard fell back to raw `log_growth`
        // (= v2 formula) whenever the rollout was shorter than a trading
        // year. For Foxhunt's volume bars the data extracts
        // `bars_per_day ≈ 34_496` → `bars_per_year ≈ 8.7M`, but a single
        // training epoch produces `n_returns ≈ 4.1M` step returns. Result:
        // every production epoch took the v2 branch and the v3 fix was a
        // no-op — smoke v4 epoch 1 (commit 62b5a50e8) reproduced v1's
        // `Return=+2.963e2%` exactly.
        //
        // Unconditional CAGR is the correct invariant: "what annualized
        // return is this policy producing?" works for any rollout length.
        // Tests (very small n_returns) get extrapolated (a 5-bar +1.5%
        // test extrapolates to e8% nominally) — the log-space clamp
        // `[-23, +20]` bounds the display magnitude, and the test cases
        // below assert ordering / sign, not exact magnitudes.
        let n_returns_f = n_returns as f64;
        if log_growth.is_finite() && n_returns_f > 0.0 && bars_per_year > 0.0 {
            let log_scaled = log_growth * bars_per_year / n_returns_f;
            // Sanity bounds: lower at -23 (≈ -100% by construction
            // from per-bar `(1+r).max(1e-10)` floor), upper at +20
            // (≈ exp(20) − 1 ≈ +4.85e8% — beyond this magnitude the
            // signal is more likely a numerical artefact than reality).
            log_scaled.clamp(-23.0, 20.0).exp() - 1.0
        } else {
            0.0
        }
    } else {
        0.0
    };
    let avg_return = if total_trades > 0 {
        total_return / total_trades as f64
    } else {
        0.0
    };

    // Sharpe ratio from PER-TRADE returns (not per-bar).
    // Per-bar step_returns collapse variance when most bars are flat,
    // producing bogus Sharpe (e.g., 19.75 with PF=0.03). Per-trade
    // returns use the GPU-aggregated sum_returns / sum_sq_returns.
    //
    // Annualization: sqrt(trades_per_year) where trades_per_year is
    // estimated from the epoch's trade frequency.
    let n_trades = total_trades as f64;
    let n_bars = trade_stats.step_returns.len() as f64;
    let trades_per_year = if n_bars > 0.0 {
        (n_trades / n_bars) * bars_per_year
    } else {
        0.0
    };
    let trade_annualization = trades_per_year.max(1.0).sqrt();

    let (sharpe, sharpe_raw, sortino) = if total_trades > 1 {
        // Per-trade mean and variance from GPU-aggregated sums
        let mean = trade_stats.sum_returns / n_trades;
        let var = (trade_stats.sum_sq_returns / n_trades) - mean * mean;
        let std = var.max(0.0).sqrt();

        let s = if std > 1e-10 {
            (mean / std) * trade_annualization
        } else {
            0.0
        };

        let sharpe_raw = if std > 1e-10 { mean / std } else { 0.0 };

        // Sortino: downside deviation from losing trades only.
        // sum_losses is sum of |returns| for losers. We need sum of squared
        // returns for losers — approximate from loss_count * (avg_loss)^2.
        // This is conservative (underestimates variance of losing trades).
        let sort = if trade_stats.losing_trades > 0 {
            let avg_loss = trade_stats.sum_losses / trade_stats.losing_trades as f64;
            let down_var = avg_loss * avg_loss; // E[loss^2] ≈ E[|loss|]^2 (lower bound)
            let down_std = (down_var * (trade_stats.losing_trades as f64 / n_trades)).sqrt();
            if down_std > 1e-10 {
                (mean / down_std) * trade_annualization
            } else {
                0.0
            }
        } else {
            0.0
        };

        (s, sharpe_raw, sort)
    } else {
        (0.0, 0.0, 0.0)
    };

    // MaxDD from step_returns equity curve over last 10K bars, capped at 100%.
    // Full-epoch compounding (2M+ bars) produces >100% drawdowns from numerical noise.
    //
    // CRITICAL: Reset equity and peak at episode boundaries (done_flags > 0.5).
    // Without this, the equity curve compounds across episode resets (capital floor
    // circuit breaker), producing fake 92%+ MaxDD from cross-episode contamination.
    // The backtest evaluator already isolates per-window; this makes training match.
    let returns = &trade_stats.step_returns;
    const MAX_DD_WINDOW: usize = 10_000;
    let dd_start = returns.len().saturating_sub(MAX_DD_WINDOW);
    let mut equity = initial_capital;
    let mut peak = equity;
    let mut max_dd = 0.0_f64;
    for (idx, &ret) in returns[dd_start..].iter().enumerate() {
        let global_idx = dd_start + idx;
        equity *= 1.0 + ret;
        if equity > peak {
            peak = equity;
        }
        // Reset equity AFTER processing the done bar's return.
        // The done bar's loss is part of this episode's DD — the reset
        // applies to the NEXT bar (start of new episode with fresh capital).
        if global_idx < trade_stats.done_flags.len()
            && trade_stats.done_flags[global_idx] > 0.5
        {
            equity = initial_capital;
            peak = initial_capital;
        }
        let dd = if peak > 1e-10 {
            (peak - equity) / peak
        } else {
            0.0
        };
        if dd > max_dd {
            max_dd = dd;
        }
    }
    // Cap at 100% — drawdown cannot exceed total liquidation
    let max_dd = max_dd.min(1.0);

    // Action distribution: 12-bin exposure layout → BUY/SELL/HOLD.
    // Layout: exp_idx = dir*3 + mag with dir ∈ {Short=0, Hold=1, Long=2, Flat=3}
    // and mag ∈ {Small=0, Half=1, Full=2}.
    //   Sell  = Short dir          = indices 0..3
    //   Hold  = Hold + Flat dirs   = indices 3..6 ∪ 9..12  (both are non-directional positions)
    //   Buy   = Long dir           = indices 6..9
    let total_actions: usize = action_counts.iter().sum();
    let (buy_pct, sell_pct, hold_pct) = if total_actions > 0 {
        let sell: usize = action_counts[0..3].iter().sum();
        let hold: usize = action_counts[3..6].iter().sum::<usize>()
            + action_counts[9..12].iter().sum::<usize>();
        let buy: usize = action_counts[6..9].iter().sum();
        let t = total_actions as f64;
        (buy as f64 / t, sell as f64 / t, hold as f64 / t)
    } else {
        (0.0, 0.0, 0.0)
    };

    EpochFinancials {
        sharpe,
        sharpe_raw,
        sortino,
        win_rate,
        max_drawdown: max_dd,
        profit_factor,
        total_return,
        avg_return,
        total_trades,
        buy_pct,
        sell_pct,
        hold_pct,
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn test_empty_trade_stats() {
        let ts = TradeStats::default();
        let f = compute_epoch_financials(&ts, &[0; 12], 100_000.0, 390.0);
        assert_eq!(f.total_trades, 0);
        assert_eq!(f.sharpe, 0.0);
    }

    #[test]
    fn test_all_winning_trades() {
        let ts = TradeStats {
            total_trades: 5,
            winning_trades: 5,
            losing_trades: 0,
            sum_wins: 0.10,
            sum_losses: 0.0,
            sum_returns: 0.10,
            sum_sq_returns: 0.00225,
            step_returns: vec![0.01, 0.02, 0.03, 0.015, 0.025],
            ..Default::default()
        };
        let f = compute_epoch_financials(&ts, &[0; 12], 100_000.0, 390.0);
        assert_eq!(f.win_rate, 1.0);
        assert_eq!(f.total_trades, 5);
        assert!(f.sharpe > 0.0, "sharpe={}", f.sharpe);
        assert!(f.max_drawdown < 1e-10, "max_dd={}", f.max_drawdown);
        assert!(f.profit_factor.is_infinite(), "pf={}", f.profit_factor);
    }

    #[test]
    fn test_mixed_trades() {
        let ts = TradeStats {
            total_trades: 5,
            winning_trades: 3,
            losing_trades: 2,
            sum_wins: 0.03,
            sum_losses: 0.01,
            sum_returns: 0.015,
            sum_sq_returns: 0.000223,
            step_returns: vec![0.01, -0.005, 0.008, -0.003, 0.005],
            ..Default::default()
        };
        let f = compute_epoch_financials(&ts, &[0; 12], 100_000.0, 390.0);
        assert_eq!(f.total_trades, 5);
        assert!((f.win_rate - 0.6).abs() < 1e-10, "win_rate={}", f.win_rate);
        assert!(f.total_return > 0.0, "total_return={}", f.total_return);
        assert!(f.profit_factor > 1.0, "profit_factor={}", f.profit_factor);
        assert!(f.max_drawdown > 0.0, "max_dd={}", f.max_drawdown);
        assert!(f.sortino != 0.0, "sortino should be non-zero with losses");
    }

    #[test]
    fn test_action_distribution_12_bins() {
        // 12-bin layout (dir*3 + mag): Short [0..3], Hold [3..6], Long [6..9], Flat [9..12].
        let mut actions = [0usize; 12];
        actions[0] = 10; // Short_Small  -> SELL
        actions[1] = 10; // Short_Half   -> SELL
        actions[3] = 15; // Hold_Small   -> HOLD
        actions[7] = 30; // Long_Half    -> BUY
        actions[8] = 30; // Long_Full    -> BUY
        actions[9] = 5;  // Flat_Small   -> HOLD
        let ts = TradeStats {
            total_trades: 1,
            winning_trades: 1,
            losing_trades: 0,
            sum_wins: 0.01,
            sum_losses: 0.0,
            sum_returns: 0.01,
            sum_sq_returns: 0.0001,
            step_returns: vec![0.01],
            ..Default::default()
        };
        let f = compute_epoch_financials(&ts, &actions, 100_000.0, 390.0);
        // Total = 10+10+15+30+30+5 = 100.
        // SELL = 20/100 = 0.20, HOLD = (15+5)/100 = 0.20, BUY = 60/100 = 0.60.
        assert!((f.sell_pct - 0.20).abs() < 1e-10, "sell_pct={}", f.sell_pct);
        assert!((f.hold_pct - 0.20).abs() < 1e-10, "hold_pct={}", f.hold_pct);
        assert!((f.buy_pct - 0.60).abs() < 1e-10, "buy_pct={}", f.buy_pct);
    }

    #[test]
    fn test_action_distribution_all_indices_counted() {
        // Fill every bin with a non-zero count to prove all 12 indices contribute
        // and BUY+SELL+HOLD partitions the total.
        let mut actions = [0usize; 12];
        for i in 0..12 { actions[i] = 10; }
        let ts = TradeStats {
            total_trades: 1,
            winning_trades: 1,
            losing_trades: 0,
            sum_wins: 0.01,
            sum_losses: 0.0,
            sum_returns: 0.01,
            sum_sq_returns: 0.0001,
            step_returns: vec![0.01],
            ..Default::default()
        };
        let f = compute_epoch_financials(&ts, &actions, 100_000.0, 390.0);
        let total = f.buy_pct + f.sell_pct + f.hold_pct;
        assert!(
            (total - 1.0).abs() < 1e-10,
            "BUY+SELL+HOLD must sum to 1.0, got {total:.6} (buy={}, sell={}, hold={})",
            f.buy_pct, f.sell_pct, f.hold_pct
        );
    }

    #[test]
    fn test_maxdd_resets_at_episode_boundary() {
        // Simulate two episodes: first loses 50%, then done=1 resets, second loses 10%.
        // Without episode boundary reset, compounding produces ~55% MaxDD.
        // With reset, MaxDD should be 50% (the worst single episode).
        let step_returns = vec![
            -0.20, -0.15, -0.10, -0.05, // Episode 1: cumulative ~42% loss
            0.01, -0.05, -0.03, -0.02,   // Episode 2: smaller loss after reset
        ];
        let mut done_flags = vec![0.0; 8];
        done_flags[3] = 1.0; // Episode boundary after bar 3

        let ts = TradeStats {
            total_trades: 2,
            winning_trades: 0,
            losing_trades: 2,
            sum_wins: 0.0,
            sum_losses: 0.50,
            sum_returns: -0.50,
            sum_sq_returns: 0.01,
            step_returns,
            done_flags,
        };
        let f = compute_epoch_financials(&ts, &[0; 12], 100_000.0, 390.0);

        // Episode 1 drawdown: (1-0.8)*(1-0.15)*(1-0.10)*(1-0.05) ≈ 0.4131 → ~42% DD
        // Episode 2 starts fresh, drawdown is smaller.
        // MaxDD should be ~42%, NOT ~55%+ from cross-episode compounding.
        assert!(
            f.max_drawdown < 0.50,
            "MaxDD should be per-episode (< 50%), got {:.4}",
            f.max_drawdown
        );
        assert!(
            f.max_drawdown > 0.30,
            "MaxDD should capture episode 1 loss (> 30%), got {:.4}",
            f.max_drawdown
        );
    }

    #[test]
    fn test_maxdd_no_done_flags_graceful() {
        // When done_flags is empty (legacy / fallback), MaxDD should still work
        // without panicking — it just compounds across everything (old behavior).
        let ts = TradeStats {
            total_trades: 2,
            winning_trades: 1,
            losing_trades: 1,
            sum_wins: 0.01,
            sum_losses: 0.005,
            sum_returns: 0.005,
            sum_sq_returns: 0.0001,
            step_returns: vec![0.01, -0.005, 0.008, -0.003],
            done_flags: vec![], // empty — no episode info
        };
        let f = compute_epoch_financials(&ts, &[0; 12], 100_000.0, 390.0);
        // Should not panic and should produce a valid number
        assert!(f.max_drawdown >= 0.0, "max_dd={}", f.max_drawdown);
        assert!(f.max_drawdown <= 1.0, "max_dd={}", f.max_drawdown);
    }
}
