//! Hyperparameter sensitivity analysis for fragility detection.
//!
//! Perturbs each hyperparameter independently and measures the resulting
//! change in objective (Sharpe ratio) to identify fragile configurations
//! that may not survive live trading conditions.

/// Result of a full sensitivity analysis across all parameters.
#[derive(Debug, Clone)]
pub struct SensitivityResult {
    /// Per-parameter sensitivity breakdown.
    pub per_param: Vec<ParamSensitivity>,
    /// Mean sensitivity across all parameters (higher = more fragile).
    pub overall_fragility: f64,
}

/// Sensitivity analysis for a single hyperparameter.
#[derive(Debug, Clone)]
pub struct ParamSensitivity {
    /// Parameter name.
    pub name: String,
    /// Baseline (unperturbed) value.
    pub base_value: f64,
    /// Normalized sensitivity score: max |delta_sharpe| / baseline_sharpe.
    pub sensitivity_score: f64,
    /// Whether this parameter is fragile (sensitivity > 0.3 threshold).
    pub is_fragile: bool,
    /// Perturbation results: `(perturbation_pct, sharpe_at_perturbation)`.
    pub perturbation_results: Vec<(f64, f64)>,
}

/// Analyzer that perturbs hyperparameters to detect fragile configurations.
#[derive(Debug)]
pub struct SensitivityAnalyzer {
    /// Parameter names.
    names: Vec<String>,
    /// Baseline parameter values.
    base_params: Vec<f64>,
    /// Perturbation percentages to apply (e.g. [-0.20, -0.10, -0.05, 0.05, 0.10, 0.20]).
    perturbation_pcts: Vec<f64>,
}

/// Threshold above which a parameter is considered fragile.
const FRAGILITY_THRESHOLD: f64 = 0.3;

impl SensitivityAnalyzer {
    /// Create a new sensitivity analyzer.
    ///
    /// - `names`: parameter names (must match length of `base_params`).
    /// - `base_params`: baseline parameter values found by optimization.
    /// - `perturbation_pcts`: optional custom perturbation percentages.
    ///   Defaults to `[-0.20, -0.10, -0.05, 0.05, 0.10, 0.20]`.
    pub fn new(
        names: Vec<String>,
        base_params: Vec<f64>,
        perturbation_pcts: Option<Vec<f64>>,
    ) -> Self {
        let perturbation_pcts = perturbation_pcts
            .unwrap_or_else(|| vec![-0.20, -0.10, -0.05, 0.05, 0.10, 0.20]);
        Self {
            names,
            base_params,
            perturbation_pcts,
        }
    }

    /// Run sensitivity analysis using the provided evaluation function.
    ///
    /// The `evaluate` closure takes a parameter slice and returns a Sharpe ratio.
    /// Each parameter is perturbed independently while all others remain at baseline.
    pub fn analyze<F: Fn(&[f64]) -> f64>(&self, evaluate: F) -> SensitivityResult {
        let baseline_sharpe = evaluate(&self.base_params);

        let mut per_param = Vec::with_capacity(self.names.len());

        for (i, name) in self.names.iter().enumerate() {
            let base_val = self.base_params.get(i).copied().unwrap_or(0.0);
            let mut perturbation_results = Vec::with_capacity(self.perturbation_pcts.len());
            let mut max_abs_change = 0.0_f64;

            for &pct in &self.perturbation_pcts {
                let mut params = self.base_params.clone();
                if let Some(p) = params.get_mut(i) {
                    *p = base_val * (1.0 + pct);
                }
                let sharpe = evaluate(&params);
                perturbation_results.push((pct, sharpe));

                let abs_change = (sharpe - baseline_sharpe).abs();
                if abs_change > max_abs_change {
                    max_abs_change = abs_change;
                }
            }

            let sensitivity_score = if baseline_sharpe.abs() < 1e-15 {
                0.0
            } else {
                max_abs_change / baseline_sharpe.abs()
            };

            per_param.push(ParamSensitivity {
                name: name.clone(),
                base_value: base_val,
                sensitivity_score,
                is_fragile: sensitivity_score > FRAGILITY_THRESHOLD,
                perturbation_results,
            });
        }

        let overall_fragility = if per_param.is_empty() {
            0.0
        } else {
            let sum: f64 = per_param.iter().map(|p| p.sensitivity_score).sum();
            sum / per_param.len() as f64
        };

        SensitivityResult {
            per_param,
            overall_fragility,
        }
    }
}

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

    #[test]
    fn test_insensitive_function_low_fragility() {
        // Constant function: always returns 2.0 regardless of params
        let analyzer = SensitivityAnalyzer::new(
            vec!["lr".to_owned(), "gamma".to_owned()],
            vec![0.001, 0.99],
            None, // use defaults
        );
        let result = analyzer.analyze(|_params| 2.0);
        assert!(
            result.overall_fragility < 0.01,
            "Constant function should have ~0 fragility, got {}",
            result.overall_fragility
        );
        assert!(!result.per_param.iter().any(|p| p.is_fragile));
    }

    #[test]
    fn test_sensitive_function_high_fragility() {
        // Exponential sensitivity: small param changes cause large Sharpe swings.
        // sharpe = exp(1000 * lr) where lr=0.001 => baseline=e^1 ~ 2.718
        // At +20%: lr=0.0012 => e^1.2 ~ 3.32 => delta/baseline ~ 0.22
        // At -20%: lr=0.0008 => e^0.8 ~ 2.23 => delta/baseline ~ 0.18
        // Use a steeper multiplier so the sensitivity clearly exceeds 0.3.
        let analyzer = SensitivityAnalyzer::new(
            vec!["lr".to_owned(), "gamma".to_owned()],
            vec![0.001, 0.99],
            None,
        );
        let result = analyzer.analyze(|params| {
            let lr = params.first().copied().unwrap_or(0.001);
            // sharpe = exp(2000 * lr), baseline = exp(2) ~ 7.39
            // At +20%: exp(2.4) ~ 11.02, delta/baseline ~ 0.49 > 0.3 => fragile
            (2000.0 * lr).exp()
        });
        // First param should be fragile (exponential sensitivity)
        assert!(
            result
                .per_param
                .first()
                .map_or(false, |p| p.is_fragile),
            "Exponential function should be fragile for its parameter, score={}",
            result.per_param.first().map_or(0.0, |p| p.sensitivity_score)
        );
        assert!(
            result.overall_fragility > 0.05,
            "Should have non-trivial fragility, got {}",
            result.overall_fragility
        );
    }

    #[test]
    fn test_perturbation_results_stored() {
        let analyzer = SensitivityAnalyzer::new(
            vec!["x".to_owned()],
            vec![1.0],
            Some(vec![-0.10, 0.10]),
        );
        let result = analyzer.analyze(|params| params.first().copied().unwrap_or(1.0));
        let param = result.per_param.first();
        assert!(param.is_some(), "Should have at least one param result");
        let param = param.unwrap_or_else(|| unreachable!());
        assert_eq!(
            param.perturbation_results.len(),
            2,
            "Should have 2 perturbation results"
        );
    }
}
