use super::*;
use crate::dqn::TradingState;
use crate::hyperopt::ParameterSpace;
use std::sync::OnceLock;

/// Shared CUDA device across all trainer tests.
///
/// Root cause fix for CUBLAS_STATUS_NOT_INITIALIZED cascades: each
/// `Device::cuda_if_available(0)` creates a new cuBLAS handle. With
/// 400+ tests doing this in rapid succession (even with --test-threads=1),
/// the driver's internal handle pool is exhausted. Sharing one device
/// eliminates the churn entirely.
static SHARED_DEVICE: OnceLock<MlDevice> = OnceLock::new();

fn shared_cuda_device() -> MlDevice {
    // Initialize tracing so kernel compilation/launch logs are visible.
    static TRACING_INIT: std::sync::Once = std::sync::Once::new();
    TRACING_INIT.call_once(|| {
        let filter = tracing_subscriber::EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
        tracing_subscriber::fmt()
            .with_env_filter(filter)
            .with_test_writer()
            .try_init()
            .ok();
    });
    SHARED_DEVICE
        .get_or_init(|| {
            MlDevice::new_cuda(0).expect("CUDA required for tests")
        })
        .clone()
}

// Helper function to create test hyperparameters
// Uses conservative defaults suitable for testing
fn create_test_params() -> DQNHyperparameters {
    let mut params = DQNHyperparameters::conservative();
    // Production default: branching DQN (3 heads: exposure, order, urgency).
    // Always enabled — the warp-cooperative kernel on H100 requires it.
    params.hidden_dim_base = Some(32); // Small for fast test iterations
    params.buffer_size = 10_000;
    params.replay_buffer_vram_fraction = 0.0; // Disable AutoReplaySizer in tests
    params
}

fn create_test_trainer() -> Result<DQNTrainer> {
    DQNTrainer::new_with_device(create_test_params(), shared_cuda_device())
}

fn create_test_trainer_with(params: DQNHyperparameters) -> Result<DQNTrainer> {
    DQNTrainer::new_with_device(params, shared_cuda_device())
}

/// Pad a TradingState's regime_features so that `state.dimension()` matches the
/// trainer's aligned state_dim (GPU state_dim is 72 or 80 due to portfolio+MTF expansion).
fn pad_state_to_aligned(state: &mut TradingState) {
    let aligned_dim = (state.dimension() + 7) & !7;
    let pad = aligned_dim.saturating_sub(state.dimension());
    if pad > 0 {
        state.regime_features.extend(vec![0.0_f32; pad]);
    }
}

#[tokio::test]
async fn test_dqn_trainer_creation() {
    let hyperparams = create_test_params();
    let trainer = create_test_trainer_with(hyperparams);

    assert!(
        trainer.is_ok(),
        "Failed to create DQN trainer: {:?}",
        trainer.err()
    );
}

#[tokio::test]
async fn test_batch_size_validation() {
    let mut hyperparams = create_test_params();
    hyperparams.batch_size = 500;

    // VRAM ceiling clamps if needed, never rejects
    let trainer = create_test_trainer_with(hyperparams);
    assert!(
        trainer.is_ok(),
        "Should clamp oversized batch, not reject: {:?}",
        trainer.err()
    );
}

#[tokio::test]
async fn test_feature_vector_to_state() {
    let hyperparams = create_test_params();
    let trainer = create_test_trainer_with(hyperparams).unwrap();

    // Create a synthetic 42-dim feature vector (42 market features)
    let mut feature_vec = [0.0; 42];
    feature_vec[0] = 4000.0; // open
    feature_vec[1] = 4010.0; // high
    feature_vec[2] = 3990.0; // low
    feature_vec[3] = 4005.0; // close
    feature_vec[4] = 1000.0; // volume
                             // Fill remaining features with synthetic data
    for i in 5..42 {
        feature_vec[i] = (i as f64) * 0.1;
    }

    let close_price =
        rust_decimal::Decimal::try_from(feature_vec[3]).unwrap_or(rust_decimal::Decimal::ZERO);
    let state = trainer.feature_vector_to_state(&feature_vec, Some(close_price));

    assert!(
        state.is_ok(),
        "Failed to convert feature vector: {:?}",
        state.err()
    );

    let state = state.unwrap();
    // State dimension depends on whether OFI (MBP-10) data dirs are configured:
    // - Without OFI: 45 = 42 market + 3 portfolio
    // - With OFI:    65 = 42 market + 20 OFI + 3 portfolio
    // Smoketest TOML sets mbp10_data_dir, so OFI is enabled.
    let expected_dim = if trainer.hyperparams.mbp10_data_dir.is_empty() { 45 } else { 65 };
    assert_eq!(
        state.dimension(),
        expected_dim,
        "State dimension mismatch (OFI enabled={})",
        !trainer.hyperparams.mbp10_data_dir.is_empty()
    );
}

#[tokio::test]
async fn test_batched_action_selection() {
    let hyperparams = create_test_params();
    let mut trainer = create_test_trainer_with(hyperparams).unwrap();

    // Create multiple synthetic states for batched action selection
    let batch_size = 10;
    let mut states = Vec::with_capacity(batch_size);

    for i in 0..batch_size {
        let mut feature_vec = [0.0; 42]; // 42 market features
                                          // Create varied states for testing
        feature_vec[0] = 4000.0 + (i as f64 * 10.0); // open
        feature_vec[1] = 4010.0 + (i as f64 * 10.0); // high
        feature_vec[2] = 3990.0 + (i as f64 * 10.0); // low
        feature_vec[3] = 4005.0 + (i as f64 * 10.0); // close
        feature_vec[4] = 1000.0 + (i as f64 * 100.0); // volume

        // Fill remaining features
        for j in 5..42 {
            feature_vec[j] = (j as f64 + i as f64) * 0.1;
        }

        let close_price = rust_decimal::Decimal::try_from(feature_vec[3])
            .unwrap_or(rust_decimal::Decimal::ZERO);
        let mut state = trainer
            .feature_vector_to_state(&feature_vec, Some(close_price))
            .unwrap();
        pad_state_to_aligned(&mut state);
        states.push(state);
    }

    // Test batched action selection
    let actions_result = trainer.select_actions_batch(&states).await;

    assert!(
        actions_result.is_ok(),
        "Batched action selection failed: {:?}",
        actions_result.err()
    );

    let actions = actions_result.unwrap();
    assert_eq!(
        actions.len(),
        batch_size,
        "Expected {} actions, got {}",
        batch_size,
        actions.len()
    );

    // Verify all actions have valid exposure indices (0-4)
    for (i, action) in actions.iter().enumerate() {
        let exp_idx = action.exposure as usize;
        assert!(
            exp_idx < 5,
            "Action {} has invalid exposure index {}: {:?}",
            i,
            exp_idx,
            action
        );
    }
}

#[tokio::test]
async fn test_batched_vs_sequential_action_selection_consistency() {
    let hyperparams = create_test_params();
    let mut trainer = create_test_trainer_with(hyperparams).unwrap();

    // Create test states
    let batch_size = 5;
    let mut states = Vec::with_capacity(batch_size);

    for i in 0..batch_size {
        let mut feature_vec = [0.0; 42]; // 42 market features
        feature_vec[0] = 4000.0 + (i as f64 * 50.0);
        feature_vec[1] = 4050.0 + (i as f64 * 50.0);
        feature_vec[2] = 3950.0 + (i as f64 * 50.0);
        feature_vec[3] = 4025.0 + (i as f64 * 50.0);
        feature_vec[4] = 5000.0 + (i as f64 * 500.0);

        for j in 5..42 {
            feature_vec[j] = (j as f64) * 0.5 + (i as f64);
        }

        let close_price = rust_decimal::Decimal::try_from(feature_vec[3])
            .unwrap_or(rust_decimal::Decimal::ZERO);
        let mut state = trainer
            .feature_vector_to_state(&feature_vec, Some(close_price))
            .unwrap();
        pad_state_to_aligned(&mut state);
        states.push(state);
    }

    // Get batched actions (GPU-optimized)
    let batched_actions = trainer.select_actions_batch(&states).await.unwrap();

    // Both should return valid actions
    assert_eq!(
        batched_actions.len(),
        batch_size,
        "Batched action count mismatch"
    );

    // Verify all actions have valid exposure indices (0-4)
    for action in &batched_actions {
        let exp_idx = action.exposure as usize;
        assert!(exp_idx < 5, "Invalid exposure index {}: {:?}", exp_idx, action);
    }
}

#[tokio::test]
async fn test_empty_batch_handling() {
    let hyperparams = create_test_params();
    let mut trainer = create_test_trainer_with(hyperparams).unwrap();

    let empty_states: Vec<TradingState> = Vec::new();
    let result = trainer.select_actions_batch(&empty_states).await;

    assert!(result.is_ok(), "Empty batch should be handled gracefully");
    assert_eq!(
        result.unwrap().len(),
        0,
        "Empty batch should return empty actions"
    );
}

#[tokio::test]
async fn test_zero_batch_size_handling() {
    // Test DQN rejects zero batch size
    let mut hyperparams = create_test_params();
    hyperparams.batch_size = 0;

    let result = create_test_trainer_with(hyperparams);

    // Should fail with descriptive error
    assert!(
        result.is_err(),
        "DQN should reject zero batch size, but got: {:?}",
        result
    );

    // Error message should mention batch size
    let error_msg = result.unwrap_err().to_string();
    assert!(
        error_msg.to_lowercase().contains("batch"),
        "Error message should mention batch size, got: {}",
        error_msg
    );
}

// ===== Agent 23 Test #6: Batch Size Mismatch Validation Tests =====

/// Production-critical test: Verify trainer handles batch smaller than configured
#[tokio::test]
async fn test_batch_size_mismatch_smaller_than_configured() {
    let mut hyperparams = create_test_params();
    hyperparams.batch_size = 32;
    let mut trainer = create_test_trainer_with(hyperparams).unwrap();

    // Create batch with 16 states (half of configured 32)
    let mut feature_vec = [0.0; 42]; // 42 market features
    for i in 0..4 {
        feature_vec[i] = 4000.0 + (i as f64 * 10.0);
    }
    for i in 5..42 {
        feature_vec[i] = (i as f64) * 0.1;
    }

    let close_price =
        rust_decimal::Decimal::try_from(feature_vec[3]).unwrap_or(rust_decimal::Decimal::ZERO);
    let mut state = trainer
        .feature_vector_to_state(&feature_vec, Some(close_price))
        .unwrap();
    pad_state_to_aligned(&mut state);
    let smaller_batch = vec![state.clone(); 16];

    let result = trainer.select_actions_batch(&smaller_batch).await;
    assert!(
        result.is_ok(),
        "DQN should handle smaller batches: {:?}",
        result.err()
    );
    assert_eq!(
        result.unwrap().len(),
        16,
        "Should return action for each state"
    );
}

/// Production-critical test: Verify trainer handles batch larger than configured
#[tokio::test]
async fn test_batch_size_mismatch_larger_than_configured() {
    let mut hyperparams = create_test_params();
    hyperparams.batch_size = 16;
    let mut trainer = create_test_trainer_with(hyperparams).unwrap();

    // Create batch with 64 states (4x configured 16)
    let mut feature_vec = [0.0; 42]; // 42 market features
    for i in 0..4 {
        feature_vec[i] = 4000.0 + (i as f64 * 10.0);
    }
    for i in 5..42 {
        feature_vec[i] = (i as f64) * 0.1;
    }

    let close_price =
        rust_decimal::Decimal::try_from(feature_vec[3]).unwrap_or(rust_decimal::Decimal::ZERO);
    let mut state = trainer
        .feature_vector_to_state(&feature_vec, Some(close_price))
        .unwrap();
    pad_state_to_aligned(&mut state);
    let larger_batch = vec![state.clone(); 64];

    let result = trainer.select_actions_batch(&larger_batch).await;
    assert!(
        result.is_ok(),
        "DQN should handle larger batches: {:?}",
        result.err()
    );
    assert_eq!(
        result.unwrap().len(),
        64,
        "Should return action for each state"
    );
}

/// Production-critical test: Verify empty batch handling
#[tokio::test]
async fn test_empty_batch_returns_empty_actions() {
    let mut trainer = create_test_trainer().unwrap();
    let empty_batch: Vec<TradingState> = vec![];

    let result = trainer.select_actions_batch(&empty_batch).await;
    assert!(result.is_ok(), "Should handle empty batch gracefully");
    assert_eq!(
        result.unwrap().len(),
        0,
        "Empty batch should return empty actions"
    );
}

/// Production-critical test: Verify single-sample batch handling
#[tokio::test]
async fn test_single_sample_batch() {
    let mut hyperparams = create_test_params();
    hyperparams.batch_size = 32;
    let mut trainer = create_test_trainer_with(hyperparams).unwrap();

    let mut feature_vec = [0.0; 42]; // 42 market features
    for i in 0..4 {
        feature_vec[i] = 4000.0;
    }
    for i in 5..42 {
        feature_vec[i] = (i as f64) * 0.1;
    }

    let close_price =
        rust_decimal::Decimal::try_from(feature_vec[3]).unwrap_or(rust_decimal::Decimal::ZERO);
    let mut state = trainer
        .feature_vector_to_state(&feature_vec, Some(close_price))
        .unwrap();
    pad_state_to_aligned(&mut state);
    let single_batch = vec![state];

    let result = trainer.select_actions_batch(&single_batch).await;
    assert!(
        result.is_ok(),
        "Should handle single-sample batch: {:?}",
        result.err()
    );
    assert_eq!(result.unwrap().len(), 1, "Should return exactly one action");
}

/// Large batch sizes are accepted (VRAM ceiling is the only cap)
#[test]
fn test_large_batch_size_accepted() {
    let mut hyperparams = create_test_params();
    hyperparams.batch_size = 2048;

    let result = create_test_trainer_with(hyperparams);
    assert!(
        result.is_ok(),
        "Should accept large batch sizes within VRAM ceiling: {:?}",
        result.err()
    );
}

/// Production-critical test: Non-power-of-2 batch sizes
#[tokio::test]
async fn test_non_power_of_two_batch_size() {
    let mut hyperparams = create_test_params();
    hyperparams.batch_size = 13; // Not a power of 2

    let result = create_test_trainer_with(hyperparams);
    assert!(
        result.is_ok(),
        "Should accept non-power-of-2 batch sizes: {:?}",
        result.err()
    );
}

/// Production-critical test: Train with empty dataset doesn't crash
#[tokio::test]
async fn test_train_with_empty_data_completes_gracefully() {
    let mut params = create_test_params();
    params.epochs = 5; // Short run — just checking it doesn't panic
    params.early_stopping_enabled = false;
    params.gradient_collapse_patience = 1000;
    params.buffer_size = 1024; // MIN_GPU_CAPACITY — GPU PER mandatory
    let device = MlDevice::new_cuda(0).expect("CUDA device required");
    let mut trainer = DQNTrainer::new_with_device(params, device).unwrap();
    let empty_data: Vec<([f64; 42], [f64; 6])> = vec![];
    // Wrap in the shared callback handle — `train_with_data_full_loop_slices`
    // takes a `CheckpointCallbackHandle` so the async-checkpoint worker
    // can hold a clone alongside the synchronous epoch-loop callsites.
    let cb_handle: super::CheckpointCallbackHandle = std::sync::Arc::new(
        std::sync::Mutex::new(Box::new(|_, _, _| Ok(String::new())) as super::BoxedCheckpointCallback),
    );

    let result = trainer
        .train_with_data_full_loop_slices(&empty_data, cb_handle)
        .await;

    assert!(
        result.is_err(),
        "Training with empty data should return an error (no CPU fallback)"
    );
}

/// Test reward function calculates actual price changes correctly
#[test]
fn test_reward_function_price_changes() {
    let trainer = create_test_trainer().unwrap();

    // Test upward price move (+14.25 points, should clamp to +1.0)
    let reward_up = trainer.calculate_reward(5900.0, 5914.25);
    assert!(
        (reward_up - 1.0).abs() < 1e-6,
        "Upward move should return +1.0 (clamped), got: {}",
        reward_up
    );

    // Test downward price move (-14.25 points, should clamp to -1.0)
    let reward_down = trainer.calculate_reward(5914.25, 5900.0);
    assert!(
        (reward_down - (-1.0)).abs() < 1e-6,
        "Downward move should return -1.0 (clamped), got: {}",
        reward_down
    );

    // Test flat market (0 points, should return 0.0)
    let reward_flat = trainer.calculate_reward(5900.0, 5900.0);
    assert!(
        reward_flat.abs() < 1e-6,
        "Flat market should return 0.0, got: {}",
        reward_flat
    );

    // Test small upward move (+5 points, should return +0.5)
    let reward_small_up = trainer.calculate_reward(5900.0, 5905.0);
    assert!(
        (reward_small_up - 0.5).abs() < 1e-6,
        "Small upward move (+5) should return +0.5, got: {}",
        reward_small_up
    );

    // Test small downward move (-5 points, should return -0.5)
    let reward_small_down = trainer.calculate_reward(5905.0, 5900.0);
    assert!(
        (reward_small_down - (-0.5)).abs() < 1e-6,
        "Small downward move (-5) should return -0.5, got: {}",
        reward_small_down
    );

    // Test unclamped move (+3 points, should return +0.3)
    let reward_unclamped = trainer.calculate_reward(5900.0, 5903.0);
    assert!(
        (reward_unclamped - 0.3).abs() < 1e-6,
        "Move of +3 points should return +0.3, got: {}",
        reward_unclamped
    );
}

#[test]
fn test_dynamic_batch_size_l4() {
    // L4 has 24GB VRAM — HardwareBudget should allow batch_size >> 230
    let budget = crate::hyperopt::HardwareBudget {
        gpu_memory_mb: 24_000,
        gpu_name: "NVIDIA L4".to_string(),
    };
    let batch = budget.max_batch_size(50.0, 0.0005, 64.0, 8192.0);
    assert!(batch.unwrap_or(0.0) > 230.0, "L4 should support DQN batch > 230, got {:?}", batch);
}

#[test]
fn test_dynamic_batch_size_h100() {
    // H100 has 80GB VRAM — should hit the 8192 ceiling
    let budget = crate::hyperopt::HardwareBudget {
        gpu_memory_mb: 81_920,
        gpu_name: "NVIDIA H100".to_string(),
    };
    let batch = budget.max_batch_size(50.0, 0.0005, 64.0, 8192.0);
    assert!((batch.unwrap_or(0.0) - 8192.0).abs() < 1.0, "H100 should hit 8192 ceiling, got {:?}", batch);
}

// ── C2 Overhaul Smoke Tests ─────────────────────────────────────────

/// Verify DQN action space is 4 direction levels (Short/Hold/Long/Flat).
#[test]
fn test_c2_dqn_default_num_actions_is_4() {
    let config = crate::dqn::DQNConfig::default();
    assert_eq!(config.num_actions, 4, "DQN default must be 4 direction-level actions (Short/Hold/Long/Flat)");
}

/// Verify 5 exposure indices produce 5 distinct exposure levels.
#[test]
fn test_c2_five_actions_produce_distinct_exposures() {
    use crate::dqn::action_space::ExposureLevel;
    use crate::dqn::order_router::OrderRouter;

    let actions: Vec<_> = (0..5)
        .filter_map(|idx| ExposureLevel::from_index(idx).ok())
        .map(|e| OrderRouter::route_default(e))
        .collect();

    assert_eq!(actions.len(), 5);

    let unique: std::collections::HashSet<_> = actions.iter().map(|a| a.exposure).collect();
    assert_eq!(unique.len(), 5, "All 5 exposure levels must be distinct");
}

/// Verify hyperopt search space is 14D (consolidated from 30D via family grouping).
#[test]
fn test_c3_search_space_is_14d() {
    let bounds = crate::hyperopt::adapters::dqn::DQNParams::continuous_bounds();
    assert_eq!(bounds.len(), 15, "Search space must be 15D (4 breakouts + 5 core families + 6 gen families)");

    let names = crate::hyperopt::adapters::dqn::DQNParams::param_names();
    assert_eq!(names.len(), 15);
    assert!(names.contains(&"gamma"), "gamma must be in search space (breakout)");
    assert!(names.contains(&"c51_warmup_epochs"), "c51_warmup_epochs must be in search space (breakout)");
    assert!(names.contains(&"iqn_lambda"), "iqn_lambda must be in search space (breakout)");
    assert!(names.contains(&"learning_intensity"), "learning_intensity must be in search space (core family)");
    assert!(names.contains(&"architecture_intensity"), "architecture_intensity must be in search space (core family)");
    assert!(names.contains(&"adversarial_intensity"), "adversarial_intensity must be in search space (gen family)");
    // Individual params absorbed into families should NOT be in the search space:
    assert!(!names.contains(&"learning_rate"), "learning_rate absorbed into learning_intensity family");
    assert!(!names.contains(&"batch_size"), "batch_size fixed at TOML default");
    assert!(!names.contains(&"hidden_dim_base"), "hidden_dim_base absorbed into architecture_intensity family");
    assert!(!names.contains(&"branch_hidden_dim"), "branch_hidden_dim should be fixed, not in search space");
    assert!(!names.contains(&"curiosity_weight"), "curiosity_weight should be fixed, not in search space");
    assert!(!names.contains(&"her_ratio"), "her_ratio should be fixed, not in search space");
    assert!(!names.contains(&"cvar_alpha"), "cvar_alpha should be fixed, not in search space");
}

/// Verify exploration_intensity defaults to 1.0 (neutral scaling of exploration params).
/// noisy_epsilon_floor and count_bonus_coefficient are now fixed in DQNHyperparameters,
/// not in DQNParams.
#[test]
fn test_exploration_intensity_default() {
    let params = crate::hyperopt::adapters::dqn::DQNParams::default();
    assert!(
        (params.exploration_intensity - 1.0).abs() < 1e-6,
        "exploration_intensity must default to 1.0 (neutral)"
    );
}

/// Verify DQNParams 14D roundtrip preserves all family intensities.
#[test]
fn test_14d_roundtrip_preserves_intensities() {
    use crate::hyperopt::traits::ParameterSpace;

    let params = crate::hyperopt::adapters::dqn::DQNParams::default();
    let continuous = params.to_continuous();
    let recovered = crate::hyperopt::adapters::dqn::DQNParams::from_continuous(&continuous).unwrap();

    assert!(
        (recovered.exploration_intensity - params.exploration_intensity).abs() < 1e-6,
        "exploration_intensity must survive roundtrip"
    );
    assert!(
        (recovered.learning_intensity - params.learning_intensity).abs() < 1e-6,
        "learning_intensity must survive roundtrip"
    );
    assert!(
        (recovered.ensemble_intensity - params.ensemble_intensity).abs() < 1e-6,
        "ensemble_intensity must survive roundtrip"
    );
}

/// SP13 P0b — aux_w deficit + stagnation controller unit tests.
///
/// The kernel-tested machinery (apply_pearls EMAs, dir_acc reduction) lives in
/// `sp13_phase0_oracle_tests.rs`. These tests cover the host-side controller
/// formula `compute_aux_w_p0b(target, short, long, epochs_in_fold)` extracted
/// in P0b for testability, exercising the boundary regimes the spec calls out:
///   1. at-target: deficit=0 → aux_w collapses to base
///   2. fully-stagnant: short below target with no improvement → decays to floor
///   3. improving: deficit-gain amplifies above base
///   4. SP14 A.3: epoch-0 warmup gate suppresses stagnation at fold reset
///      (Pearl-A first-observation bootstrap collapses short_ema = long_ema)
mod aux_w_controller_tests {
    use crate::cuda_pipeline::sp13_isv_slots::{
        AUX_W_BASE, AUX_W_HARD_FLOOR_RATIO,
    };
    use super::super::training_loop::compute_aux_w_p0b;

    /// At-target steady state: short = target, no deficit, no stagnation
    /// activation → aux_w = base × 1 × 1 = base.
    /// Uses `epochs_in_fold = 1` (post-warmup); the deficit=0 branch makes
    /// the warmup gate a no-op anyway.
    #[test]
    fn aux_w_at_target_returns_base() {
        let target = 0.55_f32;
        let short  = 0.55_f32;
        let long   = 0.50_f32;
        let aux_w = compute_aux_w_p0b(target, short, long, 1);
        assert!(
            (aux_w - AUX_W_BASE).abs() < 1e-6,
            "expected base={}, got {}",
            AUX_W_BASE, aux_w,
        );
    }

    /// Stagnation regime: short stuck below target with zero improvement
    /// (short == long) → stagnation = 1.0 → (1 - 0.7 × 1) = 0.3 decay factor
    /// drives aux_w_raw close to floor without crippling Q-head training.
    /// With deficit=0.05: aux_w_raw = 0.5 × 1.25 × 0.3 = 0.1875. The hard floor
    /// 0.15 = base × 0.3 prevents further suppression even at extreme stagnation
    /// (the 0.3 decay factor cannot push below floor unless deficit→0, in which
    /// case the deficit-gain term collapses anyway). The contract: under
    /// stagnation, aux_w must be much closer to floor than the matching
    /// improving regime would give.
    /// Uses `epochs_in_fold = 1` (post-warmup) so the stagnation gate fires.
    #[test]
    fn aux_w_stagnation_decays_to_floor() {
        let target = 0.55_f32;
        let short  = 0.50_f32;
        let long   = 0.50_f32;  // no improvement → stagnation = 1
        let aux_w_stagnant = compute_aux_w_p0b(target, short, long, 1);
        let floor = AUX_W_BASE * AUX_W_HARD_FLOOR_RATIO;

        // Sanity: never below the hard floor.
        assert!(
            aux_w_stagnant >= floor - 1e-6,
            "must respect floor={}; got {}",
            floor, aux_w_stagnant,
        );

        // Same deficit but with full improvement (long < short) → stagnation = 0.
        let long_improving = 0.45_f32;
        let aux_w_improving = compute_aux_w_p0b(target, short, long_improving, 1);

        // Stagnation must drag aux_w toward floor: ≥3× drop versus improving.
        assert!(
            aux_w_improving / aux_w_stagnant >= 3.0,
            "stagnation factor 0.3 must decay aux_w by ≥3× (improving={}, stagnant={})",
            aux_w_improving, aux_w_stagnant,
        );

        // Concrete value check: 0.5 × 1.25 × 0.3 = 0.1875.
        let expected = 0.1875_f32;
        assert!(
            (aux_w_stagnant - expected).abs() < 1e-5,
            "stagnant value should be {}, got {}",
            expected, aux_w_stagnant,
        );
    }

    /// Improving below target: short pulled ahead of long, no stagnation, but
    /// deficit-gain still amplifies. With target=0.55, short=0.50, long=0.45:
    ///   deficit=0.05, improvement=0.05, stagnation=0.0
    ///   aux_w_raw = 0.5 × (1 + 5 × 0.05) × 1.0 = 0.5 × 1.25 = 0.625
    /// Within ceiling 1.5, so result = 0.625.
    /// Uses `epochs_in_fold = 1` (post-warmup); zero stagnation makes the gate
    /// a no-op for this case.
    #[test]
    fn aux_w_improving_amplifies_above_base() {
        let target = 0.55_f32;
        let short  = 0.50_f32;
        let long   = 0.45_f32;
        let aux_w = compute_aux_w_p0b(target, short, long, 1);
        let expected = 0.625_f32;
        assert!(
            (aux_w - expected).abs() < 1e-5,
            "improving regime should amplify to {}, got {}",
            expected, aux_w,
        );
        assert!(aux_w > AUX_W_BASE, "must rise above base when improving with deficit");
    }

    /// SP14 A.3 — epoch-0 warmup gate suppresses stagnation at fold reset.
    ///
    /// Pearl-A first-observation bootstrap forces both EMAs equal at fold
    /// reset (sentinel 0.5 → first observation X causes short_ema =
    /// long_ema = X), making `improvement = max(0, short - long) = 0` BY
    /// CONSTRUCTION in epoch 0 — that's not stagnation, that's bootstrap.
    /// Without the gate the stagnation term would crush aux_w from
    /// `base × deficit_amp = 0.625` down to floor (0.15), masking the
    /// deficit-amp signal entirely on every new fold.
    ///
    /// Contract:
    ///   - Epoch 0: stagnation suppressed, aux_w = base × deficit_amp = 0.625
    ///   - Epoch 1+: stagnation active; with short==long that's stagnation=1,
    ///     so aux_w = base × deficit_amp × (1 - 0.7) = 0.1875, floored at 0.15.
    #[test]
    fn aux_w_stagnation_warmup_gate_epoch_0() {
        let target = 0.55_f32;
        let short_ema = 0.50_f32;  // Pearl-A first-observation: short = long
        let long_ema  = 0.50_f32;
        let deficit = (target - short_ema).max(0.0);  // 0.05
        let deficit_amp = 1.0 + 5.0 * deficit;        // 1.25

        // Epoch 0: stagnation SUPPRESSED.
        let aux_w_e0 = compute_aux_w_p0b(target, short_ema, long_ema, 0);
        let expected_e0 = (0.5 * deficit_amp).clamp(0.15, 1.5); // 0.625
        assert!(
            (aux_w_e0 - expected_e0).abs() < 1e-5,
            "epoch 0 (warmup) should skip stagnation; got {} expected {}",
            aux_w_e0, expected_e0,
        );

        // Epoch 1+: stagnation ACTIVE; with short==long, stagnation=1, so
        // aux_w = base × deficit_amp × (1 - 0.7 × 1) = 0.5 × 1.25 × 0.3 = 0.1875,
        // floored at 0.15.
        let aux_w_e1 = compute_aux_w_p0b(target, short_ema, long_ema, 1);
        let expected_e1 = (0.5 * deficit_amp * (1.0 - 0.7 * 1.0)).clamp(0.15, 1.5);
        assert!(
            (aux_w_e1 - expected_e1).abs() < 1e-5,
            "epoch 1 (post-warmup) should include stagnation; got {} expected {}",
            aux_w_e1, expected_e1,
        );
    }
}
