//! Generic Bayesian Optimizer using Argmin
//!
//! This module provides a production-ready optimization implementation that works
//! with any model implementing `HyperparameterOptimizable`. It uses the argmin
//! library with Particle Swarm Optimization (PSO) for efficient parameter search.
//!
//! ## Key Features
//!
//! - **Particle Swarm Optimization**: Derivative-free, population-based optimization
//! - **Latin Hypercube Sampling**: Smart initialization for first N trials
//! - **Type-safe**: Works with any `ParameterSpace` implementation
//! - **Production-ready**: Comprehensive error handling and logging
//!
//! ## Algorithm Overview
//!
//! 1. **Initialization**: Generate `n_initial` diverse samples via LHS
//! 2. **Evaluation**: Train model with each parameter set, record objective
//! 3. **Optimization**: Use Particle Swarm to find global optima
//! 4. **Multi-restart**: Swarm automatically explores multiple regions
//! 5. **Repeat**: Continue until `max_trials` reached
//!
//! ## Usage Example
//!
//! ```rust,no_run
//! use ml::hyperopt::{ArgminOptimizer, HyperparameterOptimizable};
//! use ml::hyperopt::adapters::mamba2::{Mamba2Trainer, Mamba2Params};
//!
//! # fn example() -> anyhow::Result<()> {
//! let trainer = Mamba2Trainer::new("data.parquet", 50)?;
//!
//! let optimizer = ArgminOptimizer::builder()
//!     .max_trials(30)
//!     .n_initial(5)
//!     .seed(42)
//!     .build();
//!
//! let result = optimizer.optimize(trainer)?;
//! println!("Best loss: {:.6}", result.best_objective);
//! # Ok(())
//! # }
//! ```

use anyhow::{Context, Result};
use argmin::core::{CostFunction, Executor, State};
use argmin::solver::particleswarm::ParticleSwarm;
use ndarray::Array2;
use rand::prelude::*;
use rand::SeedableRng;
use rand_distr::Uniform;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tracing::{info, warn};

use super::traits::{
    HyperparameterOptimizable, ObjectiveMode, OptimizationResult, ParameterSpace, TrialResult,
};
use crate::MLError;

/// Bayesian optimizer using argmin library
///
/// This optimizer uses Particle Swarm Optimization (PSO) with Latin Hypercube Sampling
/// for initialization. It's designed to work with any model implementing
/// `HyperparameterOptimizable`.
///
/// ## Configuration
///
/// - `max_trials`: Total evaluations (including initial samples)
/// - `n_initial`: Number of Latin Hypercube samples for initialization
/// - `n_particles`: Number of particles in the swarm (default: 20)
/// - `seed`: Random seed for reproducibility (optional)
///
/// ## Performance Characteristics
///
/// - Initial samples: ~O(n_initial * training_time)
/// - Per-iteration overhead: ~1-10ms for swarm updates
/// - Memory usage: O(max_trials) for trial history
///
/// ## Advantages over Nelder-Mead
///
/// - Supports multi-dimensional vector parameters (Vec<f64>)
/// - Better at avoiding local minima through swarm intelligence
/// - Scales well to higher dimensions (20-50 parameters)
/// - No gradient information required
#[derive(Debug, Clone)]
pub struct ArgminOptimizer {
    /// Maximum number of trials (evaluations)
    pub max_trials: usize,
    /// Number of initial samples via Latin Hypercube Sampling
    pub n_initial: usize,
    /// Number of particles in the swarm
    pub n_particles: usize,
    /// Random seed for reproducibility
    pub seed: Option<u64>,
    /// Maximum iterations per restart
    pub max_iters_per_restart: usize,
    /// Model name for Prometheus metrics progress reporting (e.g. "dqn", "ppo")
    pub model_name: Option<String>,
}

impl Default for ArgminOptimizer {
    fn default() -> Self {
        Self {
            max_trials: 30,
            n_initial: 5,
            n_particles: 20,
            seed: None,
            max_iters_per_restart: 50,
            model_name: None,
        }
    }
}

impl ArgminOptimizer {
    /// Create a new optimizer with default settings
    ///
    /// Uses 30 max trials and 5 initial samples.
    pub fn new() -> Self {
        Self::default()
    }

    /// Create optimizer with specified settings
    ///
    /// # Arguments
    ///
    /// * `max_trials` - Total number of trials (must be > n_initial)
    /// * `n_initial` - Number of initial LHS samples (typically 3-10)
    ///
    /// # Panics
    ///
    /// Panics if `max_trials <= n_initial` or if either is zero.
    pub fn with_trials(max_trials: usize, n_initial: usize) -> Self {
        assert!(max_trials > n_initial, "max_trials must be > n_initial");
        assert!(n_initial > 0, "n_initial must be > 0");

        Self {
            max_trials,
            n_initial,
            n_particles: 20,
            seed: None,
            max_iters_per_restart: 50,
            model_name: None,
        }
    }

    /// Set random seed for reproducibility
    pub fn with_seed(mut self, seed: u64) -> Self {
        self.seed = Some(seed);
        self
    }

    /// Start building an optimizer with custom configuration
    pub fn builder() -> ArgminOptimizerBuilder {
        ArgminOptimizerBuilder::new()
    }

    /// Generate Latin Hypercube samples for initialization
    ///
    /// # Arguments
    ///
    /// * `n_samples` - Number of samples to generate
    /// * `bounds` - Parameter bounds [(min, max), ...]
    /// * `rng` - Random number generator
    ///
    /// # Returns
    ///
    /// Array of shape (n_samples, n_params) with LHS samples
    pub fn latin_hypercube_sampling<R: Rng>(
        n_samples: usize,
        bounds: &[(f64, f64)],
        rng: &mut R,
    ) -> Array2<f64> {
        let n_params = bounds.len();
        let mut samples = Array2::zeros((n_samples, n_params));

        // For each parameter dimension
        for dim in 0..n_params {
            // Create permutation of [0, 1, 2, ..., n_samples-1]
            let mut indices: Vec<usize> = (0..n_samples).collect();
            indices.shuffle(rng);

            // Generate stratified samples
            let uniform = Uniform::new(0.0, 1.0);
            for (sample_idx, &perm_idx) in indices.iter().enumerate() {
                let segment_start = perm_idx as f64 / n_samples as f64;
                let segment_end = (perm_idx + 1) as f64 / n_samples as f64;
                let random_offset: f64 = rng.sample(uniform);
                let normalized = segment_start + random_offset * (segment_end - segment_start);

                // Denormalize to actual bounds
                let (min, max) = bounds[dim];
                samples[[sample_idx, dim]] = min + normalized * (max - min);
            }
        }

        samples
    }

    /// Run optimization on a model
    ///
    /// This method:
    /// 1. Extracts parameter bounds from `P::continuous_bounds()`
    /// 2. Generates initial samples via Latin Hypercube
    /// 3. Evaluates each sample by calling `model.train_with_params()`
    /// 4. Uses Nelder-Mead to optimize from best initial point
    /// 5. Performs multi-restart from other good points
    /// 6. Repeats until `max_trials` reached
    ///
    /// # Type Parameters
    ///
    /// - `M`: Model type implementing `HyperparameterOptimizable`
    /// - `P`: Parameter type implementing `ParameterSpace`
    ///
    /// # Arguments
    ///
    /// * `model` - Mutable reference to model (needed for training)
    ///
    /// # Returns
    ///
    /// `OptimizationResult<P>` containing best parameters and full trial history
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - Parameter space has zero dimensions
    /// - Initial sampling fails
    /// - All trials produce errors (no valid observations)
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use ml::hyperopt::{ArgminOptimizer, HyperparameterOptimizable};
    /// # use ml::hyperopt::adapters::mamba2::Mamba2Trainer;
    /// # fn example() -> anyhow::Result<()> {
    /// let mut trainer = Mamba2Trainer::new("data.parquet", 50)?;
    /// let optimizer = ArgminOptimizer::new();
    /// let result = optimizer.optimize(trainer)?;
    /// # Ok(())
    /// # }
    /// ```
    #[allow(clippy::unwrap_in_result)]
    #[allow(clippy::cognitive_complexity)]
    pub fn optimize<M>(&self, mut model: M) -> Result<OptimizationResult<M::Params>>
    where
        M: HyperparameterOptimizable + Send,
        M::Params: ParameterSpace + Send,
    {
        info!("╔═══════════════════════════════════════════════════════════╗");
        info!("║  Bayesian Hyperparameter Optimization (Argmin)           ║");
        info!("╚═══════════════════════════════════════════════════════════╝");

        // Detect hardware budget and extract VRAM-aware parameter space bounds
        let budget = crate::traits::HardwareBudget::detect();
        info!("Hardware budget: {}MB GPU memory", budget.gpu_memory_mb);
        let bounds = M::Params::continuous_bounds_for(&budget);
        let n_params = bounds.len();

        if n_params == 0 {
            return Err(MLError::ConfigError("Parameter space has zero dimensions".to_owned())
            .into());
        }

        info!("Configuration:");
        info!("  Max Trials: {}", self.max_trials);
        info!("  Initial Samples: {}", self.n_initial);
        info!("  Swarm Particles: {}", self.n_particles);
        info!("  Parameters: {}", n_params);
        info!("  Max Iters/Restart: {}", self.max_iters_per_restart);

        let param_names = M::Params::param_names();
        for (i, name) in param_names.iter().enumerate() {
            info!("    {} - [{:.6}, {:.6}]", name, bounds[i].0, bounds[i].1);
        }

        // Initialize RNG
        let mut rng = match self.seed {
            Some(seed) => StdRng::seed_from_u64(seed),
            None => StdRng::from_entropy(),
        };

        // Generate initial samples using Latin Hypercube
        info!(
            "Generating {} initial samples with Latin Hypercube Sampling...",
            self.n_initial
        );
        let initial_samples = Self::latin_hypercube_sampling(self.n_initial, &bounds, &mut rng);
        info!("✓ Generated {} initial samples", initial_samples.nrows());

        // Shared state for collecting trial results
        let trial_results = Arc::new(Mutex::new(Vec::new()));
        let trial_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));

        // Evaluate initial samples
        info!("Evaluating initial samples...");
        for i in 0..self.n_initial {
            let continuous_vec: Vec<f64> = initial_samples.row(i).to_vec();
            Self::evaluate_point(
                &continuous_vec,
                &mut model,
                &trial_results,
                &trial_counter,
                &param_names,
            )?;
            if let Some(ref name) = self.model_name {
                common::metrics::training_metrics::set_hyperopt_trial(
                    name,
                    (i + 1) as f64,
                    self.max_trials as f64,
                );
            }
        }

        // Create trial budget observer BEFORE creating cost function
        // This observer enforces strict trial limits (fixes PSO infinite iteration bug)
        let observer = crate::TrialBudgetObserver::new(self.max_trials);

        // Create cost function wrapper
        let optimization_start = Instant::now();
        let cost_fn = ObjectiveFunction {
            model: Arc::new(Mutex::new(model)),
            trial_results: Arc::clone(&trial_results),
            trial_counter: Arc::clone(&trial_counter),
            param_names: param_names.clone(),
            bounds: bounds.clone(),
            observer: observer.clone(),
            model_name: self.model_name.clone(),
            max_trials: self.max_trials,
            optimization_start,
        };

        // Find best initial point to start optimization
        let trials = trial_results.lock().map_err(|e| {
            anyhow::anyhow!("Failed to lock trial results: {}", e)
        })?.clone();
        let best_initial = trials
            .iter()
            .min_by(|a, b| {
                a.objective
                    .partial_cmp(&b.objective)
                    .unwrap_or(std::cmp::Ordering::Equal)
            })
            .ok_or_else(|| {
                anyhow::anyhow!("No initial trials completed successfully -- cannot start PSO")
            })?;

        info!("╔═══════════════════════════════════════════════════════════╗");
        info!("║  Starting Particle Swarm Optimization                    ║");
        info!("╚═══════════════════════════════════════════════════════════╝");
        info!("Best initial objective: {:.6}", best_initial.objective);
        info!("Execution mode: Sequential trials (model locked by Mutex, rayon for swarm only)");

        // Determine how many iterations we can afford
        let trials_used = trials.len();
        let remaining_trials = self.max_trials.saturating_sub(trials_used);

        // FIX: Each PSO iteration evaluates n_particles candidates (sequentially via mutex)
        // PSO evaluates ALL particles in swarm per iteration, so divide remaining budget
        // by swarm size to prevent trial count overflow (fixes 962 trial bug)
        // CRITICAL FIX (2025-11-07): Use CEILING division to ensure all trials complete
        // Example: 8 remaining ÷ 20 particles = 0.4 → ceil to 1 iteration (not 0)
        let max_iters_by_budget =
            ((remaining_trials as f64) / (self.n_particles as f64)).ceil() as usize;

        let max_iters = max_iters_by_budget.min(self.max_iters_per_restart);

        info!(
            "PSO Budget: {} iterations ({} remaining trials ÷ {} particles = {} max iters)",
            max_iters, remaining_trials, self.n_particles, max_iters_by_budget
        );

        if max_iters > 0 {
            // Create bounds vectors for ParticleSwarm
            let lower_bounds: Vec<f64> = bounds.iter().map(|(min, _)| *min).collect();
            let upper_bounds: Vec<f64> = bounds.iter().map(|(_, max)| *max).collect();

            // Create Particle Swarm solver
            let solver = ParticleSwarm::new((lower_bounds, upper_bounds), self.n_particles);

            // Run optimization (parallel execution enabled via rayon feature)
            // CRITICAL FIX (2025-11-03): Removed .target_cost(0.0) to prevent early termination
            // PSO must run for exactly max_iters iterations to complete all requested trials
            // CRITICAL FIX (2025-11-07): Added TrialBudgetObserver to enforce strict trial limits
            let res = Executor::new(cost_fn, solver)
                .configure(|state| state.max_iters(max_iters as u64))
                .add_observer(
                    observer,
                    argmin::core::observers::ObserverMode::Always,
                )
                .run();

            // Handle budget exhaustion gracefully (not an error condition)
            match res {
                Ok(result) => {
                    info!("Optimization complete:");
                    info!("  Final cost: {:.6}", result.state().get_best_cost());
                    info!("  Iterations: {}", result.state().get_iter());
                    info!("  Evaluations: {}", trial_counter.load(std::sync::atomic::Ordering::Relaxed));
                },
                Err(e) if e.to_string().contains("Trial budget exhausted") => {
                    info!(
                        "PSO terminated: trial budget reached ({} trials)",
                        self.max_trials
                    );
                    info!("  Evaluations: {}", trial_counter.load(std::sync::atomic::Ordering::Relaxed));
                },
                Err(e) => return Err(e),
            }
        } else {
            info!("No remaining budget for Particle Swarm optimization");
        }

        info!("╔═══════════════════════════════════════════════════════════╗");
        info!("║  Optimization Complete                                    ║");
        info!("╚═══════════════════════════════════════════════════════════╝");

        // Extract final results
        let trials = match Arc::try_unwrap(trial_results) {
            Ok(mutex) => mutex.into_inner().map_err(|e| {
                anyhow::anyhow!("Failed to unwrap trial results mutex: {}", e)
            })?,
            Err(arc) => arc.lock().map_err(|e| {
                anyhow::anyhow!("Failed to lock trial results: {}", e)
            })?.clone(),
        };

        if trials.is_empty() {
            return Err(MLError::TrainingError(
                "All trials failed - no valid observations".to_owned(),
            )
            .into());
        }

        // Create optimization result
        let result = OptimizationResult::from_trials(trials);

        info!("Best Parameters Found:");
        let best_continuous = result.best_params.to_continuous();
        for (i, name) in param_names.iter().enumerate() {
            info!("  {}: {:.6}", name, best_continuous[i]);
        }
        info!("Best Objective: {:.6}", result.best_objective);
        info!("Total Improvement: {:.6}", result.total_improvement());
        info!("Improvement: {:.2}%", result.improvement_percentage());

        Ok(result)
    }

    /// Evaluate a single point in the parameter space.
    ///
    /// Converts `continuous_vec` to typed parameters, trains the model,
    /// records the trial result, and returns the objective value.
    #[allow(clippy::unwrap_in_result)]
    #[allow(clippy::cognitive_complexity)]
    pub(crate) fn evaluate_point<M>(
        continuous_vec: &[f64],
        model: &mut M,
        trial_results: &Arc<Mutex<Vec<TrialResult<M::Params>>>>,
        trial_counter: &Arc<std::sync::atomic::AtomicUsize>,
        _param_names: &[&'static str],
    ) -> Result<f64>
    where
        M: HyperparameterOptimizable,
        M::Params: ParameterSpace,
    {
        let start_time = Instant::now();

        // Increment trial counter
        let trial_num = trial_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;

        info!("╔═══════════════════════════════════════════════════════════╗");
        info!(
            "║  Trial {}: Evaluating Parameters                          ║",
            trial_num
        );
        info!("╚═══════════════════════════════════════════════════════════╝");

        // Convert continuous vector to parameters BEFORE logging
        let params =
            M::Params::from_continuous(continuous_vec).context("Failed to convert parameters")?;

        // Log CONVERTED parameters (shows actual values: learning_rate ~1e-4, not -11)
        info!("  Parameters (converted): {:?}", params);

        // Train model with parameters — NaN/divergence is a valid PSO outcome,
        // not a fatal error. Score failed trials with maximum penalty so PSO
        // learns to avoid that region of the search space.
        let (objective, trial_metrics) = match model.train_with_params(params.clone()) {
            Ok(metrics) => {
                let raw_objective = M::extract_objective(&metrics);
                let obj = if raw_objective.is_finite() {
                    raw_objective
                } else {
                    warn!("Trial {} produced non-finite objective ({:?}), using penalty 1e6", trial_num, raw_objective);
                    1e6
                };
                (obj, M::extract_metrics(&metrics))
            }
            Err(e) => {
                warn!("Trial {} training failed (scored as penalty): {:#}", trial_num, e);
                (1e6, None)
            }
        };
        let duration_secs = start_time.elapsed().as_secs_f64();

        info!("✓ Trial {} completed in {:.1}s", trial_num, duration_secs);
        info!("  Objective: {:.6}", objective);

        // Record trial result
        {
            let mut results = trial_results.lock().map_err(|e| {
                anyhow::anyhow!("Failed to lock trial results: {}", e)
            })?;
            results.push(TrialResult {
                trial_num,
                params,
                objective,
                duration_secs,
                metrics: trial_metrics,
            });
        }

        Ok(objective)
    }

    /// Run two-phase optimization: episode reward then Sharpe ratio.
    ///
    /// Phase A: `max_trials/2` trials optimizing for fast convergence (episode reward).
    /// Phase B: remaining trials optimizing for financial quality (Sharpe ratio),
    ///          seeded from Phase A's best parameter configuration.
    ///
    /// This reuses the existing `optimize()` method for each phase. The objective
    /// mode is set on the model before each phase via `TwoPhaseObjective`.
    ///
    /// # Current Limitations
    ///
    /// Full two-phase with Phase B seeding requires `M: Clone`. Currently runs
    /// Phase A only and returns its result. Phase B will be enabled once the
    /// model types implement `Clone`.
    #[allow(clippy::cognitive_complexity, clippy::similar_names)]
    pub fn optimize_two_phase<M>(&self, mut model: M) -> Result<OptimizationResult<M::Params>>
    where
        M: HyperparameterOptimizable + TwoPhaseObjective + Send,
        M::Params: ParameterSpace + Send,
    {
        let phase_a_trials = self.max_trials / 2;
        let phase_b_trials = self.max_trials - phase_a_trials;

        info!("======= Two-Phase Optimization =======");
        info!("Phase A: {} trials optimizing episode reward", phase_a_trials);
        info!("Phase B: {} trials optimizing Sharpe ratio (pending Clone support)", phase_b_trials);

        // Phase A: Episode Reward
        model.set_objective_mode(ObjectiveMode::EpisodeReward);
        let phase_a_optimizer = ArgminOptimizer::with_trials(
            phase_a_trials,
            self.n_initial.min(phase_a_trials - 1).max(1),
        );
        let phase_a_result = phase_a_optimizer.optimize(model)?;

        info!(
            "Phase A complete: best_objective={:.6}, evaluated {} trials",
            phase_a_result.best_objective,
            phase_a_result.all_trials.len()
        );

        // TODO: Phase B requires M: Clone to reconstruct model from Phase A result.
        // Once DQNTrainer implements Clone, Phase B will:
        // 1. Extract the model back (currently consumed by optimize())
        // 2. Set objective mode to Sharpe
        // 3. Seed Phase B with top-3 parameter configs from Phase A
        // 4. Run remaining trials with Sharpe-based objective

        info!(
            "======= Two-Phase Complete: best_objective={:.6} =======",
            phase_a_result.best_objective
        );

        Ok(phase_a_result)
    }

    /// Run optimization with parallel trial evaluation.
    ///
    /// Requires `M: Clone` so each evaluation thread gets its own model instance.
    /// Parallelism is controlled by `RAYON_NUM_THREADS` (for PSO phase) and
    /// internal thread scoping (for LHS phase, capped at `n_initial` threads).
    ///
    /// DQN/PPO trials are CPU-bound (~1 core each, 278MB VRAM). On an 8-vCPU L4
    /// with 24GB VRAM, 4-6 concurrent trials safely fit. This gives ~4-5x speedup
    /// over sequential execution.
    #[allow(clippy::unwrap_in_result)]
    #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
    pub fn optimize_parallel<M>(&self, model: M) -> Result<OptimizationResult<M::Params>>
    where
        M: HyperparameterOptimizable + Clone + Send,
        M::Params: ParameterSpace + Send,
    {
        info!("╔═══════════════════════════════════════════════════════════╗");
        info!("║  Parallel Hyperparameter Optimization (Argmin + Rayon)   ║");
        info!("╚═══════════════════════════════════════════════════════════╝");

        let rayon_threads = rayon::current_num_threads();
        info!("Parallel execution: {} rayon threads", rayon_threads);

        // Detect hardware budget and extract VRAM-aware parameter space bounds
        let budget = crate::traits::HardwareBudget::detect();
        info!("Hardware budget: {}MB GPU memory", budget.gpu_memory_mb);

        // Compute VRAM-aware concurrency strategy
        let strategy = budget.plan_hyperopt(
            M::Params::model_overhead_mb(),
            M::Params::per_sample_mb(),
            64.0,
            4096.0,
        );
        info!("HyperoptStrategy: {} concurrent trials, batch bounds [{}, {}], {:.0}MB/trial, {:.0}MB total",
            strategy.max_concurrent_trials,
            strategy.batch_size_bounds.0, strategy.batch_size_bounds.1,
            strategy.per_trial_memory_mb, strategy.total_reserved_mb);

        // Auto-scale PSO particles to match GPU concurrency for maximum
        // hardware utilization. Each PSO iteration evaluates all particles,
        // so matching particle count to VRAM slots fills every GPU slot per
        // iteration. Cap at max_trials — no benefit from more particles than
        // the total trial budget. On CPU (max_concurrent = 1), fall back to
        // configured n_particles for sequential PSO exploration.
        let effective_particles = if strategy.max_concurrent_trials > 1 {
            let scaled = strategy.max_concurrent_trials.min(self.max_trials);
            if scaled != self.n_particles {
                info!("Auto-scaled PSO particles: {} -> {} (GPU fits {} concurrent, budget {})",
                    self.n_particles, scaled, strategy.max_concurrent_trials, self.max_trials);
            }
            scaled
        } else {
            self.n_particles.min(self.max_trials)
        };

        // Trial budget is user-configured — never auto-inflate.
        let effective_max_trials = self.max_trials;

        let bounds = M::Params::continuous_bounds_for(&budget);
        let n_params = bounds.len();

        if n_params == 0 {
            return Err(MLError::ConfigError("Parameter space has zero dimensions".to_owned())
            .into());
        }

        info!("Configuration:");
        info!("  Max Trials: {} (configured: {})", effective_max_trials, self.max_trials);
        info!("  Initial Samples: {}", self.n_initial);
        info!("  Swarm Particles: {} (configured: {})", effective_particles, self.n_particles);
        info!("  Parameters: {}", n_params);
        info!("  Max Iters/Restart: {}", self.max_iters_per_restart);
        info!("  Parallel Threads: {}", rayon_threads);

        let param_names = M::Params::param_names();
        for (i, name) in param_names.iter().enumerate() {
            info!("    {} - [{:.6}, {:.6}]", name, bounds[i].0, bounds[i].1);
        }

        // Initialize RNG
        let mut rng = match self.seed {
            Some(seed) => StdRng::seed_from_u64(seed),
            None => StdRng::from_entropy(),
        };

        // Generate initial samples using Latin Hypercube
        info!(
            "Generating {} initial samples with Latin Hypercube Sampling...",
            self.n_initial
        );
        let initial_samples = Self::latin_hypercube_sampling(self.n_initial, &bounds, &mut rng);
        info!("Generated {} initial samples", initial_samples.nrows());

        // Shared state for collecting trial results
        let trial_results = Arc::new(Mutex::new(Vec::new()));
        let trial_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));

        // OOM Layer 2: Start at ceil(max/2), ramp up after first success
        let initial_concurrency = strategy.max_concurrent_trials.div_ceil(2).max(1);
        let concurrency = std::sync::atomic::AtomicUsize::new(initial_concurrency);

        info!(
            "Evaluating {} initial samples with ramp-up concurrency (start: {}, max: {})...",
            self.n_initial, initial_concurrency, strategy.max_concurrent_trials
        );

        let initial_vecs: Vec<Vec<f64>> = (0..self.n_initial)
            .map(|i| initial_samples.row(i).to_vec())
            .collect();

        for chunk in initial_vecs.chunks(initial_concurrency) {
            // OOM Layer 4: VRAM watchdog — check free memory before spawning
            if let Ok((_total_mb, free_mb, _name)) = ml_core::memory_optimization::auto_batch_size::detect_gpu_memory() {
                let total_mb = budget.gpu_memory_mb as f64;
                if total_mb > 0.0 && free_mb / total_mb < 0.10 {
                    warn!("VRAM watchdog: only {:.0}MB free ({:.0}%), reducing concurrency",
                        free_mb, (free_mb / total_mb) * 100.0);
                    let current = concurrency.load(std::sync::atomic::Ordering::Relaxed);
                    concurrency.store((current / 2).max(1), std::sync::atomic::Ordering::Relaxed);
                }
            }

            let lhs_errors: Vec<Option<anyhow::Error>> = std::thread::scope(|scope| {
                let handles: Vec<_> = chunk.iter().map(|continuous_vec| {
                    let mut model_clone = model.clone();
                    let results = Arc::clone(&trial_results);
                    let counter = Arc::clone(&trial_counter);
                    let names = param_names.clone();
                    let vec = continuous_vec.clone();
                    scope.spawn(move || {
                        Self::evaluate_point(&vec, &mut model_clone, &results, &counter, &names).err()
                    })
                }).collect();
                handles.into_iter().map(|h| h.join().unwrap_or(None)).collect()
            });

            for err in lhs_errors.into_iter().flatten() {
                warn!("LHS evaluation error (continuing): {}", err);
            }

            // OOM Layer 2: Ramp up after first chunk succeeds
            let current = concurrency.load(std::sync::atomic::Ordering::Relaxed);
            if current < strategy.max_concurrent_trials {
                info!("Ramp-up: {} -> {} concurrent trials (first chunk succeeded)",
                    current, strategy.max_concurrent_trials);
                concurrency.store(strategy.max_concurrent_trials, std::sync::atomic::Ordering::Relaxed);
            }

            if let Some(ref name) = self.model_name {
                let done = trial_counter.load(std::sync::atomic::Ordering::Relaxed);
                common::metrics::training_metrics::set_hyperopt_trial(
                    name,
                    done as f64,
                    self.max_trials as f64,
                );
            }
        }

        // Create trial budget observer BEFORE creating cost function
        let observer = crate::TrialBudgetObserver::new(effective_max_trials);

        // Create parallel cost function — clones model per evaluation instead of holding mutex
        let cost_fn = ParallelObjectiveFunction {
            model: Arc::new(Mutex::new(model)),
            trial_results: Arc::clone(&trial_results),
            trial_counter: Arc::clone(&trial_counter),
            param_names: param_names.clone(),
            bounds: bounds.clone(),
            observer: observer.clone(),
            model_name: self.model_name.clone(),
            max_trials: self.max_trials,
            optimization_start: Instant::now(),
        };

        // Find best initial point
        let trials = trial_results.lock().map_err(|e| {
            anyhow::anyhow!("Failed to lock trial results: {}", e)
        })?.clone();
        let best_initial = trials
            .iter()
            .min_by(|a, b| {
                a.objective
                    .partial_cmp(&b.objective)
                    .unwrap_or(std::cmp::Ordering::Equal)
            })
            .ok_or_else(|| {
                anyhow::anyhow!("No initial trials completed successfully -- cannot start PSO")
            })?;

        info!("╔═══════════════════════════════════════════════════════════╗");
        info!("║  Starting Parallel Particle Swarm Optimization           ║");
        info!("╚═══════════════════════════════════════════════════════════╝");
        info!("Best initial objective: {:.6}", best_initial.objective);
        let final_concurrency = concurrency.load(std::sync::atomic::Ordering::Relaxed);
        info!("Execution mode: Parallel trials (clone-per-particle, {} VRAM-concurrent, {} rayon threads)",
            final_concurrency, rayon_threads);
        if rayon_threads > strategy.max_concurrent_trials {
            warn!("Rayon threads ({}) exceed VRAM concurrency ({}). Consider capping rayon pool at startup.",
                rayon_threads, strategy.max_concurrent_trials);
        }

        // Determine PSO iterations
        let trials_used = trials.len();
        let remaining_trials = effective_max_trials.saturating_sub(trials_used);
        let max_iters_by_budget =
            ((remaining_trials as f64) / (effective_particles as f64)).ceil() as usize;
        let max_iters = max_iters_by_budget.min(self.max_iters_per_restart);

        info!(
            "PSO Budget: {} iterations ({} remaining trials / {} particles = {} max iters)",
            max_iters, remaining_trials, effective_particles, max_iters_by_budget
        );

        if max_iters > 0 {
            let lower_bounds: Vec<f64> = bounds.iter().map(|(min, _)| *min).collect();
            let upper_bounds: Vec<f64> = bounds.iter().map(|(_, max)| *max).collect();

            let solver = ParticleSwarm::new((lower_bounds, upper_bounds), effective_particles);

            let res = Executor::new(cost_fn, solver)
                .configure(|state| state.max_iters(max_iters as u64))
                .add_observer(
                    observer,
                    argmin::core::observers::ObserverMode::Always,
                )
                .run();

            match res {
                Ok(result) => {
                    info!("Optimization complete:");
                    info!("  Final cost: {:.6}", result.state().get_best_cost());
                    info!("  Iterations: {}", result.state().get_iter());
                    info!("  Evaluations: {}", trial_counter.load(std::sync::atomic::Ordering::Relaxed));
                },
                Err(e) if e.to_string().contains("Trial budget exhausted") => {
                    info!(
                        "PSO terminated: trial budget reached ({} trials, configured {})",
                        effective_max_trials, self.max_trials
                    );
                    info!("  Evaluations: {}", trial_counter.load(std::sync::atomic::Ordering::Relaxed));
                },
                Err(e) => return Err(e),
            }
        } else {
            info!("No remaining budget for Particle Swarm optimization");
        }

        info!("╔═══════════════════════════════════════════════════════════╗");
        info!("║  Optimization Complete                                    ║");
        info!("╚═══════════════════════════════════════════════════════════╝");

        // Extract final results
        let trials = match Arc::try_unwrap(trial_results) {
            Ok(mutex) => mutex.into_inner().map_err(|e| {
                anyhow::anyhow!("Failed to unwrap trial results mutex: {}", e)
            })?,
            Err(arc) => arc.lock().map_err(|e| {
                anyhow::anyhow!("Failed to lock trial results: {}", e)
            })?.clone(),
        };

        if trials.is_empty() {
            return Err(MLError::TrainingError(
                "All trials failed - no valid observations".to_owned(),
            )
            .into());
        }

        let result = OptimizationResult::from_trials(trials);

        info!("Best Parameters Found:");
        let best_continuous = result.best_params.to_continuous();
        for (i, name) in param_names.iter().enumerate() {
            info!("  {}: {:.6}", name, best_continuous[i]);
        }
        info!("Best Objective: {:.6}", result.best_objective);
        info!("Total Improvement: {:.6}", result.total_improvement());
        info!("Improvement: {:.2}%", result.improvement_percentage());

        Ok(result)
    }
}

/// Run optimization using Tree-Parzen Estimator (TPE) instead of PSO.
///
/// TPE builds separate density models for "good" and "bad" trials,
/// then suggests new points by maximizing Expected Improvement.
/// Better sample efficiency than PSO in 20-50D spaces.
///
/// # Arguments
///
/// * `model` - Model implementing `HyperparameterOptimizable` (consumed)
/// * `max_trials` - Total number of trials to evaluate
/// * `n_initial` - Number of initial LHS samples before TPE kicks in
/// * `seed` - Optional random seed for reproducibility
///
/// # Returns
///
/// `OptimizationResult<M::Params>` containing best parameters and full trial history
#[allow(clippy::cognitive_complexity)]
pub fn optimize_with_tpe<M>(
    mut model: M,
    max_trials: usize,
    n_initial: usize,
    seed: Option<u64>,
    model_name: Option<&str>,
) -> Result<OptimizationResult<M::Params>>
where
    M: HyperparameterOptimizable + Send,
    M::Params: ParameterSpace + Send,
{
    use crate::tpe::{TpeConfig, TpeOptimizer};

    info!("╔═══════════════════════════════════════════════════════════╗");
    info!("║  Bayesian Hyperparameter Optimization (TPE)              ║");
    info!("╚═══════════════════════════════════════════════════════════╝");

    let budget = crate::traits::HardwareBudget::detect();
    let bounds = M::Params::continuous_bounds_for(&budget);
    let n_params = bounds.len();

    if n_params == 0 {
        return Err(MLError::ConfigError("Parameter space has zero dimensions".to_owned())
        .into());
    }

    // Clamp n_initial to valid range: at least 1, at most max_trials - 1
    let n_initial = n_initial.max(1).min(max_trials.saturating_sub(1).max(1));

    info!("Configuration:");
    info!("  Optimizer: TPE (Tree-Parzen Estimator)");
    info!("  Max Trials: {}", max_trials);
    info!("  Initial LHS Samples: {}", n_initial);
    info!("  Parameters: {}", n_params);
    let n_candidates = 256_usize.max(8 * n_params);
    let gamma = if max_trials < 50 { 0.15 } else { 0.25 };
    info!("  Gamma (good quantile): {:.2}", gamma);
    info!("  EI Candidates: {} (scaled for {}D)", n_candidates, n_params);

    let param_names = M::Params::param_names();
    for (i, name) in param_names.iter().enumerate() {
        if let Some(&(lo, hi)) = bounds.get(i) {
            info!("    {} - [{:.6}, {:.6}]", name, lo, hi);
        }
    }

    let tpe_config = TpeConfig {
        n_dims: n_params,
        max_trials,
        n_initial,
        gamma,
        n_candidates,
        seed,
    };
    let mut tpe = TpeOptimizer::new(tpe_config);

    let trial_results = Arc::new(Mutex::new(Vec::new()));
    let trial_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let tpe_start = Instant::now();

    for trial_idx in 0..max_trials {
        let continuous_vec = tpe.suggest(&bounds);

        info!(
            "TPE Trial {}/{}: evaluating suggested point",
            trial_idx + 1,
            max_trials
        );

        ArgminOptimizer::evaluate_point(
            &continuous_vec,
            &mut model,
            &trial_results,
            &trial_counter,
            &param_names,
        )?;

        // Feed the objective back to TPE so it can update its density models
        let results = trial_results.lock().map_err(|e| {
            MLError::ConcurrencyError {
                operation: format!("lock trial results: {}", e),
            }
        })?;
        if let Some(last) = results.last() {
            tpe.add_trial(continuous_vec, last.objective);
            info!(
                "TPE Trial {}/{}: objective = {:.6}",
                trial_idx + 1,
                max_trials,
                last.objective
            );
        }

        if let Some(name) = model_name {
            common::metrics::training_metrics::set_hyperopt_trial(
                name,
                (trial_idx + 1) as f64,
                max_trials as f64,
            );
            common::metrics::training_metrics::set_hyperopt_elapsed(
                name,
                tpe_start.elapsed().as_secs_f64(),
            );
            if let Some(last) = results.last() {
                common::metrics::training_metrics::record_hyperopt_trial_duration(
                    name,
                    last.duration_secs,
                );
            }
            if let Some(best) = results.iter().map(|r| r.objective).reduce(f64::min) {
                common::metrics::training_metrics::set_hyperopt_best_objective(name, best);
                common::metrics::training_metrics::set_hyperopt_trial_best_loss(name, best);
            }
        }
    }

    // Extract results
    let trials = match Arc::try_unwrap(trial_results) {
        Ok(mutex) => mutex.into_inner().map_err(|e| {
            anyhow::anyhow!("Failed to unwrap trial results mutex: {}", e)
        })?,
        Err(arc) => arc.lock().map_err(|e| {
            anyhow::anyhow!("Failed to lock trial results: {}", e)
        })?.clone(),
    };

    if trials.is_empty() {
        return Err(
            MLError::ModelError("No valid trials completed".to_owned()).into(),
        );
    }

    let result = OptimizationResult::from_trials(trials);

    info!("═══ TPE Optimization Complete ═══");
    info!(
        "  Best trial: objective {:.6}",
        result.best_objective
    );
    info!("  Total trials: {}", result.all_trials.len());

    Ok(result)
}

/// Trait for models supporting two-phase objective switching.
///
/// Used by [`ArgminOptimizer::optimize_two_phase()`] to switch between
/// episode reward (fast convergence) and Sharpe ratio (financial quality).
///
/// This trait is intentionally separate from [`HyperparameterOptimizable`]
/// because `extract_objective` is a static method that cannot access instance
/// state. Two-phase optimization instead sets the objective mode on the model
/// instance before each phase.
pub trait TwoPhaseObjective {
    /// Set the objective mode for the current optimization phase.
    fn set_objective_mode(&mut self, mode: ObjectiveMode);
    /// Get the current objective mode.
    fn objective_mode(&self) -> ObjectiveMode;
}

/// Cost function wrapper for argmin
struct ObjectiveFunction<M>
where
    M: HyperparameterOptimizable,
    M::Params: ParameterSpace,
{
    model: Arc<Mutex<M>>,
    trial_results: Arc<Mutex<Vec<TrialResult<M::Params>>>>,
    trial_counter: Arc<std::sync::atomic::AtomicUsize>,
    param_names: Vec<&'static str>,
    bounds: Vec<(f64, f64)>,
    observer: crate::TrialBudgetObserver,
    model_name: Option<String>,
    max_trials: usize,
    optimization_start: Instant,
}

impl<M> CostFunction for ObjectiveFunction<M>
where
    M: HyperparameterOptimizable,
    M::Params: ParameterSpace,
{
    type Param = Vec<f64>;
    type Output = f64;

    #[allow(clippy::cognitive_complexity)]
    fn cost(&self, param: &Self::Param) -> Result<Self::Output, argmin::core::Error> {
        // Increment observer trial count FIRST
        self.observer.increment_trial();

        // Check if budget exhausted (prevents runaway PSO)
        if self.observer.should_terminate() {
            warn!(
                "Trial budget exhausted: {}/{} trials. Stopping PSO.",
                self.observer.get_trials_used(),
                self.observer.get_trials_used()
            );
            return Err(argmin::core::Error::msg("Trial budget exhausted"));
        }

        // Clamp parameters to bounds
        let mut clamped = param.clone();
        for (i, (min, max)) in self.bounds.iter().enumerate() {
            clamped[i] = clamped[i].clamp(*min, *max);
        }

        let start_time = Instant::now();

        // Increment trial counter
        let trial_num = self.trial_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;

        info!("╔═══════════════════════════════════════════════════════════╗");
        info!(
            "║  Trial {}: Evaluating Parameters                          ║",
            trial_num
        );
        info!("╚═══════════════════════════════════════════════════════════╝");

        // Convert continuous vector to parameters BEFORE logging
        let params = match M::Params::from_continuous(&clamped) {
            Ok(p) => p,
            Err(e) => {
                warn!(
                    "Failed to convert parameters for trial {}: {}",
                    trial_num, e
                );
                return Ok(1e6); // Penalty for invalid parameters
            },
        };

        // Log CONVERTED parameters (shows actual values: learning_rate ~1e-4, not -11)
        info!("  Parameters (converted): {:?}", params);

        // Train model with parameters
        let mut model = self.model.lock().map_err(|e| {
            argmin::core::Error::msg(format!("Failed to lock model: {}", e))
        })?;
        let metrics = match model.train_with_params(params.clone()) {
            Ok(m) => m,
            Err(e) => {
                warn!("Training failed for trial {}: {}", trial_num, e);
                return Ok(1e6); // Penalty for training failure
            },
        };

        // Extract objective (replace NaN/Inf with large penalty)
        let raw_objective = M::extract_objective(&metrics);
        let objective = if raw_objective.is_finite() {
            raw_objective
        } else {
            warn!("Trial {} produced non-finite objective ({:?}), using penalty 1e6", trial_num, raw_objective);
            1e6
        };
        let trial_metrics = M::extract_metrics(&metrics);
        let duration_secs = start_time.elapsed().as_secs_f64();

        info!("✓ Trial {} completed in {:.1}s", trial_num, duration_secs);
        info!("  Objective: {:.6}", objective);

        // Record trial result
        {
            let mut results = self.trial_results.lock().map_err(|e| {
                argmin::core::Error::msg(format!("Failed to lock trial results: {}", e))
            })?;
            results.push(TrialResult {
                trial_num,
                params,
                objective,
                duration_secs,
                metrics: trial_metrics,
            });
        }

        if let Some(ref name) = self.model_name {
            common::metrics::training_metrics::set_hyperopt_trial(
                name,
                trial_num as f64,
                self.max_trials as f64,
            );
            common::metrics::training_metrics::record_hyperopt_trial_duration(name, duration_secs);
            common::metrics::training_metrics::set_hyperopt_elapsed(
                name,
                self.optimization_start.elapsed().as_secs_f64(),
            );
            // Update running best objective
            if let Ok(results) = self.trial_results.lock() {
                if let Some(best) = results.iter().map(|r| r.objective).reduce(f64::min) {
                    common::metrics::training_metrics::set_hyperopt_best_objective(name, best);
                    common::metrics::training_metrics::set_hyperopt_trial_best_loss(name, best);
                }
            }
        }

        Ok(objective)
    }
}

/// Parallel cost function for argmin — clones model per evaluation.
///
/// Unlike [`ObjectiveFunction`] which holds an `Arc<Mutex<M>>` lock for the
/// entire training duration, this variant clones the model and releases the
/// lock immediately. This allows argmin's rayon-based PSO to evaluate multiple
/// particles truly in parallel.
///
/// Each clone gets its own `InternalDQNTrainer` (created inside `train_with_params`),
/// so GPU/CPU resources are the only shared concern. DQN uses ~1 core + 278MB VRAM
/// per trial, allowing 4-6 concurrent trials on an 8-vCPU L4 with 24GB VRAM.
struct ParallelObjectiveFunction<M>
where
    M: HyperparameterOptimizable + Clone,
    M::Params: ParameterSpace,
{
    model: Arc<Mutex<M>>,
    trial_results: Arc<Mutex<Vec<TrialResult<M::Params>>>>,
    trial_counter: Arc<std::sync::atomic::AtomicUsize>,
    param_names: Vec<&'static str>,
    bounds: Vec<(f64, f64)>,
    observer: crate::TrialBudgetObserver,
    model_name: Option<String>,
    max_trials: usize,
    optimization_start: Instant,
}

impl<M> CostFunction for ParallelObjectiveFunction<M>
where
    M: HyperparameterOptimizable + Clone,
    M::Params: ParameterSpace,
{
    type Param = Vec<f64>;
    type Output = f64;

    #[allow(clippy::cognitive_complexity)]
    fn cost(&self, param: &Self::Param) -> Result<Self::Output, argmin::core::Error> {
        // Increment observer trial count FIRST
        self.observer.increment_trial();

        // Check if budget exhausted
        if self.observer.should_terminate() {
            warn!(
                "Trial budget exhausted: {} trials. Stopping PSO.",
                self.observer.get_trials_used()
            );
            return Err(argmin::core::Error::msg("Trial budget exhausted"));
        }

        // Clone model and release mutex immediately — enables true parallel evaluation
        let mut model = {
            let guard = self.model.lock().map_err(|e| {
                argmin::core::Error::msg(format!("Failed to lock model: {}", e))
            })?;
            guard.clone()
        }; // Mutex released here — other particles can proceed concurrently

        // Clamp parameters to bounds
        let mut clamped = param.clone();
        for (i, (min, max)) in self.bounds.iter().enumerate() {
            clamped[i] = clamped[i].clamp(*min, *max);
        }

        let start_time = Instant::now();

        let trial_num = self.trial_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;

        info!("╔═══════════════════════════════════════════════════════════╗");
        info!(
            "║  Trial {}: Evaluating Parameters (parallel)               ║",
            trial_num
        );
        info!("╚═══════════════════════════════════════════════════════════╝");

        let params = match M::Params::from_continuous(&clamped) {
            Ok(p) => p,
            Err(e) => {
                warn!("Failed to convert parameters for trial {}: {}", trial_num, e);
                return Ok(1e6);
            },
        };

        info!("  Parameters (converted): {:?}", params);

        let metrics = match model.train_with_params(params.clone()) {
            Ok(m) => m,
            Err(e) => {
                warn!("Training failed for trial {}: {}", trial_num, e);
                return Ok(1e6);
            },
        };

        let raw_objective = M::extract_objective(&metrics);
        let objective = if raw_objective.is_finite() {
            raw_objective
        } else {
            warn!("Trial {} produced non-finite objective ({:?}), using penalty 1e6", trial_num, raw_objective);
            1e6
        };
        let trial_metrics = M::extract_metrics(&metrics);
        let duration_secs = start_time.elapsed().as_secs_f64();

        info!("Trial {} completed in {:.1}s", trial_num, duration_secs);
        info!("  Objective: {:.6}", objective);

        {
            let mut results = self.trial_results.lock().map_err(|e| {
                argmin::core::Error::msg(format!("Failed to lock trial results: {}", e))
            })?;
            results.push(TrialResult {
                trial_num,
                params,
                objective,
                duration_secs,
                metrics: trial_metrics,
            });
        }

        if let Some(ref name) = self.model_name {
            common::metrics::training_metrics::set_hyperopt_trial(
                name,
                trial_num as f64,
                self.max_trials as f64,
            );
            common::metrics::training_metrics::record_hyperopt_trial_duration(name, duration_secs);
            common::metrics::training_metrics::set_hyperopt_elapsed(
                name,
                self.optimization_start.elapsed().as_secs_f64(),
            );
            if let Ok(results) = self.trial_results.lock() {
                if let Some(best) = results.iter().map(|r| r.objective).reduce(f64::min) {
                    common::metrics::training_metrics::set_hyperopt_best_objective(name, best);
                    common::metrics::training_metrics::set_hyperopt_trial_best_loss(name, best);
                }
            }
        }

        Ok(objective)
    }
}

/// Builder for `ArgminOptimizer` with fluent API
///
/// Provides a convenient way to configure optimizer settings.
///
/// # Example
///
/// ```rust
/// use ml::hyperopt::ArgminOptimizer;
///
/// let optimizer = ArgminOptimizer::builder()
///     .max_trials(50)
///     .n_initial(10)
///     .n_particles(30)
///     .seed(12345)
///     .build();
/// ```
#[derive(Debug, Clone)]
pub struct ArgminOptimizerBuilder {
    max_trials: usize,
    n_initial: usize,
    n_particles: usize,
    seed: Option<u64>,
    max_iters_per_restart: usize,
    model_name: Option<String>,
}

impl Default for ArgminOptimizerBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl ArgminOptimizerBuilder {
    /// Create a new builder with default settings
    pub fn new() -> Self {
        Self {
            max_trials: 30,
            n_initial: 5,
            n_particles: 20,
            seed: None,
            max_iters_per_restart: 50,
            model_name: None,
        }
    }

    /// Set maximum number of trials
    pub fn max_trials(mut self, max_trials: usize) -> Self {
        self.max_trials = max_trials;
        self
    }

    /// Set number of initial samples
    pub fn n_initial(mut self, n_initial: usize) -> Self {
        self.n_initial = n_initial;
        self
    }

    /// Set number of particles in the swarm
    pub fn n_particles(mut self, n_particles: usize) -> Self {
        self.n_particles = n_particles;
        self
    }

    /// Set random seed for reproducibility
    pub fn seed(mut self, seed: u64) -> Self {
        self.seed = Some(seed);
        self
    }

    /// Set maximum iterations per restart
    pub fn max_iters_per_restart(mut self, max_iters: usize) -> Self {
        self.max_iters_per_restart = max_iters;
        self
    }

    /// Set model name for per-trial Prometheus progress metrics
    pub fn model_name<S: Into<String>>(mut self, name: S) -> Self {
        self.model_name = Some(name.into());
        self
    }

    /// Build the optimizer
    ///
    /// # Panics
    ///
    /// Panics if `max_trials <= n_initial` or if either is zero.
    pub fn build(self) -> ArgminOptimizer {
        assert!(
            self.max_trials > self.n_initial,
            "max_trials must be > n_initial"
        );
        assert!(self.n_initial > 0, "n_initial must be > 0");
        assert!(self.n_particles > 0, "n_particles must be > 0");

        ArgminOptimizer {
            max_trials: self.max_trials,
            n_initial: self.n_initial,
            n_particles: self.n_particles,
            seed: self.seed,
            max_iters_per_restart: self.max_iters_per_restart,
            model_name: self.model_name,
        }
    }
}

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

    #[derive(Debug, Clone)]
    struct TestParams {
        x: f64,
        y: f64,
    }

    impl ParameterSpace for TestParams {
        fn continuous_bounds() -> Vec<(f64, f64)> {
            vec![(-5.0, 5.0), (-5.0, 5.0)]
        }

        fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
            Ok(Self { x: x[0], y: x[1] })
        }

        fn to_continuous(&self) -> Vec<f64> {
            vec![self.x, self.y]
        }

        fn param_names() -> Vec<&'static str> {
            vec!["x", "y"]
        }
    }

    #[derive(Debug, Clone)]
    struct TestMetrics {
        loss: f64,
    }

    struct TestModel;

    impl HyperparameterOptimizable for TestModel {
        type Params = TestParams;
        type Metrics = TestMetrics;

        fn train_with_params(&mut self, params: Self::Params) -> Result<Self::Metrics, MLError> {
            // Rosenbrock function: (1-x)^2 + 100(y-x^2)^2
            // Minimum at (1, 1) with value 0
            let loss = (1.0 - params.x).powi(2) + 100.0 * (params.y - params.x.powi(2)).powi(2);
            Ok(TestMetrics { loss })
        }

        fn extract_objective(metrics: &Self::Metrics) -> f64 {
            metrics.loss
        }
    }

    #[test]
    fn test_optimizer_builder() {
        let optimizer = ArgminOptimizer::builder()
            .max_trials(20)
            .n_initial(3)
            .seed(42)
            .build();

        assert_eq!(optimizer.max_trials, 20);
        assert_eq!(optimizer.n_initial, 3);
        assert_eq!(optimizer.seed, Some(42));
    }

    #[test]
    fn test_latin_hypercube_sampling() {
        let mut rng = StdRng::seed_from_u64(42);
        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
        let samples = ArgminOptimizer::latin_hypercube_sampling(10, &bounds, &mut rng);

        assert_eq!(samples.nrows(), 10);
        assert_eq!(samples.ncols(), 2);

        // Check all samples are within bounds
        for i in 0..10 {
            assert!(samples[[i, 0]] >= -5.0 && samples[[i, 0]] <= 5.0);
            assert!(samples[[i, 1]] >= -5.0 && samples[[i, 1]] <= 5.0);
        }
    }

    #[test]
    #[ignore] // Expensive test - run manually
    fn test_optimizer_rosenbrock() {
        let model = TestModel;
        let optimizer = ArgminOptimizer::with_trials(15, 3);

        let result = optimizer.optimize(model).unwrap();

        // Should find minimum near (1, 1)
        assert!(
            result.best_objective < 1.0,
            "Expected to find near-optimal solution"
        );
        assert!(
            result.all_trials.len() <= 15,
            "Should have at most 15 trials"
        );
    }

    #[test]
    fn test_parameter_conversion_with_log_scale() {
        // Test that log-scale parameters are converted correctly
        #[derive(Debug, Clone, PartialEq)]
        struct LogScaleParams {
            learning_rate: f64,
            weight_decay: f64,
            batch_size: usize,
        }

        impl ParameterSpace for LogScaleParams {
            fn continuous_bounds() -> Vec<(f64, f64)> {
                vec![
                    (1e-5_f64.ln(), 1e-2_f64.ln()), // learning_rate (log scale)
                    (1e-6_f64.ln(), 1e-2_f64.ln()), // weight_decay (log scale)
                    (16.0, 256.0),                  // batch_size (linear)
                ]
            }

            fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
                Ok(Self {
                    learning_rate: x[0].exp(),
                    weight_decay: x[1].exp(),
                    batch_size: x[2].round() as usize,
                })
            }

            fn to_continuous(&self) -> Vec<f64> {
                vec![
                    self.learning_rate.ln(),
                    self.weight_decay.ln(),
                    self.batch_size as f64,
                ]
            }

            fn param_names() -> Vec<&'static str> {
                vec!["learning_rate", "weight_decay", "batch_size"]
            }
        }

        // Test continuous space values (negative for log-scale)
        let continuous = vec![-9.21, -11.51, 64.0]; // ln(1e-4), ln(1e-5), 64

        // Convert to actual parameters
        let params = LogScaleParams::from_continuous(&continuous).unwrap();

        // Verify conversions (use relative tolerance for floating point)
        // Allow 1% relative error due to log/exp floating point precision
        assert!(
            (params.learning_rate - 1e-4).abs() / 1e-4 < 0.01,
            "Learning rate should be ~1e-4 (±1%), got {}",
            params.learning_rate
        );
        assert!(
            (params.weight_decay - 1e-5).abs() / 1e-5 < 0.01,
            "Weight decay should be ~1e-5 (±1%), got {}",
            params.weight_decay
        );
        assert_eq!(params.batch_size, 64, "Batch size should be 64");

        // Verify round-trip
        let recovered = params.to_continuous();
        assert!((recovered[0] - continuous[0]).abs() < 1e-6);
        assert!((recovered[1] - continuous[1]).abs() < 1e-6);
        assert!((recovered[2] - continuous[2]).abs() < 1e-6);
    }

    #[test]
    fn test_parameter_logging_shows_converted_values() {
        // This test verifies that parameters are logged AFTER conversion,
        // so Debug output shows actual values (learning_rate ~1e-4, not -11)

        #[derive(Debug, Clone)]
        struct LogParams {
            value: f64,
        }

        impl ParameterSpace for LogParams {
            fn continuous_bounds() -> Vec<(f64, f64)> {
                vec![(1e-5_f64.ln(), 1e-2_f64.ln())]
            }

            fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
                Ok(Self { value: x[0].exp() })
            }

            fn to_continuous(&self) -> Vec<f64> {
                vec![self.value.ln()]
            }

            fn param_names() -> Vec<&'static str> {
                vec!["value"]
            }
        }

        // Raw continuous value (log scale)
        let continuous = vec![-11.51]; // ln(1e-5)

        // Convert to actual parameter
        let params = LogParams::from_continuous(&continuous).unwrap();

        // Debug output should show converted value (in scientific notation or decimal)
        let debug_str = format!("{:?}", params);

        // Should show value close to 1e-5 (not the log value -11.5)
        assert!(
            debug_str.contains("e-5") || debug_str.contains("0.00001"),
            "Debug output should show actual value ~1e-5, got: {}",
            debug_str
        );

        // Should NOT contain raw log value
        assert!(
            !debug_str.contains("-11."),
            "Debug output should NOT show raw log value -11.x"
        );
    }

    #[test]
    fn test_two_phase_optimizer_config() {
        let optimizer = ArgminOptimizer::with_trials(30, 5);
        assert_eq!(optimizer.max_trials, 30);
        // Two-phase would run 15 + 15 trials
        let phase_a = optimizer.max_trials / 2;
        let phase_b = optimizer.max_trials - phase_a;
        assert_eq!(phase_a, 15);
        assert_eq!(phase_b, 15);
    }

    #[test]
    fn test_two_phase_optimizer_odd_trials() {
        let optimizer = ArgminOptimizer::with_trials(31, 5);
        // Odd total: 15 + 16 trials
        let phase_a = optimizer.max_trials / 2;
        let phase_b = optimizer.max_trials - phase_a;
        assert_eq!(phase_a, 15);
        assert_eq!(phase_b, 16);
    }

    #[test]
    fn test_two_phase_objective_trait_object_safety() {
        // Verify TwoPhaseObjective can be used with the ObjectiveMode enum
        use super::TwoPhaseObjective;

        struct MockModel {
            mode: ObjectiveMode,
        }

        impl TwoPhaseObjective for MockModel {
            fn set_objective_mode(&mut self, mode: ObjectiveMode) {
                self.mode = mode;
            }
            fn objective_mode(&self) -> ObjectiveMode {
                self.mode
            }
        }

        let mut model = MockModel {
            mode: ObjectiveMode::Sharpe,
        };
        assert_eq!(model.objective_mode(), ObjectiveMode::Sharpe);

        model.set_objective_mode(ObjectiveMode::EpisodeReward);
        assert_eq!(model.objective_mode(), ObjectiveMode::EpisodeReward);
    }
}
