//! # Combinatorial Purged Cross-Validation (CPCV)
//!
//! A validation method specifically designed for financial time series that avoids
//! data leakage through purging and embargo mechanisms. Based on the methodology
//! described by Marcos Lopez de Prado in *Advances in Financial Machine Learning*.
//!
//! ## Overview
//!
//! CPCV generates all combinatorial splits of N groups into training and test sets,
//! applies purging around train/test boundaries to prevent information leakage, and
//! computes performance across all combinations for unbiased strategy estimation.
//!
//! ## Key Features
//!
//! - **Combinatorial splits**: Generates all `C(n_groups`, `n_test_groups`) possible splits
//! - **Purging**: Removes data around train/test boundaries to avoid lookahead bias
//! - **Embargo**: Applies an embargo period after each test set to prevent leakage
//! - **Unbiased estimation**: Computes performance across all combinations
//!
//! ## Usage
//!
//! ```rust,no_run
//! use ml_data_validation::cpcv::{CPCVConfig, CPCVValidator};
//!
//! let config = CPCVConfig::default();
//! let validator = CPCVValidator::new(config)?;
//!
//! let data: Vec<f64> = vec![/* time series returns */];
//! let result = validator.validate(&data, |train, test| {
//!     // Compute strategy returns given train/test indices
//!     Ok(0.01) // placeholder
//! })?;
//!
//! println!("Mean return: {:.4}", result.mean_return);
//! println!("P(loss): {:.2}%", result.probability_of_loss * 100.0);
//! # Ok::<(), ml_data_validation::MLError>(())
//! ```

use crate::MLError;
use serde::{Deserialize, Serialize};

/// Configuration for Combinatorial Purged Cross-Validation.
///
/// Controls the number of groups, test groups, purge length, and embargo length
/// used when generating cross-validation splits.
///
/// # Defaults
///
/// - `n_groups`: 10
/// - `n_test_groups`: 2
/// - `purge_length`: 5
/// - `embargo_length`: 3
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CPCVConfig {
    /// Total number of groups to partition the data into.
    pub n_groups: usize,
    /// Number of groups to hold out as test sets in each split.
    pub n_test_groups: usize,
    /// Number of samples to purge around each train/test boundary to prevent leakage.
    pub purge_length: usize,
    /// Number of samples to embargo after each test group to prevent leakage.
    pub embargo_length: usize,
}

impl Default for CPCVConfig {
    fn default() -> Self {
        Self {
            n_groups: 10,
            n_test_groups: 2,
            purge_length: 5,
            embargo_length: 3,
        }
    }
}

/// A single train/test split produced by the CPCV generator.
///
/// Contains the indices into the original data array that belong to the
/// training set and test set respectively, after purging and embargo
/// have been applied.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CPCVSplit {
    /// Indices belonging to the training set (after purge and embargo removal).
    pub train_indices: Vec<usize>,
    /// Indices belonging to the test set.
    pub test_indices: Vec<usize>,
}

/// Aggregated results from running CPCV across all combinatorial splits.
///
/// Contains summary statistics and per-split returns that allow assessment
/// of strategy robustness and probability of backtest overfitting.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CPCVResult {
    /// Mean return across all combinatorial splits.
    pub mean_return: f64,
    /// Standard deviation of returns across all splits.
    pub std_return: f64,
    /// Return value from each individual split.
    pub per_split_returns: Vec<f64>,
    /// Fraction of splits with negative returns (estimate of probability of loss).
    pub probability_of_loss: f64,
    /// Total number of combinatorial splits evaluated.
    pub num_splits: usize,
}

/// Combinatorial Purged Cross-Validation validator.
///
/// Implements the full CPCV workflow: partitioning data into groups, generating
/// all combinatorial train/test splits, applying purging and embargo, and
/// aggregating strategy performance across all splits.
#[derive(Debug, Clone)]
pub struct CPCVValidator {
    /// Configuration parameters.
    config: CPCVConfig,
}

impl CPCVValidator {
    /// Create a new CPCV validator with the given configuration.
    ///
    /// # Arguments
    ///
    /// * `config` - CPCV configuration specifying groups, purge, and embargo parameters.
    ///
    /// # Returns
    ///
    /// Returns `Ok(CPCVValidator)` if the configuration is valid, or an error if:
    /// - `n_groups` is zero
    /// - `n_test_groups` is zero or greater than or equal to `n_groups`
    ///
    /// # Errors
    ///
    /// Returns `MLError::ValidationError` when configuration parameters are invalid.
    pub fn new(config: CPCVConfig) -> Result<Self, MLError> {
        if config.n_groups == 0 {
            return Err(MLError::ValidationError {
                message: "n_groups must be greater than 0".to_owned(),
            });
        }
        if config.n_test_groups == 0 {
            return Err(MLError::ValidationError {
                message: "n_test_groups must be greater than 0".to_owned(),
            });
        }
        if config.n_test_groups >= config.n_groups {
            return Err(MLError::ValidationError {
                message: format!(
                    "n_test_groups ({}) must be less than n_groups ({})",
                    config.n_test_groups, config.n_groups
                ),
            });
        }
        Ok(Self { config })
    }

    /// Return the number of combinatorial splits: `C(n_groups`, `n_test_groups`).
    ///
    /// This is the total number of distinct ways to choose `n_test_groups` groups
    /// from `n_groups` groups.
    pub fn num_combinations(&self) -> usize {
        binomial(self.config.n_groups, self.config.n_test_groups)
    }

    /// Generate all combinatorial purged cross-validation splits.
    ///
    /// Partitions `n_samples` data points into `n_groups` contiguous groups,
    /// then generates all `C(n_groups`, `n_test_groups`) possible train/test splits.
    /// For each split, purging and embargo are applied to remove data points
    /// near train/test boundaries.
    ///
    /// # Arguments
    ///
    /// * `n_samples` - Total number of data points in the time series.
    ///
    /// # Returns
    ///
    /// A vector of `CPCVSplit` structs, one for each combinatorial split.
    ///
    /// # Errors
    ///
    /// Returns `MLError::ValidationError` if `n_samples` is less than `n_groups`.
    pub fn generate_splits(&self, n_samples: usize) -> Result<Vec<CPCVSplit>, MLError> {
        if n_samples < self.config.n_groups {
            return Err(MLError::ValidationError {
                message: format!(
                    "n_samples ({}) must be >= n_groups ({})",
                    n_samples, self.config.n_groups
                ),
            });
        }

        // Partition indices into contiguous groups
        let group_boundaries = compute_group_boundaries(n_samples, self.config.n_groups);

        // Generate all combinations of test group indices
        let test_group_combos = combinations(self.config.n_groups, self.config.n_test_groups);

        let mut splits = Vec::with_capacity(test_group_combos.len());

        for test_groups in &test_group_combos {
            // Collect test indices
            let mut test_indices = Vec::new();
            for &tg in test_groups {
                let (start, end) = get_group_range(&group_boundaries, tg)?;
                for idx in start..end {
                    test_indices.push(idx);
                }
            }

            // Build a set of indices to exclude from training (test + purge + embargo)
            let mut excluded = vec![false; n_samples];

            // Mark test indices as excluded
            for &idx in &test_indices {
                if idx < n_samples {
                    // Note: idx < n_samples guaranteed by group boundary construction
                    if let Some(slot) = excluded.get_mut(idx) {
                        *slot = true;
                    }
                }
            }

            // Apply purging and embargo around each test group
            for &tg in test_groups {
                let (test_start, test_end) = get_group_range(&group_boundaries, tg)?;

                // Purge before test set: remove `purge_length` samples before test_start
                let purge_start = test_start.saturating_sub(self.config.purge_length);
                for idx in purge_start..test_start {
                    if let Some(slot) = excluded.get_mut(idx) {
                        *slot = true;
                    }
                }

                // Purge after test set: remove `purge_length` samples after test_end
                let purge_after_end = (test_end + self.config.purge_length).min(n_samples);
                for idx in test_end..purge_after_end {
                    if let Some(slot) = excluded.get_mut(idx) {
                        *slot = true;
                    }
                }

                // Embargo after test set: remove `embargo_length` samples after purge zone
                let embargo_end =
                    (purge_after_end + self.config.embargo_length).min(n_samples);
                for idx in purge_after_end..embargo_end {
                    if let Some(slot) = excluded.get_mut(idx) {
                        *slot = true;
                    }
                }
            }

            // Training indices = all non-excluded indices
            let train_indices: Vec<usize> = (0..n_samples)
                .filter(|&i| {
                    !excluded.get(i).copied().unwrap_or(true)
                })
                .collect();

            splits.push(CPCVSplit {
                train_indices,
                test_indices,
            });
        }

        Ok(splits)
    }

    /// Run full CPCV validation on a data series using a user-supplied strategy function.
    ///
    /// For each combinatorial split, the strategy function is called with training and
    /// test data slices, and should return the strategy's return metric for that split.
    /// Results are aggregated across all splits.
    ///
    /// # Arguments
    ///
    /// * `data` - The full time series of returns or prices.
    /// * `strategy_fn` - A function that receives train data and test data slices and
    ///   returns a single f64 performance metric (e.g., mean return, Sharpe ratio).
    ///
    /// # Returns
    ///
    /// A `CPCVResult` with aggregated statistics across all splits.
    ///
    /// # Errors
    ///
    /// Returns an error if split generation fails or if the strategy function returns
    /// an error for any split.
    pub fn validate<F>(
        &self,
        data: &[f64],
        strategy_fn: F,
    ) -> Result<CPCVResult, MLError>
    where
        F: Fn(&[f64], &[f64]) -> Result<f64, MLError>,
    {
        let splits = self.generate_splits(data.len())?;

        if splits.is_empty() {
            return Err(MLError::ValidationError {
                message: "No splits generated".to_owned(),
            });
        }

        let mut per_split_returns = Vec::with_capacity(splits.len());

        for split in &splits {
            // Gather train and test data from indices
            let train_data: Vec<f64> = split
                .train_indices
                .iter()
                .filter_map(|&i| data.get(i).copied())
                .collect();

            let test_data: Vec<f64> = split
                .test_indices
                .iter()
                .filter_map(|&i| data.get(i).copied())
                .collect();

            let ret = strategy_fn(&train_data, &test_data)?;
            per_split_returns.push(ret);
        }

        let num_splits = per_split_returns.len();
        let mean_return = if num_splits > 0 {
            per_split_returns.iter().sum::<f64>() / num_splits as f64
        } else {
            0.0
        };

        let std_return = if num_splits > 1 {
            let variance = per_split_returns
                .iter()
                .map(|&r| {
                    let diff = r - mean_return;
                    diff * diff
                })
                .sum::<f64>()
                / (num_splits - 1) as f64;
            variance.sqrt()
        } else {
            0.0
        };

        let loss_count = per_split_returns.iter().filter(|&&r| r < 0.0).count();
        let probability_of_loss = if num_splits > 0 {
            loss_count as f64 / num_splits as f64
        } else {
            0.0
        };

        Ok(CPCVResult {
            mean_return,
            std_return,
            per_split_returns,
            probability_of_loss,
            num_splits,
        })
    }

    /// Get a reference to the current configuration.
    pub const fn config(&self) -> &CPCVConfig {
        &self.config
    }
}

// =============================================================================
// Internal helper functions
// =============================================================================

/// Compute the binomial coefficient C(n, k) using an iterative algorithm
/// that avoids overflow for reasonable inputs.
fn binomial(n: usize, k: usize) -> usize {
    if k > n {
        return 0;
    }
    if k == 0 || k == n {
        return 1;
    }
    // Use the smaller of k and n-k for efficiency
    let k = k.min(n - k);
    let mut result: usize = 1;
    for i in 0..k {
        // Multiply first then divide to maintain integer precision
        result = result * (n - i) / (i + 1);
    }
    result
}

/// Compute contiguous group boundaries for partitioning `n_samples` into `n_groups`.
///
/// Returns a vector of length `n_groups + 1` where group `i` spans
/// `[boundaries[i], boundaries[i+1])`.
fn compute_group_boundaries(n_samples: usize, n_groups: usize) -> Vec<usize> {
    let mut boundaries = Vec::with_capacity(n_groups + 1);
    let base_size = n_samples / n_groups;
    let remainder = n_samples % n_groups;

    let mut current = 0;
    for i in 0..n_groups {
        boundaries.push(current);
        // Distribute remainder samples across the first `remainder` groups
        let group_size = base_size + if i < remainder { 1 } else { 0 };
        current += group_size;
    }
    boundaries.push(n_samples);

    boundaries
}

/// Get the [start, end) range for a given group index from the boundaries vector.
///
/// # Errors
///
/// Returns `MLError::ValidationError` if the group index is out of bounds.
fn get_group_range(
    boundaries: &[usize],
    group_idx: usize,
) -> Result<(usize, usize), MLError> {
    let start = boundaries.get(group_idx).copied().ok_or_else(|| {
        MLError::ValidationError {
            message: format!(
                "Group index {} out of range (boundaries len: {})",
                group_idx,
                boundaries.len()
            ),
        }
    })?;
    let end = boundaries.get(group_idx + 1).copied().ok_or_else(|| {
        MLError::ValidationError {
            message: format!(
                "Group index {} + 1 out of range (boundaries len: {})",
                group_idx,
                boundaries.len()
            ),
        }
    })?;
    Ok((start, end))
}

/// Generate all combinations of `k` items from `0..n`.
///
/// Returns a vector of vectors, where each inner vector contains `k` indices
/// in ascending order.
fn combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
    let mut result = Vec::new();
    let mut combo = Vec::with_capacity(k);
    generate_combinations(n, k, 0, &mut combo, &mut result);
    result
}

/// Recursive helper for combination generation.
fn generate_combinations(
    n: usize,
    k: usize,
    start: usize,
    current: &mut Vec<usize>,
    result: &mut Vec<Vec<usize>>,
) {
    if current.len() == k {
        result.push(current.clone());
        return;
    }

    let remaining = k - current.len();
    // Prune: ensure enough elements remain to complete the combination
    if start + remaining > n {
        return;
    }

    for i in start..n {
        current.push(i);
        generate_combinations(n, k, i + 1, current, result);
        current.pop();
    }
}

#[cfg(test)]
#[allow(
    clippy::assertions_on_result_states,
    clippy::redundant_clone,
    clippy::unnecessary_wraps
)]
mod tests {
    use super::*;

    // =========================================================================
    // Helper utilities
    // =========================================================================

    /// Create a simple ascending data series for testing.
    fn make_data(n: usize) -> Vec<f64> {
        (0..n).map(|i| i as f64 * 0.001).collect()
    }

    /// A trivial strategy that returns the mean of test data.
    fn mean_strategy(
        _train: &[f64],
        test: &[f64],
    ) -> Result<f64, MLError> {
        if test.is_empty() {
            return Ok(0.0);
        }
        let sum: f64 = test.iter().sum();
        Ok(sum / test.len() as f64)
    }

    // =========================================================================
    // Configuration tests
    // =========================================================================

    #[test]
    fn test_default_config() {
        let config = CPCVConfig::default();
        assert_eq!(config.n_groups, 10);
        assert_eq!(config.n_test_groups, 2);
        assert_eq!(config.purge_length, 5);
        assert_eq!(config.embargo_length, 3);
    }

    #[test]
    fn test_config_serde_roundtrip() {
        let config = CPCVConfig {
            n_groups: 8,
            n_test_groups: 3,
            purge_length: 10,
            embargo_length: 5,
        };
        let json = serde_json::to_string(&config).ok();
        assert!(json.is_some());

        let json_str = json.unwrap_or_default();
        let parsed: Result<CPCVConfig, _> = serde_json::from_str(&json_str);
        assert!(parsed.is_ok());

        if let Ok(c) = parsed {
            assert_eq!(c.n_groups, 8);
            assert_eq!(c.n_test_groups, 3);
            assert_eq!(c.purge_length, 10);
            assert_eq!(c.embargo_length, 5);
        }
    }

    // =========================================================================
    // Validator construction tests
    // =========================================================================

    #[test]
    fn test_validator_creation_default() {
        let validator = CPCVValidator::new(CPCVConfig::default());
        assert!(validator.is_ok());
    }

    #[test]
    fn test_validator_rejects_zero_groups() {
        let config = CPCVConfig {
            n_groups: 0,
            ..CPCVConfig::default()
        };
        let result = CPCVValidator::new(config);
        assert!(result.is_err());
    }

    #[test]
    fn test_validator_rejects_zero_test_groups() {
        let config = CPCVConfig {
            n_test_groups: 0,
            ..CPCVConfig::default()
        };
        let result = CPCVValidator::new(config);
        assert!(result.is_err());
    }

    #[test]
    fn test_validator_rejects_test_groups_gte_groups() {
        let config = CPCVConfig {
            n_groups: 5,
            n_test_groups: 5,
            ..CPCVConfig::default()
        };
        let result = CPCVValidator::new(config);
        assert!(result.is_err());

        let config2 = CPCVConfig {
            n_groups: 5,
            n_test_groups: 6,
            ..CPCVConfig::default()
        };
        let result2 = CPCVValidator::new(config2);
        assert!(result2.is_err());
    }

    // =========================================================================
    // Binomial coefficient tests
    // =========================================================================

    #[test]
    fn test_binomial_basic() {
        assert_eq!(binomial(10, 2), 45);
        assert_eq!(binomial(5, 0), 1);
        assert_eq!(binomial(5, 5), 1);
        assert_eq!(binomial(6, 3), 20);
        assert_eq!(binomial(10, 3), 120);
    }

    #[test]
    fn test_binomial_k_greater_than_n() {
        assert_eq!(binomial(3, 5), 0);
    }

    #[test]
    fn test_binomial_edge_cases() {
        assert_eq!(binomial(0, 0), 1);
        assert_eq!(binomial(1, 0), 1);
        assert_eq!(binomial(1, 1), 1);
    }

    // =========================================================================
    // Combination generation tests
    // =========================================================================

    #[test]
    fn test_combinations_count() {
        let combos = combinations(5, 2);
        assert_eq!(combos.len(), 10); // C(5,2) = 10

        let combos = combinations(10, 2);
        assert_eq!(combos.len(), 45); // C(10,2) = 45
    }

    #[test]
    fn test_combinations_content() {
        let combos = combinations(4, 2);
        assert_eq!(combos.len(), 6);

        // All combinations of 2 from {0,1,2,3}
        let expected = vec![
            vec![0, 1],
            vec![0, 2],
            vec![0, 3],
            vec![1, 2],
            vec![1, 3],
            vec![2, 3],
        ];
        assert_eq!(combos, expected);
    }

    #[test]
    fn test_combinations_single() {
        let combos = combinations(4, 1);
        assert_eq!(combos.len(), 4);
        assert_eq!(combos, vec![vec![0], vec![1], vec![2], vec![3]]);
    }

    // =========================================================================
    // Group boundary tests
    // =========================================================================

    #[test]
    fn test_group_boundaries_even_split() {
        let boundaries = compute_group_boundaries(100, 10);
        assert_eq!(boundaries.len(), 11);
        assert_eq!(*boundaries.first().unwrap_or(&usize::MAX), 0);
        assert_eq!(*boundaries.last().unwrap_or(&0), 100);

        // Each group should have exactly 10 elements
        for i in 0..10 {
            let start = boundaries.get(i).copied().unwrap_or(0);
            let end = boundaries.get(i + 1).copied().unwrap_or(0);
            assert_eq!(end - start, 10);
        }
    }

    #[test]
    fn test_group_boundaries_uneven_split() {
        let boundaries = compute_group_boundaries(103, 10);
        assert_eq!(boundaries.len(), 11);
        assert_eq!(*boundaries.first().unwrap_or(&usize::MAX), 0);
        assert_eq!(*boundaries.last().unwrap_or(&0), 103);

        // First 3 groups should have 11 elements, rest should have 10
        for i in 0..10 {
            let start = boundaries.get(i).copied().unwrap_or(0);
            let end = boundaries.get(i + 1).copied().unwrap_or(0);
            let size = end - start;
            if i < 3 {
                assert_eq!(size, 11);
            } else {
                assert_eq!(size, 10);
            }
        }
    }

    // =========================================================================
    // Split generation tests
    // =========================================================================

    #[test]
    fn test_num_combinations() {
        let config = CPCVConfig {
            n_groups: 10,
            n_test_groups: 2,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());
        if let Ok(v) = validator {
            assert_eq!(v.num_combinations(), 45);
        }
    }

    #[test]
    fn test_generate_splits_count() {
        let config = CPCVConfig {
            n_groups: 5,
            n_test_groups: 2,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let splits = v.generate_splits(100);
            assert!(splits.is_ok());
            if let Ok(s) = splits {
                assert_eq!(s.len(), 10); // C(5,2) = 10
            }
        }
    }

    #[test]
    fn test_generate_splits_no_overlap() {
        let config = CPCVConfig {
            n_groups: 5,
            n_test_groups: 2,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let splits = v.generate_splits(100);
            assert!(splits.is_ok());

            if let Ok(s) = splits {
                for split in &s {
                    // Train and test should not overlap
                    for train_idx in &split.train_indices {
                        assert!(
                            !split.test_indices.contains(train_idx),
                            "Train index {} found in test set",
                            train_idx
                        );
                    }
                }
            }
        }
    }

    #[test]
    fn test_purging_removes_boundary_samples() {
        let config = CPCVConfig {
            n_groups: 5,
            n_test_groups: 1,
            purge_length: 3,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let n_samples = 50;
            let splits = v.generate_splits(n_samples);
            assert!(splits.is_ok());

            if let Ok(s) = splits {
                // Check a middle group (e.g., group 2 = indices 20..30 for 50/5=10 per group)
                // Find the split where group 2 is the test group
                for split in &s {
                    // Check that purged indices (3 before test start, 3 after test end)
                    // are not in the training set
                    if !split.test_indices.is_empty() {
                        let test_min = split
                            .test_indices
                            .iter()
                            .copied()
                            .min()
                            .unwrap_or(0);
                        let test_max = split
                            .test_indices
                            .iter()
                            .copied()
                            .max()
                            .unwrap_or(0);

                        // Indices just before test should be purged
                        for purge_idx in test_min.saturating_sub(3)..test_min {
                            assert!(
                                !split.train_indices.contains(&purge_idx),
                                "Purge index {} before test should not be in training",
                                purge_idx
                            );
                        }

                        // Indices just after test should be purged
                        let purge_end = (test_max + 1 + 3).min(n_samples);
                        for purge_idx in (test_max + 1)..purge_end {
                            assert!(
                                !split.train_indices.contains(&purge_idx),
                                "Purge index {} after test should not be in training",
                                purge_idx
                            );
                        }
                    }
                }
            }
        }
    }

    #[test]
    fn test_embargo_removes_samples_after_test() {
        let config = CPCVConfig {
            n_groups: 5,
            n_test_groups: 1,
            purge_length: 0,
            embargo_length: 4,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let n_samples = 50;
            let splits = v.generate_splits(n_samples);
            assert!(splits.is_ok());

            if let Ok(s) = splits {
                for split in &s {
                    if !split.test_indices.is_empty() {
                        let test_max = split
                            .test_indices
                            .iter()
                            .copied()
                            .max()
                            .unwrap_or(0);

                        // Embargo indices after test should not be in training
                        let embargo_end = (test_max + 1 + 4).min(n_samples);
                        for embargo_idx in (test_max + 1)..embargo_end {
                            assert!(
                                !split.train_indices.contains(&embargo_idx),
                                "Embargo index {} should not be in training",
                                embargo_idx
                            );
                        }
                    }
                }
            }
        }
    }

    #[test]
    fn test_generate_splits_insufficient_samples() {
        let config = CPCVConfig {
            n_groups: 10,
            n_test_groups: 2,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let result = v.generate_splits(5); // 5 < 10
            assert!(result.is_err());
        }
    }

    #[test]
    fn test_all_samples_covered_no_purge() {
        let config = CPCVConfig {
            n_groups: 4,
            n_test_groups: 1,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let n_samples = 40;
            let splits = v.generate_splits(n_samples);
            assert!(splits.is_ok());

            if let Ok(s) = splits {
                for split in &s {
                    // Without purge/embargo, train + test = all samples
                    let mut all_indices: Vec<usize> = split.train_indices.clone();
                    all_indices.extend_from_slice(&split.test_indices);
                    all_indices.sort_unstable();
                    all_indices.dedup();
                    assert_eq!(
                        all_indices.len(),
                        n_samples,
                        "Train + test should cover all samples when purge=0, embargo=0"
                    );
                }
            }
        }
    }

    // =========================================================================
    // Validation tests
    // =========================================================================

    #[test]
    fn test_validate_basic() {
        let config = CPCVConfig {
            n_groups: 5,
            n_test_groups: 2,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let data = make_data(100);
            let result = v.validate(&data, mean_strategy);
            assert!(result.is_ok());

            if let Ok(r) = result {
                assert_eq!(r.num_splits, 10); // C(5,2) = 10
                assert_eq!(r.per_split_returns.len(), 10);
                // Mean of ascending data should be positive
                assert!(r.mean_return > 0.0);
                assert!(r.std_return >= 0.0);
                // All returns from ascending data are positive
                assert!((r.probability_of_loss - 0.0).abs() < f64::EPSILON);
            }
        }
    }

    #[test]
    fn test_validate_with_purge_and_embargo() {
        let config = CPCVConfig {
            n_groups: 5,
            n_test_groups: 2,
            purge_length: 2,
            embargo_length: 2,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let data = make_data(200);
            let result = v.validate(&data, mean_strategy);
            assert!(result.is_ok());

            if let Ok(r) = result {
                assert_eq!(r.num_splits, 10);
                // With purge/embargo, training sets are smaller, but mean should still be positive
                assert!(r.mean_return > 0.0);
            }
        }
    }

    #[test]
    fn test_validate_probability_of_loss() {
        let config = CPCVConfig {
            n_groups: 4,
            n_test_groups: 1,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            // Data that goes negative in later segments
            let mut data = Vec::with_capacity(40);
            for i in 0..20 {
                data.push(i as f64 * 0.01); // positive
            }
            for i in 0..20 {
                data.push(-0.01 * (i as f64 + 1.0)); // negative
            }

            let result = v.validate(&data, mean_strategy);
            assert!(result.is_ok());

            if let Ok(r) = result {
                // Some splits should have negative returns (groups in the negative region)
                assert!(r.probability_of_loss > 0.0);
                assert!(r.probability_of_loss <= 1.0);
            }
        }
    }

    #[test]
    fn test_validate_strategy_error_propagation() {
        let config = CPCVConfig {
            n_groups: 3,
            n_test_groups: 1,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let data = make_data(30);
            let result = v.validate(&data, |_train, _test| {
                Err(MLError::ValidationError {
                    message: "strategy failure".to_owned(),
                })
            });
            assert!(result.is_err());
        }
    }

    // =========================================================================
    // Result serde tests
    // =========================================================================

    #[test]
    fn test_result_serde_roundtrip() {
        let result = CPCVResult {
            mean_return: 0.05,
            std_return: 0.02,
            per_split_returns: vec![0.03, 0.07, 0.05],
            probability_of_loss: 0.0,
            num_splits: 3,
        };

        let json = serde_json::to_string(&result).ok();
        assert!(json.is_some());

        let json_str = json.unwrap_or_default();
        let parsed: Result<CPCVResult, _> = serde_json::from_str(&json_str);
        assert!(parsed.is_ok());

        if let Ok(r) = parsed {
            assert!((r.mean_return - 0.05).abs() < f64::EPSILON);
            assert_eq!(r.num_splits, 3);
            assert_eq!(r.per_split_returns.len(), 3);
        }
    }

    // =========================================================================
    // Edge case tests
    // =========================================================================

    #[test]
    fn test_minimal_valid_config() {
        let config = CPCVConfig {
            n_groups: 2,
            n_test_groups: 1,
            purge_length: 0,
            embargo_length: 0,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            assert_eq!(v.num_combinations(), 2); // C(2,1) = 2
            let splits = v.generate_splits(10);
            assert!(splits.is_ok());
            if let Ok(s) = splits {
                assert_eq!(s.len(), 2);
            }
        }
    }

    #[test]
    fn test_large_purge_does_not_panic() {
        // Purge larger than group size should still produce valid (possibly empty) training sets
        let config = CPCVConfig {
            n_groups: 3,
            n_test_groups: 1,
            purge_length: 100,
            embargo_length: 100,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let splits = v.generate_splits(30);
            assert!(splits.is_ok());
            // Splits should still be generated even if training sets are small
            if let Ok(s) = splits {
                assert_eq!(s.len(), 3); // C(3,1) = 3
            }
        }
    }

    #[test]
    fn test_purge_and_embargo_combined() {
        let config = CPCVConfig {
            n_groups: 5,
            n_test_groups: 1,
            purge_length: 2,
            embargo_length: 3,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let n_samples = 50;
            let splits = v.generate_splits(n_samples);
            assert!(splits.is_ok());

            if let Ok(s) = splits {
                for split in &s {
                    if !split.test_indices.is_empty() {
                        let test_min = split
                            .test_indices
                            .iter()
                            .copied()
                            .min()
                            .unwrap_or(0);
                        let test_max = split
                            .test_indices
                            .iter()
                            .copied()
                            .max()
                            .unwrap_or(0);

                        // Check purge zone before test
                        for idx in test_min.saturating_sub(2)..test_min {
                            assert!(
                                !split.train_indices.contains(&idx),
                                "Purge-before index {} should be excluded",
                                idx
                            );
                        }

                        // Check purge zone after test
                        let purge_after_end = (test_max + 1 + 2).min(n_samples);
                        for idx in (test_max + 1)..purge_after_end {
                            assert!(
                                !split.train_indices.contains(&idx),
                                "Purge-after index {} should be excluded",
                                idx
                            );
                        }

                        // Check embargo zone (after purge zone)
                        let embargo_end = (purge_after_end + 3).min(n_samples);
                        for idx in purge_after_end..embargo_end {
                            assert!(
                                !split.train_indices.contains(&idx),
                                "Embargo index {} should be excluded",
                                idx
                            );
                        }
                    }
                }
            }
        }
    }

    #[test]
    fn test_train_set_smaller_with_purge() {
        let config_no_purge = CPCVConfig {
            n_groups: 5,
            n_test_groups: 1,
            purge_length: 0,
            embargo_length: 0,
        };
        let config_with_purge = CPCVConfig {
            n_groups: 5,
            n_test_groups: 1,
            purge_length: 3,
            embargo_length: 2,
        };

        let v_no_purge = CPCVValidator::new(config_no_purge);
        let v_with_purge = CPCVValidator::new(config_with_purge);
        assert!(v_no_purge.is_ok());
        assert!(v_with_purge.is_ok());

        if let (Ok(v_np), Ok(v_wp)) = (v_no_purge, v_with_purge) {
            let splits_np = v_np.generate_splits(100);
            let splits_wp = v_wp.generate_splits(100);
            assert!(splits_np.is_ok());
            assert!(splits_wp.is_ok());

            if let (Ok(s_np), Ok(s_wp)) = (splits_np, splits_wp) {
                // Training sets with purge/embargo should be smaller
                for i in 0..s_np.len().min(s_wp.len()) {
                    if let (Some(np), Some(wp)) = (s_np.get(i), s_wp.get(i)) {
                        assert!(
                            wp.train_indices.len() <= np.train_indices.len(),
                            "Purged training set should be <= non-purged. Got {} vs {}",
                            wp.train_indices.len(),
                            np.train_indices.len()
                        );
                    }
                }
            }
        }
    }

    #[test]
    fn test_config_accessor() {
        let config = CPCVConfig {
            n_groups: 7,
            n_test_groups: 3,
            purge_length: 4,
            embargo_length: 2,
        };
        let validator = CPCVValidator::new(config.clone());
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            assert_eq!(v.config().n_groups, 7);
            assert_eq!(v.config().n_test_groups, 3);
            assert_eq!(v.config().purge_length, 4);
            assert_eq!(v.config().embargo_length, 2);
        }
    }

    #[test]
    fn test_default_config_validation() {
        let config = CPCVConfig::default();
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let data = make_data(200);
            let result = v.validate(&data, mean_strategy);
            assert!(result.is_ok());

            if let Ok(r) = result {
                assert_eq!(r.num_splits, 45); // C(10, 2) = 45
            }
        }
    }

    #[test]
    fn test_split_indices_are_sorted_and_unique() {
        let config = CPCVConfig {
            n_groups: 4,
            n_test_groups: 2,
            purge_length: 1,
            embargo_length: 1,
        };
        let validator = CPCVValidator::new(config);
        assert!(validator.is_ok());

        if let Ok(v) = validator {
            let splits = v.generate_splits(40);
            assert!(splits.is_ok());

            if let Ok(s) = splits {
                for split in &s {
                    // Test indices should be sorted (because groups are contiguous)
                    let mut sorted_test = split.test_indices.clone();
                    sorted_test.sort_unstable();
                    assert_eq!(split.test_indices, sorted_test);

                    // Train indices should be sorted
                    let mut sorted_train = split.train_indices.clone();
                    sorted_train.sort_unstable();
                    assert_eq!(split.train_indices, sorted_train);

                    // No duplicates in train
                    let mut deduped = split.train_indices.clone();
                    deduped.dedup();
                    assert_eq!(split.train_indices.len(), deduped.len());

                    // No duplicates in test
                    let mut deduped_test = split.test_indices.clone();
                    deduped_test.dedup();
                    assert_eq!(split.test_indices.len(), deduped_test.len());
                }
            }
        }
    }
}
