//! Solver trait abstraction and strategy dispatch for thermodynamic system solving.
//!
//! This module defines the core solver interface used by all solver strategies in
//! the Entropyk solver engine. The design uses two complementary patterns:
//!
//! 1. **`Solver` trait** — object-safe interface for custom/user-provided solvers
//!    (`Box<dyn Solver>`).
//! 2. **`SolverStrategy` enum** — zero-cost static dispatch for built-in strategies
//!    (Newton-Raphson, Sequential Substitution), avoiding vtable overhead.
//!
//! # Convergence Criteria
//!
//! A solver is considered converged when the residual norm satisfies:
//!
//! $$\| \mathbf{r}(\mathbf{x}) \|_2 < \varepsilon$$
//!
//! where $\varepsilon$ is the configured tolerance (default: $10^{-6}$).
//!
//! # Newton-Raphson Method
//!
//! The Newton-Raphson solver iterates:
//!
//! $$\mathbf{x}_{k+1} = \mathbf{x}_k - \alpha \mathbf{J}^{-1}(\mathbf{x}_k)\,\mathbf{r}(\mathbf{x}_k)$$
//!
//! where $\mathbf{J}$ is the Jacobian matrix and $\alpha$ is the step length
//! (1.0 for full Newton step, reduced by line search if enabled).
//!
//! # Example
//!
//! ```rust
//! use entropyk_solver::solver::{Solver, SolverStrategy, NewtonConfig};
//! use std::time::Duration;
//!
//! let solver = SolverStrategy::default()
//!     .with_timeout(Duration::from_millis(500));
//! ```

use std::time::{Duration, Instant};
use thiserror::Error;

use crate::criteria::{ConvergenceCriteria, ConvergenceReport};
use crate::jacobian::JacobianMatrix;
use crate::system::System;
use entropyk_components::JacobianBuilder;

// ─────────────────────────────────────────────────────────────────────────────
// Error types
// ─────────────────────────────────────────────────────────────────────────────

/// Errors that can occur during solving.
///
/// All variants carry enough context for the caller to diagnose the failure
/// and decide on a recovery strategy (e.g. retry with a different solver,
/// return best-known state, or propagate to the user).
#[derive(Error, Debug, Clone, PartialEq)]
pub enum SolverError {
    /// The solver exhausted its iteration budget without converging.
    ///
    /// The `final_residual` is the $\ell_2$ norm of the residual vector at the
    /// last iteration. Use this to assess how close the solver got.
    #[error(
        "Solver did not converge after {iterations} iterations \
         (final residual norm: {final_residual:.3e})"
    )]
    NonConvergence {
        /// Number of iterations performed before giving up.
        iterations: usize,
        /// $\ell_2$ norm of the residual vector at the last iteration.
        final_residual: f64,
    },

    /// The solver exceeded its configured time budget.
    ///
    /// The `timeout_ms` field records the budget that was exceeded.
    /// Story 4.5 (Time-Budgeted Solving) will return the best-known state
    /// alongside this error.
    #[error("Solver timed out after {timeout_ms} ms")]
    Timeout {
        /// The configured timeout in milliseconds.
        timeout_ms: u64,
    },

    /// The iteration sequence diverged (residual norm is growing unboundedly).
    ///
    /// This typically indicates a poor initial guess or an ill-conditioned system.
    #[error("Solver diverged: {reason}")]
    Divergence {
        /// Human-readable description of why divergence was detected.
        reason: String,
    },

    /// The system description is invalid and cannot be solved.
    ///
    /// Examples: empty system, singular Jacobian at the initial point,
    /// inconsistent constraints.
    #[error("Invalid system: {message}")]
    InvalidSystem {
        /// Human-readable description of the system defect.
        message: String,
    },

    /// Post-solve validation failed (e.g., mass or energy balance violation).
    #[error("Validation failed: mass error = {mass_error:.3e} kg/s, energy error = {energy_error:.3e} W")]
    Validation {
        /// Mass balance error in kg/s
        mass_error: f64,
        /// Energy balance error in W
        energy_error: f64,
    },
}

// ─────────────────────────────────────────────────────────────────────────────
// Result types
// ─────────────────────────────────────────────────────────────────────────────

/// Convergence status of a completed solve.
#[derive(Debug, Clone, PartialEq)]
pub enum ConvergenceStatus {
    /// The solver converged within tolerance and iteration budget.
    Converged,
    /// The solver stopped due to timeout but returned the best-known state.
    /// (Used by Story 4.5 — Time-Budgeted Solving.)
    TimedOutWithBestState,
    /// The solver converged, but one or more control variables saturated at bounds.
    /// (Used by Story 5.2 — Bounded Control Variables)
    ControlSaturation,
}

/// The result of a successful (or best-effort) solve.
///
/// Contains the final state vector and convergence metadata.
#[derive(Debug, Clone)]
pub struct ConvergedState {
    /// Final state vector $\mathbf{x}^*$ at convergence.
    ///
    /// Layout mirrors `System::state_vector_len()`:
    /// `[P_edge0, h_edge0, P_edge1, h_edge1, ...]`.
    pub state: Vec<f64>,

    /// Number of iterations performed.
    pub iterations: usize,

    /// $\ell_2$ norm of the residual vector at the final state.
    pub final_residual: f64,

    /// Whether the solver fully converged or returned a best-effort state.
    pub status: ConvergenceStatus,

    /// Optional multi-circuit convergence report (Story 4.7).
    ///
    /// `Some(report)` when [`ConvergenceCriteria`] was set on the solver.
    /// `None` when using the raw tolerance check (backward-compatible default).
    pub convergence_report: Option<ConvergenceReport>,
}

impl ConvergedState {
    /// Creates a new `ConvergedState` without a convergence report (backward-compat).
    pub fn new(
        state: Vec<f64>,
        iterations: usize,
        final_residual: f64,
        status: ConvergenceStatus,
    ) -> Self {
        Self {
            state,
            iterations,
            final_residual,
            status,
            convergence_report: None,
        }
    }

    /// Creates a `ConvergedState` with an attached convergence report.
    pub fn with_report(
        state: Vec<f64>,
        iterations: usize,
        final_residual: f64,
        status: ConvergenceStatus,
        report: ConvergenceReport,
    ) -> Self {
        Self {
            state,
            iterations,
            final_residual,
            status,
            convergence_report: Some(report),
        }
    }

    /// Returns `true` if the solver fully converged (not just best-effort).
    pub fn is_converged(&self) -> bool {
        matches!(
            self.status,
            ConvergenceStatus::Converged | ConvergenceStatus::ControlSaturation
        )
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Solver trait
// ─────────────────────────────────────────────────────────────────────────────

/// Object-safe interface for all solver strategies.
///
/// Implementors must be able to solve a [`System`] of equations and optionally
/// respect a time budget. The trait is intentionally minimal and object-safe so
/// that user-provided custom solvers can be used via `Box<dyn Solver>`.
///
/// # Object Safety
///
/// This trait is object-safe:
/// - No generic methods.
/// - No `Self` in return types.
/// - `with_timeout` takes `self` by value — **not** object-safe as written.
///
/// > **Note:** `with_timeout` is excluded from the object-safe vtable. Use the
/// > concrete type (e.g. `NewtonConfig`) or `SolverStrategy` to configure
/// > timeouts, then box the result.
///
/// # Example
///
/// ```rust,no_run
/// use entropyk_solver::solver::Solver;
/// use entropyk_solver::system::System;
///
/// fn run(solver: &mut dyn Solver, system: &mut System) {
///     match solver.solve(system) {
///         Ok(state) => println!("Converged in {} iters", state.iterations),
///         Err(e) => eprintln!("Solver failed: {}", e),
///     }
/// }
/// ```
pub trait Solver {
    /// Solve the system of equations.
    ///
    /// Mutates `system` state in-place during iteration. On success, returns a
    /// [`ConvergedState`] with the final state vector and convergence metadata.
    ///
    /// # Errors
    ///
    /// - [`SolverError::NonConvergence`] — max iterations exceeded.
    /// - [`SolverError::Timeout`] — time budget exceeded.
    /// - [`SolverError::Divergence`] — residual norm growing unboundedly.
    /// - [`SolverError::InvalidSystem`] — system is ill-formed.
    fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError>;

    /// Configure a time budget for this solver (builder pattern).
    ///
    /// If the solver exceeds `timeout`, it stops and returns
    /// [`SolverError::Timeout`]. Actual enforcement is implemented in
    /// Stories 4.2 / 4.3; this method stores the configuration.
    ///
    /// # Note on Object Safety
    ///
    /// This method is bounded by `where Self: Sized` and is therefore excluded
    /// from the vtable. It **cannot** be called through a `dyn Solver` reference.
    /// Configure the timeout on the concrete type before boxing.
    fn with_timeout(self, timeout: Duration) -> Self
    where
        Self: Sized;
}

// ─────────────────────────────────────────────────────────────────────────────
// Timeout Configuration (Story 4.5)
// ─────────────────────────────────────────────────────────────────────────────

/// Configuration for timeout behavior in solvers.
///
/// This struct controls how solvers handle timeout conditions, enabling
/// graceful degradation for HIL (Hardware-in-the-Loop) scenarios where
/// real-time constraints must never be violated.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::TimeoutConfig;
///
/// // Default: return best state on timeout (graceful degradation)
/// let config = TimeoutConfig::default();
/// assert!(config.return_best_state_on_timeout);
/// assert!(!config.zoh_fallback);
///
/// // HIL scenario with Zero-Order Hold fallback
/// let hil_config = TimeoutConfig {
///     zoh_fallback: true,
///     ..Default::default()
/// };
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct TimeoutConfig {
    /// Return best-known state on timeout instead of error.
    ///
    /// When `true` (default), the solver returns `Ok(ConvergedState)` with
    /// `status: TimedOutWithBestState` on timeout, containing the best state
    /// encountered during iteration (lowest residual norm).
    ///
    /// When `false`, the solver returns `Err(SolverError::Timeout)` on timeout.
    pub return_best_state_on_timeout: bool,

    /// On timeout, return previous state (ZOH) instead of current best.
    ///
    /// Zero-Order Hold (ZOH) fallback is useful for HIL scenarios where the
    /// previous known-good state should be used when a new solution cannot be
    /// found within the time budget.
    ///
    /// Requires `previous_state` to be set before solving.
    /// Default: `false`.
    pub zoh_fallback: bool,
}

impl Default for TimeoutConfig {
    fn default() -> Self {
        Self {
            return_best_state_on_timeout: true,
            zoh_fallback: false,
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Jacobian Freezing Configuration (Story 4.8)
// ─────────────────────────────────────────────────────────────────────────────

/// Configuration for Jacobian-freezing optimization.
///
/// When enabled, the Newton-Raphson solver reuses the previously computed
/// Jacobian matrix for up to `max_frozen_iters` consecutive iterations,
/// provided the residual norm is still decreasing. This avoids expensive
/// Jacobian assembly and can reduce per-iteration CPU time by up to ~80%.
///
/// # Auto-disable on divergence
///
/// If the residual norm *increases* while a frozen Jacobian is being used,
/// the solver immediately forces a fresh Jacobian computation on the next
/// iteration and resets the frozen-iteration counter.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{NewtonConfig, JacobianFreezingConfig};
///
/// let config = NewtonConfig::default()
///     .with_jacobian_freezing(JacobianFreezingConfig {
///         max_frozen_iters: 3,
///         threshold: 0.1,
///     });
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct JacobianFreezingConfig {
    /// Maximum number of consecutive iterations the Jacobian may be reused
    /// without recomputing.
    ///
    /// After this many frozen iterations the solver forces a fresh assembly,
    /// even if the residual is still decreasing.  Default: 3.
    pub max_frozen_iters: usize,

    /// Residual-norm ratio threshold below which freezing is considered safe.
    ///
    /// Freezing is only attempted when
    /// `current_norm / previous_norm < (1.0 - threshold)`,
    /// ensuring that convergence is still progressing sufficiently.
    /// Default: 0.1 (i.e., at least a 10 % residual decrease per step).
    pub threshold: f64,
}

impl Default for JacobianFreezingConfig {
    fn default() -> Self {
        Self {
            max_frozen_iters: 3,
            threshold: 0.1,
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Configuration structs
// ─────────────────────────────────────────────────────────────────────────────

/// Configuration for the Newton-Raphson solver.
///
/// Newton-Raphson solves $\mathbf{F}(\mathbf{x}) = \mathbf{0}$ by iterating:
///
/// $$\mathbf{x}_{k+1} = \mathbf{x}_k - \alpha \mathbf{J}^{-1}(\mathbf{x}_k)\,\mathbf{r}(\mathbf{x}_k)$$
///
/// where $\mathbf{J}$ is the Jacobian matrix and $\alpha$ is the step length
/// (1.0 for full Newton step, reduced by line search if enabled).
/// Quadratic convergence near the solution makes this the preferred strategy
/// for well-conditioned systems.
#[derive(Debug, Clone, PartialEq)]
pub struct NewtonConfig {
    /// Maximum number of Newton iterations before declaring non-convergence.
    ///
    /// Default: 100.
    pub max_iterations: usize,

    /// Convergence tolerance: solver stops when $\|\mathbf{r}\|_2 < \text{tolerance}$.
    ///
    /// Default: $10^{-6}$.
    pub tolerance: f64,

    /// Enable Armijo line-search to improve convergence for non-linear systems.
    ///
    /// When `true`, the step length $\alpha$ is reduced until sufficient decrease
    /// is achieved. Default: `false` (full Newton step).
    pub line_search: bool,

    /// Optional time budget. If `Some(d)`, the solver stops after `d` has elapsed.
    ///
    /// Enforcement is implemented in Story 4.2.
    pub timeout: Option<Duration>,

    /// Use numerical Jacobian (finite differences) instead of analytical.
    ///
    /// When `true`, the Jacobian is computed via finite differences.
    /// When `false` (default), the analytical Jacobian from components is used.
    pub use_numerical_jacobian: bool,

    /// Armijo condition constant for line search.
    ///
    /// The line search accepts step length $\alpha$ if:
    /// $\|\mathbf{r}(\mathbf{x} + \alpha \Delta \mathbf{x})\| \leq \|\mathbf{r}(\mathbf{x})\| + c \cdot \alpha \cdot \nabla r \cdot \Delta x$
    ///
    /// Default: $10^{-4}$.
    pub line_search_armijo_c: f64,

    /// Maximum number of backtracking iterations in line search.
    ///
    /// Default: 20.
    pub line_search_max_backtracks: usize,

    /// Residual norm threshold for divergence detection.
    ///
    /// If the residual norm exceeds this value, the solver returns `Divergence`.
    /// Default: $10^{10}$.
    pub divergence_threshold: f64,

    /// Timeout behavior configuration (Story 4.5).
    ///
    /// Controls whether the solver returns best state on timeout or an error.
    /// Default: `TimeoutConfig::default()` (return best state on timeout).
    pub timeout_config: TimeoutConfig,

    /// Previous state for Zero-Order Hold (ZOH) fallback (Story 4.5).
    ///
    /// When `zoh_fallback` is enabled in `timeout_config` and the solver times out,
    /// this previous state is returned instead of the current best state.
    /// This is useful for HIL scenarios where the last known-good state should be used.
    pub previous_state: Option<Vec<f64>>,

    /// Residual norm associated with `previous_state` for ZOH fallback (Story 4.5).
    ///
    /// When using ZOH fallback, this residual is returned instead of `best_residual`,
    /// ensuring the returned state and residual are consistent.
    /// Should be set alongside `previous_state` by the HIL controller.
    pub previous_residual: Option<f64>,

    /// Smart initial state for cold-start solving (Story 4.6).
    ///
    /// When `Some`, the solver starts from this state instead of the zero vector.
    /// Use [`SmartInitializer::populate_state`] to generate a physically reasonable
    /// initial guess from source and sink temperatures.
    ///
    /// The length must match `system.state_vector_len()`. A length mismatch triggers
    /// a `debug_assert` in debug builds and silently falls back to zeros in release.
    pub initial_state: Option<Vec<f64>>,

    /// Multi-circuit convergence criteria (Story 4.7).
    ///
    /// When `Some`, the solver uses [`ConvergenceCriteria::check()`] for the convergence
    /// test instead of the raw L2-norm tolerance check. The old `tolerance` field is retained
    /// for backward compatibility and is ignored when this is `Some`.
    pub convergence_criteria: Option<ConvergenceCriteria>,

    /// Jacobian-freezing optimization (Story 4.8).
    ///
    /// When `Some`, the solver reuses the previous Jacobian matrix for up to
    /// `max_frozen_iters` iterations while the residual is decreasing faster than
    /// the configured threshold.  Auto-disables when the residual increases.
    ///
    /// Default: `None` (recompute every iteration — backward-compatible).
    pub jacobian_freezing: Option<JacobianFreezingConfig>,
}

impl Default for NewtonConfig {
    fn default() -> Self {
        Self {
            max_iterations: 100,
            tolerance: 1e-6,
            line_search: false,
            timeout: None,
            use_numerical_jacobian: false,
            line_search_armijo_c: 1e-4,
            line_search_max_backtracks: 20,
            divergence_threshold: 1e10,
            timeout_config: TimeoutConfig::default(),
            previous_state: None,
            previous_residual: None,
            initial_state: None,
            convergence_criteria: None,
            jacobian_freezing: None,
        }
    }
}

impl NewtonConfig {
    /// Sets the initial state for cold-start solving (Story 4.6 — builder pattern).
    ///
    /// The solver will start from `state` instead of the zero vector.
    /// Use [`SmartInitializer::populate_state`] to generate a physically reasonable
    /// initial guess.
    pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
        self.initial_state = Some(state);
        self
    }

    /// Sets multi-circuit convergence criteria (Story 4.7 — builder pattern).
    ///
    /// When set, the solver uses [`ConvergenceCriteria::check()`] instead of the
    /// raw L2-norm `tolerance` check. The `tolerance` field is retained for
    /// backward compatibility and is ignored when this is `Some`.
    pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
        self.convergence_criteria = Some(criteria);
        self
    }

    /// Enables Jacobian-freezing optimization (Story 4.8 — builder pattern).
    ///
    /// When set, the solver skips Jacobian re-assembly for iterations where the
    /// residual is still decreasing, up to `config.max_frozen_iters` consecutive
    /// frozen steps. Freezing is automatically disabled when the residual
    /// increases.
    pub fn with_jacobian_freezing(mut self, config: JacobianFreezingConfig) -> Self {
        self.jacobian_freezing = Some(config);
        self
    }

    /// Computes the residual norm (L2 norm of the residual vector).
    fn residual_norm(residuals: &[f64]) -> f64 {
        residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
    }

    /// Handles timeout based on configuration (Story 4.5).
    ///
    /// Returns either:
    /// - `Ok(ConvergedState)` with `TimedOutWithBestState` status (default)
    /// - `Err(SolverError::Timeout)` if `return_best_state_on_timeout` is false
    /// - Previous state (ZOH) if `zoh_fallback` is true and previous state available
    fn handle_timeout(
        &self,
        best_state: &[f64],
        best_residual: f64,
        iterations: usize,
        timeout: Duration,
    ) -> Result<ConvergedState, SolverError> {
        // If configured to return error on timeout
        if !self.timeout_config.return_best_state_on_timeout {
            return Err(SolverError::Timeout {
                timeout_ms: timeout.as_millis() as u64,
            });
        }

        // If ZOH fallback is enabled and previous state is available
        if self.timeout_config.zoh_fallback {
            if let Some(ref prev_state) = self.previous_state {
                let residual = self.previous_residual.unwrap_or(best_residual);
                tracing::info!(
                    iterations = iterations,
                    residual = residual,
                    "Returning previous state (ZOH fallback) on timeout"
                );
                return Ok(ConvergedState::new(
                    prev_state.clone(),
                    iterations,
                    residual,
                    ConvergenceStatus::TimedOutWithBestState,
                ));
            }
        }

        // Default: return best state encountered during iteration
        tracing::info!(
            iterations = iterations,
            best_residual = best_residual,
            "Returning best state on timeout"
        );
        Ok(ConvergedState::new(
            best_state.to_vec(),
            iterations,
            best_residual,
            ConvergenceStatus::TimedOutWithBestState,
        ))
    }

    /// Checks for divergence based on residual growth pattern.
    ///
    /// Returns `Some(SolverError::Divergence)` if:
    /// - Residual norm exceeds `divergence_threshold`, or
    /// - Residual has increased for 3+ consecutive iterations
    fn check_divergence(
        &self,
        current_norm: f64,
        previous_norm: f64,
        divergence_count: &mut usize,
    ) -> Option<SolverError> {
        // Check absolute threshold
        if current_norm > self.divergence_threshold {
            return Some(SolverError::Divergence {
                reason: format!(
                    "Residual norm {} exceeds threshold {}",
                    current_norm, self.divergence_threshold
                ),
            });
        }

        // Check consecutive increases
        if current_norm > previous_norm {
            *divergence_count += 1;
            if *divergence_count >= 3 {
                return Some(SolverError::Divergence {
                    reason: format!(
                        "Residual increased for 3 consecutive iterations: {:.6e} → {:.6e}",
                        previous_norm, current_norm
                    ),
                });
            }
        } else {
            *divergence_count = 0;
        }

        None
    }

    /// Performs Armijo line search to find an appropriate step length.
    ///
    /// Starting from $\alpha = 1.0$, backtracks by factor 0.5 until the Armijo
    /// condition is satisfied or maximum backtracks reached.
    ///
    /// Returns `Some(alpha)` if a valid step length is found, `None` otherwise.
    ///
    /// # Pre-allocated Buffers
    ///
    /// This method requires pre-allocated buffers to avoid heap allocation in the
    /// hot path. `state_copy` and `new_residuals` must have appropriate lengths.
    #[allow(clippy::too_many_arguments)]
    fn line_search(
        &self,
        system: &System,
        state: &mut Vec<f64>,
        delta: &[f64],
        _residuals: &[f64],
        current_norm: f64,
        state_copy: &mut [f64],
        new_residuals: &mut Vec<f64>,
        clipping_mask: &[Option<(f64, f64)>],
    ) -> Option<f64> {
        let mut alpha: f64 = 1.0;
        state_copy.copy_from_slice(state);

        // Approximate gradient dot delta: since delta = -J^{-1} r, we have ∇r·Δx ≈ -‖r‖
        let gradient_dot_delta = -current_norm;

        for _backtrack in 0..self.line_search_max_backtracks {
            // Apply step: x = x + alpha * delta
            apply_newton_step(state, delta, clipping_mask, alpha);

            // Compute new residuals (uses pre-allocated buffer)
            if system.compute_residuals(state, new_residuals).is_err() {
                // Restore state and try smaller alpha
                state.copy_from_slice(state_copy);
                alpha *= 0.5;
                continue;
            }

            let new_norm = Self::residual_norm(new_residuals);

            // Check Armijo condition: f(x + αΔx) ≤ f(x) + c·α·∇f·Δx
            // For residual norm: ‖r(x + αΔx)‖ ≤ ‖r(x)‖ + c·α·(∇r·Δx)
            if new_norm <= current_norm + self.line_search_armijo_c * alpha * gradient_dot_delta {
                tracing::debug!(
                    alpha = alpha,
                    old_norm = current_norm,
                    new_norm = new_norm,
                    "Line search accepted"
                );
                return Some(alpha);
            }

            // Restore state and try smaller alpha
            state.copy_from_slice(state_copy);
            alpha *= 0.5;
        }

        tracing::warn!(
            "Line search failed to find valid step length after {} backtracks",
            self.line_search_max_backtracks
        );
        None
    }
}

/// Applies a Newton step to the state vector, clamping bounded variables.
///
/// Update formula: x_new = clamp(x_old + alpha * delta)
fn apply_newton_step(
    state: &mut [f64],
    delta: &[f64],
    clipping_mask: &[Option<(f64, f64)>],
    alpha: f64,
) {
    for (i, s) in state.iter_mut().enumerate() {
        let proposed = *s + alpha * delta[i];
        *s = match &clipping_mask[i] {
            Some((min, max)) => proposed.clamp(*min, *max),
            None => proposed,
        };
    }
}

impl Solver for NewtonConfig {
    fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
        let start_time = Instant::now();

        tracing::info!(
            max_iterations = self.max_iterations,
            tolerance = self.tolerance,
            line_search = self.line_search,
            use_numerical_jacobian = self.use_numerical_jacobian,
            return_best_state_on_timeout = self.timeout_config.return_best_state_on_timeout,
            zoh_fallback = self.timeout_config.zoh_fallback,
            "Newton-Raphson solver starting"
        );

        // Get system dimensions
        let n_state = system.full_state_vector_len();
        let n_equations: usize = system
            .traverse_for_jacobian()
            .map(|(_, c, _)| c.n_equations())
            .sum::<usize>()
            + system.constraints().count()
            + system.coupling_residual_count();

        // Validate system
        if n_state == 0 || n_equations == 0 {
            return Err(SolverError::InvalidSystem {
                message: "Empty system has no state variables or equations".to_string(),
            });
        }

        // Pre-allocate all buffers (AC: #5 - no heap allocation in iteration loop)
        // Story 4.6 - AC: #8: Use initial_state if provided, else start from zeros
        let mut state: Vec<f64> = self
            .initial_state
            .as_ref()
            .map(|s| {
                debug_assert_eq!(
                    s.len(),
                    n_state,
                    "initial_state length mismatch: expected {}, got {}",
                    n_state,
                    s.len()
                );
                if s.len() == n_state {
                    s.clone()
                } else {
                    vec![0.0; n_state]
                }
            })
            .unwrap_or_else(|| vec![0.0; n_state]);
        let mut residuals: Vec<f64> = vec![0.0; n_equations];
        let mut jacobian_builder = JacobianBuilder::new();
        let mut divergence_count: usize = 0;
        let mut previous_norm: f64;
        let mut state_copy: Vec<f64> = vec![0.0; n_state]; // Pre-allocated for line search
        let mut new_residuals: Vec<f64> = vec![0.0; n_equations]; // Pre-allocated for line search
        let mut prev_iteration_state: Vec<f64> = vec![0.0; n_state]; // For convergence delta check

        // Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
        let mut best_state: Vec<f64> = vec![0.0; n_state];
        let mut best_residual: f64;

        // Story 4.8 — Jacobian-freezing tracking state.
        // `frozen_count` tracks how many consecutive iterations have reused the Jacobian.
        // `force_recompute` is set when a residual increase is detected.
        // The Jacobian matrix itself is pre-allocated here (Zero Allocation AC)
        let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
        let mut frozen_count: usize = 0;
        let mut force_recompute: bool = true; // Always compute on the very first iteration

        // Pre-compute clipping mask (Story 5.6)
        let clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
            .map(|i| system.get_bounds_for_state_index(i))
            .collect();

        // Initial residual computation
        system
            .compute_residuals(&state, &mut residuals)
            .map_err(|e| SolverError::InvalidSystem {
                message: format!("Failed to compute initial residuals: {:?}", e),
            })?;

        let mut current_norm = Self::residual_norm(&residuals);

        // Initialize best state tracking with initial state
        best_state.copy_from_slice(&state);
        best_residual = current_norm;

        tracing::debug!(iteration = 0, residual_norm = current_norm, "Initial state");

        // Check if already converged
        if current_norm < self.tolerance {
            let status = if !system.saturated_variables().is_empty() {
                ConvergenceStatus::ControlSaturation
            } else {
                ConvergenceStatus::Converged
            };

            // Criteria check with no prev_state (first call)
            if let Some(ref criteria) = self.convergence_criteria {
                let report = criteria.check(&state, None, &residuals, system);
                if report.is_globally_converged() {
                    tracing::info!(
                        iterations = 0,
                        final_residual = current_norm,
                        "System already converged at initial state (criteria)"
                    );
                    return Ok(ConvergedState::with_report(
                        state,
                        0,
                        current_norm,
                        status,
                        report,
                    ));
                }
            } else {
                tracing::info!(
                    iterations = 0,
                    final_residual = current_norm,
                    "System already converged at initial state"
                );
                return Ok(ConvergedState::new(state, 0, current_norm, status));
            }
        }

        // Main Newton-Raphson iteration loop
        for iteration in 1..=self.max_iterations {
            // Save state before step for convergence criteria delta checks
            prev_iteration_state.copy_from_slice(&state);

            // Check timeout at iteration start (Story 4.5 - AC: #1)
            if let Some(timeout) = self.timeout {
                if start_time.elapsed() > timeout {
                    tracing::info!(
                        iteration = iteration,
                        elapsed_ms = start_time.elapsed().as_millis(),
                        timeout_ms = timeout.as_millis(),
                        best_residual = best_residual,
                        "Solver timed out"
                    );

                    // Story 4.5 - AC: #2, #6: Return best state or error based on config
                    return self.handle_timeout(&best_state, best_residual, iteration - 1, timeout);
                }
            }

            // ── Jacobian Assembly / Freeze Decision (AC: #3, Story 4.8) ──
            //
            // Decide whether to recompute or reuse the Jacobian based on the
            // freezing configuration and convergence behaviour.
            let should_recompute = if let Some(ref freeze_cfg) = self.jacobian_freezing {
                if force_recompute {
                    true
                } else if frozen_count >= freeze_cfg.max_frozen_iters {
                    tracing::debug!(
                        iteration = iteration,
                        frozen_count = frozen_count,
                        "Jacobian freeze limit reached — recomputing"
                    );
                    true
                } else {
                    false
                }
            } else {
                // No freezing configured — always recompute (backward-compatible)
                true
            };

            if should_recompute {
                // Fresh Jacobian assembly (in-place update)
                jacobian_builder.clear();
                if self.use_numerical_jacobian {
                    // Numerical Jacobian via finite differences
                    let compute_residuals_fn = |s: &[f64], r: &mut [f64]| {
                        let s_vec = s.to_vec();
                        let mut r_vec = vec![0.0; r.len()];
                        let result = system.compute_residuals(&s_vec, &mut r_vec);
                        r.copy_from_slice(&r_vec);
                        result.map(|_| ()).map_err(|e| format!("{:?}", e))
                    };
                    // Rather than creating a new matrix, compute it and assign
                    let jm =
                        JacobianMatrix::numerical(compute_residuals_fn, &state, &residuals, 1e-8)
                            .map_err(|e| SolverError::InvalidSystem {
                            message: format!("Failed to compute numerical Jacobian: {}", e),
                        })?;
                    // Deep copy elements to existing matrix (DMatrix::copy_from does not reallocate)
                    jacobian_matrix.as_matrix_mut().copy_from(jm.as_matrix());
                } else {
                    // Analytical Jacobian from components
                    system
                        .assemble_jacobian(&state, &mut jacobian_builder)
                        .map_err(|e| SolverError::InvalidSystem {
                            message: format!("Failed to assemble Jacobian: {:?}", e),
                        })?;
                    jacobian_matrix.update_from_builder(jacobian_builder.entries());
                };

                frozen_count = 0;
                force_recompute = false;

                tracing::debug!(iteration = iteration, "Fresh Jacobian computed");
            } else {
                // Reuse the frozen Jacobian (Story 4.8 — AC: #2)
                frozen_count += 1;
                tracing::debug!(
                    iteration = iteration,
                    frozen_count = frozen_count,
                    "Reusing frozen Jacobian"
                );
            }

            // Solve linear system J·Δx = -r (AC: #1)
            let delta = match jacobian_matrix.solve(&residuals) {
                Some(d) => d,
                None => {
                    return Err(SolverError::Divergence {
                        reason: "Jacobian is singular - cannot solve linear system".to_string(),
                    });
                }
            };

            // Apply step with optional line search (AC: #2)
            let alpha = if self.line_search {
                match self.line_search(
                    system,
                    &mut state,
                    &delta,
                    &residuals,
                    current_norm,
                    &mut state_copy,
                    &mut new_residuals,
                    &clipping_mask,
                ) {
                    Some(a) => a,
                    None => {
                        return Err(SolverError::Divergence {
                            reason: "Line search failed to find valid step length".to_string(),
                        });
                    }
                }
            } else {
                // Full Newton step: x = x + delta (delta already includes negative sign)
                apply_newton_step(&mut state, &delta, &clipping_mask, 1.0);
                1.0
            };

            // Compute new residuals
            system
                .compute_residuals(&state, &mut residuals)
                .map_err(|e| SolverError::InvalidSystem {
                    message: format!("Failed to compute residuals: {:?}", e),
                })?;

            previous_norm = current_norm;
            current_norm = Self::residual_norm(&residuals);

            // Update best state if residual improved (Story 4.5 - AC: #2)
            if current_norm < best_residual {
                best_state.copy_from_slice(&state);
                best_residual = current_norm;
                tracing::debug!(
                    iteration = iteration,
                    best_residual = best_residual,
                    "Best state updated"
                );
            }

            // ── Story 4.8 — Jacobian-freeze feedback ──
            //
            // If the residual norm increased or did not decrease enough
            // (below the threshold), force a fresh Jacobian on the next
            // iteration and reset the frozen counter.
            if let Some(ref freeze_cfg) = self.jacobian_freezing {
                if previous_norm > 0.0
                    && current_norm / previous_norm >= (1.0 - freeze_cfg.threshold)
                {
                    if frozen_count > 0 || !force_recompute {
                        tracing::debug!(
                            iteration = iteration,
                            current_norm = current_norm,
                            previous_norm = previous_norm,
                            ratio = current_norm / previous_norm,
                            "Residual not decreasing fast enough — unfreezing Jacobian"
                        );
                    }
                    force_recompute = true;
                    frozen_count = 0;
                }
            }

            tracing::debug!(
                iteration = iteration,
                residual_norm = current_norm,
                alpha = alpha,
                "Newton iteration complete"
            );

            // Check convergence (AC: #1, Story 4.7 — criteria-aware)
            let converged = if let Some(ref criteria) = self.convergence_criteria {
                let report =
                    criteria.check(&state, Some(&prev_iteration_state), &residuals, system);
                if report.is_globally_converged() {
                    let status = if !system.saturated_variables().is_empty() {
                        ConvergenceStatus::ControlSaturation
                    } else {
                        ConvergenceStatus::Converged
                    };
                    tracing::info!(
                        iterations = iteration,
                        final_residual = current_norm,
                        "Newton-Raphson converged (criteria)"
                    );
                    return Ok(ConvergedState::with_report(
                        state,
                        iteration,
                        current_norm,
                        status,
                        report,
                    ));
                }
                false
            } else {
                current_norm < self.tolerance
            };

            if converged {
                let status = if !system.saturated_variables().is_empty() {
                    ConvergenceStatus::ControlSaturation
                } else {
                    ConvergenceStatus::Converged
                };
                tracing::info!(
                    iterations = iteration,
                    final_residual = current_norm,
                    "Newton-Raphson converged"
                );
                return Ok(ConvergedState::new(state, iteration, current_norm, status));
            }

            // Check divergence (AC: #5)
            if let Some(err) =
                self.check_divergence(current_norm, previous_norm, &mut divergence_count)
            {
                tracing::warn!(
                    iteration = iteration,
                    residual_norm = current_norm,
                    "Divergence detected"
                );
                return Err(err);
            }
        }

        // Max iterations exceeded
        tracing::warn!(
            max_iterations = self.max_iterations,
            final_residual = current_norm,
            "Newton-Raphson did not converge"
        );
        Err(SolverError::NonConvergence {
            iterations: self.max_iterations,
            final_residual: current_norm,
        })
    }

    fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }
}

/// Configuration for the Sequential Substitution (Picard iteration) solver.
///
/// Sequential Substitution solves $\mathbf{x} = \mathbf{G}(\mathbf{x})$ by iterating:
///
/// $$\mathbf{x}_{k+1} = (1 - \omega)\,\mathbf{x}_k + \omega\,\mathbf{G}(\mathbf{x}_k)$$
///
/// where $\omega \in (0, 1]$ is the relaxation factor. Slower than Newton-Raphson
/// but more robust for highly non-linear or poorly conditioned systems.
#[derive(Debug, Clone, PartialEq)]
pub struct PicardConfig {
    /// Maximum number of Picard iterations before declaring non-convergence.
    ///
    /// Default: 100.
    pub max_iterations: usize,

    /// Convergence tolerance: solver stops when $\|\mathbf{r}\|_2 < \text{tolerance}$.
    ///
    /// Default: $10^{-6}$.
    pub tolerance: f64,

    /// Relaxation factor $\omega \in (0, 1]$.
    ///
    /// Values close to 1.0 give faster convergence; values close to 0.0 give
    /// more stability. Default: 0.5.
    pub relaxation_factor: f64,

    /// Optional time budget. If `Some(d)`, the solver stops after `d` has elapsed.
    ///
    /// Enforcement is implemented in Story 4.3.
    pub timeout: Option<Duration>,

    /// Residual norm threshold for divergence detection.
    ///
    /// If the residual norm exceeds this value, the solver returns `Divergence`.
    /// Default: $10^{10}$.
    pub divergence_threshold: f64,

    /// Number of consecutive residual increases before declaring divergence.
    ///
    /// Picard is more tolerant than Newton (5 vs 3) because temporary increases
    /// can occur during convergence. Default: 5.
    pub divergence_patience: usize,

    /// Timeout behavior configuration (Story 4.5).
    ///
    /// Controls whether the solver returns best state on timeout or an error.
    /// Default: `TimeoutConfig::default()` (return best state on timeout).
    pub timeout_config: TimeoutConfig,

    /// Previous state for Zero-Order Hold (ZOH) fallback (Story 4.5).
    ///
    /// When `zoh_fallback` is enabled in `timeout_config` and the solver times out,
    /// this previous state is returned instead of the current best state.
    /// This is useful for HIL scenarios where the last known-good state should be used.
    pub previous_state: Option<Vec<f64>>,

    /// Residual norm associated with `previous_state` for ZOH fallback (Story 4.5).
    ///
    /// When using ZOH fallback, this residual is returned instead of `best_residual`,
    /// ensuring the returned state and residual are consistent.
    /// Should be set alongside `previous_state` by the HIL controller.
    pub previous_residual: Option<f64>,

    /// Smart initial state for cold-start solving (Story 4.6).
    ///
    /// When `Some`, the solver starts from this state instead of the zero vector.
    /// Use [`SmartInitializer::populate_state`] to generate a physically reasonable
    /// initial guess from source and sink temperatures.
    ///
    /// The length must match `system.state_vector_len()`. A length mismatch triggers
    /// a `debug_assert` in debug builds and silently falls back to zeros in release.
    pub initial_state: Option<Vec<f64>>,

    /// Multi-circuit convergence criteria (Story 4.7).
    ///
    /// When `Some`, the solver uses [`ConvergenceCriteria::check()`] instead of the
    /// raw L2-norm tolerance check. The old `tolerance` field is retained for
    /// backward compatibility.
    pub convergence_criteria: Option<ConvergenceCriteria>,
}

impl Default for PicardConfig {
    fn default() -> Self {
        Self {
            max_iterations: 100,
            tolerance: 1e-6,
            relaxation_factor: 0.5,
            timeout: None,
            divergence_threshold: 1e10,
            divergence_patience: 5,
            timeout_config: TimeoutConfig::default(),
            previous_state: None,
            previous_residual: None,
            initial_state: None,
            convergence_criteria: None,
        }
    }
}

impl PicardConfig {
    /// Sets the initial state for cold-start solving (Story 4.6 — builder pattern).
    ///
    /// The solver will start from `state` instead of the zero vector.
    /// Use [`SmartInitializer::populate_state`] to generate a physically reasonable
    /// initial guess.
    pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
        self.initial_state = Some(state);
        self
    }

    /// Sets multi-circuit convergence criteria (Story 4.7 — builder pattern).
    ///
    /// When set, the solver uses [`ConvergenceCriteria::check()`] instead of the
    /// raw L2-norm `tolerance` check.
    pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
        self.convergence_criteria = Some(criteria);
        self
    }

    /// Computes the residual norm (L2 norm of the residual vector).
    fn residual_norm(residuals: &[f64]) -> f64 {
        residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
    }

    /// Handles timeout based on configuration (Story 4.5).
    ///
    /// Returns either:
    /// - `Ok(ConvergedState)` with `TimedOutWithBestState` status (default)
    /// - `Err(SolverError::Timeout)` if `return_best_state_on_timeout` is false
    /// - Previous state (ZOH) if `zoh_fallback` is true and previous state available
    fn handle_timeout(
        &self,
        best_state: &[f64],
        best_residual: f64,
        iterations: usize,
        timeout: Duration,
    ) -> Result<ConvergedState, SolverError> {
        // If configured to return error on timeout
        if !self.timeout_config.return_best_state_on_timeout {
            return Err(SolverError::Timeout {
                timeout_ms: timeout.as_millis() as u64,
            });
        }

        // If ZOH fallback is enabled and previous state is available
        if self.timeout_config.zoh_fallback {
            if let Some(ref prev_state) = self.previous_state {
                let residual = self.previous_residual.unwrap_or(best_residual);
                tracing::info!(
                    iterations = iterations,
                    residual = residual,
                    "Returning previous state (ZOH fallback) on timeout"
                );
                return Ok(ConvergedState::new(
                    prev_state.clone(),
                    iterations,
                    residual,
                    ConvergenceStatus::TimedOutWithBestState,
                ));
            }
        }

        // Default: return best state encountered during iteration
        tracing::info!(
            iterations = iterations,
            best_residual = best_residual,
            "Returning best state on timeout"
        );
        Ok(ConvergedState::new(
            best_state.to_vec(),
            iterations,
            best_residual,
            ConvergenceStatus::TimedOutWithBestState,
        ))
    }

    /// Checks for divergence based on residual growth pattern.
    ///
    /// Returns `Some(SolverError::Divergence)` if:
    /// - Residual norm exceeds `divergence_threshold`, or
    /// - Residual has increased for `divergence_patience`+ consecutive iterations
    fn check_divergence(
        &self,
        current_norm: f64,
        previous_norm: f64,
        divergence_count: &mut usize,
    ) -> Option<SolverError> {
        // Check absolute threshold
        if current_norm > self.divergence_threshold {
            return Some(SolverError::Divergence {
                reason: format!(
                    "Residual norm {} exceeds threshold {}",
                    current_norm, self.divergence_threshold
                ),
            });
        }

        // Check consecutive increases
        if current_norm > previous_norm {
            *divergence_count += 1;
            if *divergence_count >= self.divergence_patience {
                return Some(SolverError::Divergence {
                    reason: format!(
                        "Residual increased for {} consecutive iterations: {:.6e} → {:.6e}",
                        self.divergence_patience, previous_norm, current_norm
                    ),
                });
            }
        } else {
            *divergence_count = 0;
        }

        None
    }

    /// Applies relaxation to the state update.
    ///
    /// Update formula: x_new = x_old - omega * residual
    /// where residual = F(x_k) represents the equation residuals.
    ///
    /// This is the standard Picard iteration: x_{k+1} = x_k - ω·F(x_k)
    fn apply_relaxation(state: &mut [f64], residuals: &[f64], omega: f64) {
        for (x, &r) in state.iter_mut().zip(residuals.iter()) {
            *x -= omega * r;
        }
    }
}

impl Solver for PicardConfig {
    fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
        let start_time = Instant::now();

        tracing::info!(
            max_iterations = self.max_iterations,
            tolerance = self.tolerance,
            relaxation_factor = self.relaxation_factor,
            divergence_threshold = self.divergence_threshold,
            divergence_patience = self.divergence_patience,
            "Sequential Substitution (Picard) solver starting"
        );

        // Get system dimensions
        let n_state = system.state_vector_len();
        let n_equations: usize = system
            .traverse_for_jacobian()
            .map(|(_, c, _)| c.n_equations())
            .sum();

        // Validate system
        if n_state == 0 || n_equations == 0 {
            return Err(SolverError::InvalidSystem {
                message: "Empty system has no state variables or equations".to_string(),
            });
        }

        // Validate state/equation dimensions
        if n_state != n_equations {
            return Err(SolverError::InvalidSystem {
                message: format!(
                    "State dimension ({}) does not match equation count ({})",
                    n_state, n_equations
                ),
            });
        }

        // Pre-allocate all buffers (AC: #6 - no heap allocation in iteration loop)
        // Story 4.6 - AC: #8: Use initial_state if provided, else start from zeros
        let mut state: Vec<f64> = self
            .initial_state
            .as_ref()
            .map(|s| {
                debug_assert_eq!(
                    s.len(),
                    n_state,
                    "initial_state length mismatch: expected {}, got {}",
                    n_state,
                    s.len()
                );
                if s.len() == n_state {
                    s.clone()
                } else {
                    vec![0.0; n_state]
                }
            })
            .unwrap_or_else(|| vec![0.0; n_state]);
        let mut prev_iteration_state: Vec<f64> = vec![0.0; n_state]; // For convergence delta check
        let mut residuals: Vec<f64> = vec![0.0; n_equations];
        let mut divergence_count: usize = 0;
        let mut previous_norm: f64;

        // Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
        let mut best_state: Vec<f64> = vec![0.0; n_state];
        let mut best_residual: f64;

        // Initial residual computation
        system
            .compute_residuals(&state, &mut residuals)
            .map_err(|e| SolverError::InvalidSystem {
                message: format!("Failed to compute initial residuals: {:?}", e),
            })?;

        let mut current_norm = Self::residual_norm(&residuals);

        // Initialize best state tracking with initial state
        best_state.copy_from_slice(&state);
        best_residual = current_norm;

        tracing::debug!(iteration = 0, residual_norm = current_norm, "Initial state");

        // Check if already converged
        if current_norm < self.tolerance {
            tracing::info!(
                iterations = 0,
                final_residual = current_norm,
                "System already converged at initial state"
            );
            return Ok(ConvergedState::new(
                state,
                0,
                current_norm,
                ConvergenceStatus::Converged,
            ));
        }

        // Main Picard iteration loop
        for iteration in 1..=self.max_iterations {
            // Save state before step for convergence criteria delta checks
            prev_iteration_state.copy_from_slice(&state);

            // Check timeout at iteration start (Story 4.5 - AC: #1)
            if let Some(timeout) = self.timeout {
                if start_time.elapsed() > timeout {
                    tracing::info!(
                        iteration = iteration,
                        elapsed_ms = start_time.elapsed().as_millis(),
                        timeout_ms = timeout.as_millis(),
                        best_residual = best_residual,
                        "Solver timed out"
                    );

                    // Story 4.5 - AC: #2, #6: Return best state or error based on config
                    return self.handle_timeout(&best_state, best_residual, iteration - 1, timeout);
                }
            }

            // Apply relaxed update: x_new = x_old - omega * residual (AC: #2, #3)
            Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);

            // Compute new residuals
            system
                .compute_residuals(&state, &mut residuals)
                .map_err(|e| SolverError::InvalidSystem {
                    message: format!("Failed to compute residuals: {:?}", e),
                })?;

            previous_norm = current_norm;
            current_norm = Self::residual_norm(&residuals);

            // Update best state if residual improved (Story 4.5 - AC: #2)
            if current_norm < best_residual {
                best_state.copy_from_slice(&state);
                best_residual = current_norm;
                tracing::debug!(
                    iteration = iteration,
                    best_residual = best_residual,
                    "Best state updated"
                );
            }

            tracing::debug!(
                iteration = iteration,
                residual_norm = current_norm,
                relaxation_factor = self.relaxation_factor,
                "Picard iteration complete"
            );

            // Check convergence (AC: #1, Story 4.7 — criteria-aware)
            let converged = if let Some(ref criteria) = self.convergence_criteria {
                let report =
                    criteria.check(&state, Some(&prev_iteration_state), &residuals, system);
                if report.is_globally_converged() {
                    tracing::info!(
                        iterations = iteration,
                        final_residual = current_norm,
                        relaxation_factor = self.relaxation_factor,
                        "Sequential Substitution converged (criteria)"
                    );
                    return Ok(ConvergedState::with_report(
                        state,
                        iteration,
                        current_norm,
                        ConvergenceStatus::Converged,
                        report,
                    ));
                }
                false
            } else {
                current_norm < self.tolerance
            };

            if converged {
                tracing::info!(
                    iterations = iteration,
                    final_residual = current_norm,
                    relaxation_factor = self.relaxation_factor,
                    "Sequential Substitution converged"
                );
                return Ok(ConvergedState::new(
                    state,
                    iteration,
                    current_norm,
                    ConvergenceStatus::Converged,
                ));
            }

            // Check divergence (AC: #5)
            if let Some(err) =
                self.check_divergence(current_norm, previous_norm, &mut divergence_count)
            {
                tracing::warn!(
                    iteration = iteration,
                    residual_norm = current_norm,
                    "Divergence detected"
                );
                return Err(err);
            }
        }

        // Max iterations exceeded
        tracing::warn!(
            max_iterations = self.max_iterations,
            final_residual = current_norm,
            "Sequential Substitution did not converge"
        );
        Err(SolverError::NonConvergence {
            iterations: self.max_iterations,
            final_residual: current_norm,
        })
    }

    fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Fallback Configuration (Story 4.4)
// ─────────────────────────────────────────────────────────────────────────────

/// Configuration for the intelligent fallback solver.
///
/// The fallback solver starts with Newton-Raphson (quadratic convergence) and
/// automatically switches to Sequential Substitution (Picard) if Newton diverges.
/// It can return to Newton when Picard stabilizes the solution.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{FallbackConfig, FallbackSolver, Solver};
/// use std::time::Duration;
///
/// let config = FallbackConfig {
///     fallback_enabled: true,
///     return_to_newton_threshold: 1e-3,
///     max_fallback_switches: 2,
/// };
///
/// let solver = FallbackSolver::new(config)
///     .with_timeout(Duration::from_secs(1));
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct FallbackConfig {
    /// Enable automatic fallback from Newton to Picard on divergence.
    ///
    /// When `true` (default), the solver switches to Picard if Newton diverges.
    /// When `false`, the solver runs pure Newton or Picard without fallback.
    pub fallback_enabled: bool,

    /// Residual norm threshold for returning to Newton from Picard.
    ///
    /// When Picard reduces the residual below this threshold, the solver
    /// attempts to return to Newton for faster convergence.
    /// Default: $10^{-3}$.
    pub return_to_newton_threshold: f64,

    /// Maximum number of solver switches before staying on current solver.
    ///
    /// Prevents infinite oscillation between Newton and Picard.
    /// Default: 2.
    pub max_fallback_switches: usize,
}

impl Default for FallbackConfig {
    fn default() -> Self {
        Self {
            fallback_enabled: true,
            return_to_newton_threshold: 1e-3,
            max_fallback_switches: 2,
        }
    }
}

/// Tracks which solver is currently active in the fallback loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CurrentSolver {
    Newton,
    Picard,
}

/// Internal state for the fallback solver.
struct FallbackState {
    current_solver: CurrentSolver,
    switch_count: usize,
    /// Whether we've permanently committed to Picard (after max switches or Newton re-divergence)
    committed_to_picard: bool,
    /// Best state encountered across all solver invocations (Story 4.5 - AC: #4)
    best_state: Option<Vec<f64>>,
    /// Best residual norm across all solver invocations (Story 4.5 - AC: #4)
    best_residual: Option<f64>,
}

impl FallbackState {
    fn new() -> Self {
        Self {
            current_solver: CurrentSolver::Newton,
            switch_count: 0,
            committed_to_picard: false,
            best_state: None,
            best_residual: None,
        }
    }

    /// Update best state if the given residual is better (Story 4.5 - AC: #4).
    fn update_best_state(&mut self, state: &[f64], residual: f64) {
        if self.best_residual.is_none() || residual < self.best_residual.unwrap() {
            self.best_state = Some(state.to_vec());
            self.best_residual = Some(residual);
        }
    }
}

/// Intelligent fallback solver that switches between Newton-Raphson and Picard.
///
/// The fallback solver implements the following algorithm:
///
/// 1. Start with Newton-Raphson (quadratic convergence)
/// 2. If Newton diverges, switch to Picard (more robust)
/// 3. If Picard stabilizes (residual < threshold), try returning to Newton
/// 4. If max switches reached, stay on current solver permanently
///
/// # Timeout Handling
///
/// The timeout applies to the total solving time across all solver switches.
/// Each solver inherits the remaining time budget.
///
/// # Pre-Allocated Buffers
///
/// All buffers are pre-allocated once before the fallback loop to avoid
/// heap allocation during solver switches (NFR4).
#[derive(Debug, Clone)]
pub struct FallbackSolver {
    /// Fallback behavior configuration.
    pub config: FallbackConfig,
    /// Newton-Raphson configuration.
    pub newton_config: NewtonConfig,
    /// Sequential Substitution (Picard) configuration.
    pub picard_config: PicardConfig,
}

impl FallbackSolver {
    /// Creates a new fallback solver with the given configuration.
    pub fn new(config: FallbackConfig) -> Self {
        Self {
            config,
            newton_config: NewtonConfig::default(),
            picard_config: PicardConfig::default(),
        }
    }

    /// Creates a fallback solver with default configuration.
    pub fn default_solver() -> Self {
        Self::new(FallbackConfig::default())
    }

    /// Sets custom Newton-Raphson configuration.
    pub fn with_newton_config(mut self, config: NewtonConfig) -> Self {
        self.newton_config = config;
        self
    }

    /// Sets custom Picard configuration.
    pub fn with_picard_config(mut self, config: PicardConfig) -> Self {
        self.picard_config = config;
        self
    }

    /// Sets the initial state for cold-start solving (Story 4.6 — builder pattern).
    ///
    /// Delegates to both `newton_config` and `picard_config` so the initial state
    /// is used regardless of which solver is active in the fallback loop.
    pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
        self.newton_config.initial_state = Some(state.clone());
        self.picard_config.initial_state = Some(state);
        self
    }

    /// Sets multi-circuit convergence criteria (Story 4.7 — builder pattern).
    ///
    /// Delegates to both `newton_config` and `picard_config` so criteria are
    /// applied regardless of which solver is active in the fallback loop.
    pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
        self.newton_config.convergence_criteria = Some(criteria.clone());
        self.picard_config.convergence_criteria = Some(criteria);
        self
    }

    /// Main fallback solving loop.
    ///
    /// Implements the intelligent fallback algorithm:
    /// - Start with Newton-Raphson
    /// - Switch to Picard on Newton divergence
    /// - Return to Newton when Picard stabilizes (if under switch limit and residual below threshold)
    /// - Stay on Picard permanently after max switches or if Newton re-diverges
    fn solve_with_fallback(
        &mut self,
        system: &mut System,
        start_time: Instant,
        timeout: Option<Duration>,
    ) -> Result<ConvergedState, SolverError> {
        let mut state = FallbackState::new();

        // Pre-configure solver configs once
        let mut newton_cfg = self.newton_config.clone();
        let mut picard_cfg = self.picard_config.clone();

        loop {
            // Check remaining time budget
            let remaining = timeout.map(|t| t.saturating_sub(start_time.elapsed()));

            // Check for timeout before running solver
            if let Some(remaining_time) = remaining {
                if remaining_time.is_zero() {
                    return Err(SolverError::Timeout {
                        timeout_ms: timeout.unwrap().as_millis() as u64,
                    });
                }
            }

            // Run current solver with remaining time
            newton_cfg.timeout = remaining;
            picard_cfg.timeout = remaining;

            let result = match state.current_solver {
                CurrentSolver::Newton => newton_cfg.solve(system),
                CurrentSolver::Picard => picard_cfg.solve(system),
            };

            match result {
                Ok(converged) => {
                    // Update best state tracking (Story 4.5 - AC: #4)
                    state.update_best_state(&converged.state, converged.final_residual);

                    tracing::info!(
                        solver = match state.current_solver {
                            CurrentSolver::Newton => "NewtonRaphson",
                            CurrentSolver::Picard => "Picard",
                        },
                        iterations = converged.iterations,
                        final_residual = converged.final_residual,
                        switch_count = state.switch_count,
                        "Fallback solver converged"
                    );
                    return Ok(converged);
                }
                Err(SolverError::Timeout { timeout_ms }) => {
                    // Story 4.5 - AC: #4: Return best state on timeout if available
                    if let (Some(best_state), Some(best_residual)) =
                        (state.best_state.clone(), state.best_residual)
                    {
                        tracing::info!(
                            best_residual = best_residual,
                            "Returning best state across all solver invocations on timeout"
                        );
                        return Ok(ConvergedState::new(
                            best_state,
                            0, // iterations not tracked across switches
                            best_residual,
                            ConvergenceStatus::TimedOutWithBestState,
                        ));
                    }
                    return Err(SolverError::Timeout { timeout_ms });
                }
                Err(SolverError::Divergence { ref reason }) => {
                    // Handle divergence based on current solver and state
                    if !self.config.fallback_enabled {
                        tracing::info!(
                            solver = match state.current_solver {
                                CurrentSolver::Newton => "NewtonRaphson",
                                CurrentSolver::Picard => "Picard",
                            },
                            reason = reason,
                            "Divergence detected, fallback disabled"
                        );
                        return result;
                    }

                    match state.current_solver {
                        CurrentSolver::Newton => {
                            // Newton diverged - switch to Picard (stay there permanently after max switches)
                            if state.switch_count >= self.config.max_fallback_switches {
                                // Max switches reached - commit to Picard permanently
                                state.committed_to_picard = true;
                                state.current_solver = CurrentSolver::Picard;
                                tracing::info!(
                                    switch_count = state.switch_count,
                                    max_switches = self.config.max_fallback_switches,
                                    "Max switches reached, committing to Picard permanently"
                                );
                            } else {
                                // Switch to Picard
                                state.switch_count += 1;
                                state.current_solver = CurrentSolver::Picard;
                                tracing::warn!(
                                    switch_count = state.switch_count,
                                    reason = reason,
                                    "Newton diverged, switching to Picard"
                                );
                            }
                            // Continue loop with Picard
                        }
                        CurrentSolver::Picard => {
                            // Picard diverged - if we were trying Newton again, commit to Picard permanently
                            if state.switch_count > 0 && !state.committed_to_picard {
                                state.committed_to_picard = true;
                                tracing::info!(
                                    switch_count = state.switch_count,
                                    reason = reason,
                                    "Newton re-diverged after return from Picard, staying on Picard permanently"
                                );
                                // Stay on Picard and try again
                            } else {
                                // Picard diverged with no return attempt - no more fallbacks available
                                tracing::warn!(
                                    reason = reason,
                                    "Picard diverged, no more fallbacks available"
                                );
                                return result;
                            }
                        }
                    }
                }
                Err(SolverError::NonConvergence {
                    iterations,
                    final_residual,
                }) => {
                    // Non-convergence: check if we should try the other solver
                    if !self.config.fallback_enabled {
                        return Err(SolverError::NonConvergence {
                            iterations,
                            final_residual,
                        });
                    }

                    match state.current_solver {
                        CurrentSolver::Newton => {
                            // Newton didn't converge - try Picard
                            if state.switch_count >= self.config.max_fallback_switches {
                                // Max switches reached - commit to Picard permanently
                                state.committed_to_picard = true;
                                state.current_solver = CurrentSolver::Picard;
                                tracing::info!(
                                    switch_count = state.switch_count,
                                    "Max switches reached, committing to Picard permanently"
                                );
                            } else {
                                state.switch_count += 1;
                                state.current_solver = CurrentSolver::Picard;
                                tracing::info!(
                                    switch_count = state.switch_count,
                                    iterations = iterations,
                                    final_residual = final_residual,
                                    "Newton did not converge, switching to Picard"
                                );
                            }
                            // Continue loop with Picard
                        }
                        CurrentSolver::Picard => {
                            // Picard didn't converge - check if we should try Newton
                            if state.committed_to_picard
                                || state.switch_count >= self.config.max_fallback_switches
                            {
                                tracing::info!(
                                    iterations = iterations,
                                    final_residual = final_residual,
                                    "Picard did not converge, no more fallbacks"
                                );
                                return Err(SolverError::NonConvergence {
                                    iterations,
                                    final_residual,
                                });
                            }

                            // Check if residual is low enough to try Newton
                            if final_residual < self.config.return_to_newton_threshold {
                                state.switch_count += 1;
                                state.current_solver = CurrentSolver::Newton;
                                tracing::info!(
                                    switch_count = state.switch_count,
                                    final_residual = final_residual,
                                    threshold = self.config.return_to_newton_threshold,
                                    "Picard stabilized, attempting Newton return"
                                );
                                // Continue loop with Newton
                            } else {
                                // Stay on Picard and keep trying
                                tracing::debug!(
                                    final_residual = final_residual,
                                    threshold = self.config.return_to_newton_threshold,
                                    "Picard not yet stabilized, aborting"
                                );
                                return Err(SolverError::NonConvergence {
                                    iterations,
                                    final_residual,
                                });
                            }
                        }
                    }
                }
                Err(other) => {
                    // InvalidSystem or other errors - propagate immediately
                    return Err(other);
                }
            }
        }
    }
}

impl Solver for FallbackSolver {
    fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
        let start_time = Instant::now();
        let timeout = self.newton_config.timeout.or(self.picard_config.timeout);

        tracing::info!(
            fallback_enabled = self.config.fallback_enabled,
            return_to_newton_threshold = self.config.return_to_newton_threshold,
            max_fallback_switches = self.config.max_fallback_switches,
            "Fallback solver starting"
        );

        if self.config.fallback_enabled {
            self.solve_with_fallback(system, start_time, timeout)
        } else {
            // Fallback disabled - run pure Newton
            self.newton_config.solve(system)
        }
    }

    fn with_timeout(mut self, timeout: Duration) -> Self {
        self.newton_config.timeout = Some(timeout);
        self.picard_config.timeout = Some(timeout);
        self
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Strategy enum (zero-cost static dispatch)
// ─────────────────────────────────────────────────────────────────────────────

/// Enum-based solver strategy dispatcher.
///
/// Provides zero-cost static dispatch to the selected solver strategy via
/// `match` (monomorphization), avoiding vtable overhead while still allowing
/// runtime strategy selection.
///
/// # Default
///
/// `SolverStrategy::default()` returns `NewtonRaphson(NewtonConfig::default())`.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{Solver, SolverStrategy, PicardConfig};
/// use std::time::Duration;
///
/// let strategy = SolverStrategy::SequentialSubstitution(
///     PicardConfig { relaxation_factor: 0.3, ..Default::default() }
/// ).with_timeout(Duration::from_secs(1));
/// ```
#[derive(Debug, Clone, PartialEq)]
pub enum SolverStrategy {
    /// Newton-Raphson solver (quadratic convergence, requires Jacobian).
    NewtonRaphson(NewtonConfig),
    /// Sequential Substitution / Picard iteration (robust, no Jacobian needed).
    SequentialSubstitution(PicardConfig),
}

impl Default for SolverStrategy {
    /// Returns `SolverStrategy::NewtonRaphson(NewtonConfig::default())`.
    fn default() -> Self {
        SolverStrategy::NewtonRaphson(NewtonConfig::default())
    }
}

impl Solver for SolverStrategy {
    fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
        tracing::info!(
            strategy = match self {
                SolverStrategy::NewtonRaphson(_) => "NewtonRaphson",
                SolverStrategy::SequentialSubstitution(_) => "SequentialSubstitution",
            },
            "SolverStrategy::solve dispatching"
        );
        let result = match self {
            SolverStrategy::NewtonRaphson(cfg) => cfg.solve(system),
            SolverStrategy::SequentialSubstitution(cfg) => cfg.solve(system),
        };

        if let Ok(state) = &result {
            if state.is_converged() {
                // Post-solve validation checks
                system.check_mass_balance(&state.state)?;
            }
        }

        result
    }

    fn with_timeout(self, timeout: Duration) -> Self {
        match self {
            SolverStrategy::NewtonRaphson(cfg) => {
                SolverStrategy::NewtonRaphson(cfg.with_timeout(timeout))
            }
            SolverStrategy::SequentialSubstitution(cfg) => {
                SolverStrategy::SequentialSubstitution(cfg.with_timeout(timeout))
            }
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────

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

    // ── AC #1: Trait object safety ────────────────────────────────────────────

    /// Verify that `Box<dyn Solver>` compiles (trait is object-safe for `solve`).
    ///
    /// Note: `with_timeout` is excluded from the vtable (requires `Sized`), which
    /// is the correct design — configure timeout on the concrete type before boxing.
    #[test]
    fn test_solver_trait_object_safety() {
        // This test verifies at compile time that `dyn Solver` is valid.
        // We use a helper function that accepts `&mut dyn Solver`.
        fn accepts_dyn_solver(_solver: &mut dyn Solver) {}

        let mut newton = NewtonConfig::default();
        accepts_dyn_solver(&mut newton);

        let mut picard = PicardConfig::default();
        accepts_dyn_solver(&mut picard);

        let mut strategy = SolverStrategy::default();
        accepts_dyn_solver(&mut strategy);
    }

    /// Verify that `Box<dyn Solver>` can be constructed from concrete types.
    #[test]
    fn test_box_dyn_solver_compiles() {
        let _boxed: Box<dyn Solver> = Box::new(NewtonConfig::default());
        let _boxed2: Box<dyn Solver> = Box::new(PicardConfig::default());
        let _boxed3: Box<dyn Solver> = Box::new(SolverStrategy::default());
    }

    // ── AC #2: SolverStrategy default and dispatch ────────────────────────────

    /// Verify that `SolverStrategy::default()` returns Newton-Raphson.
    #[test]
    fn test_solver_strategy_default_is_newton_raphson() {
        let strategy = SolverStrategy::default();
        assert!(
            matches!(strategy, SolverStrategy::NewtonRaphson(_)),
            "Default strategy must be NewtonRaphson, got {:?}",
            strategy
        );
    }

    /// Verify that the Newton-Raphson variant wraps a `NewtonConfig`.
    #[test]
    fn test_solver_strategy_newton_raphson_variant() {
        let strategy = SolverStrategy::NewtonRaphson(NewtonConfig::default());
        match strategy {
            SolverStrategy::NewtonRaphson(cfg) => {
                assert_eq!(cfg.max_iterations, 100);
                assert!((cfg.tolerance - 1e-6).abs() < 1e-15);
                assert!(!cfg.line_search);
                assert!(cfg.timeout.is_none());
            }
            other => panic!("Expected NewtonRaphson, got {:?}", other),
        }
    }

    /// Verify that the Sequential Substitution variant wraps a `PicardConfig`.
    #[test]
    fn test_solver_strategy_sequential_substitution_variant() {
        let strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default());
        match strategy {
            SolverStrategy::SequentialSubstitution(cfg) => {
                assert_eq!(cfg.max_iterations, 100);
                assert!((cfg.tolerance - 1e-6).abs() < 1e-15);
                assert!((cfg.relaxation_factor - 0.5).abs() < 1e-15);
                assert!(cfg.timeout.is_none());
            }
            other => panic!("Expected SequentialSubstitution, got {:?}", other),
        }
    }

    // ── AC #3: Timeout builder ────────────────────────────────────────────────

    /// Verify that `with_timeout` on `NewtonConfig` stores the duration.
    #[test]
    fn test_newton_config_with_timeout() {
        let timeout = Duration::from_millis(100);
        let cfg = NewtonConfig::default().with_timeout(timeout);
        assert_eq!(
            cfg.timeout,
            Some(timeout),
            "with_timeout must store the duration in NewtonConfig"
        );
    }

    /// Verify that `with_timeout` on `PicardConfig` stores the duration.
    #[test]
    fn test_picard_config_with_timeout() {
        let timeout = Duration::from_millis(250);
        let cfg = PicardConfig::default().with_timeout(timeout);
        assert_eq!(
            cfg.timeout,
            Some(timeout),
            "with_timeout must store the duration in PicardConfig"
        );
    }

    /// Verify that `with_timeout` on `SolverStrategy::NewtonRaphson` propagates to inner config.
    #[test]
    fn test_solver_strategy_newton_with_timeout() {
        let timeout = Duration::from_millis(500);
        let strategy = SolverStrategy::default().with_timeout(timeout);
        match strategy {
            SolverStrategy::NewtonRaphson(cfg) => {
                assert_eq!(cfg.timeout, Some(timeout));
            }
            other => panic!("Expected NewtonRaphson after with_timeout, got {:?}", other),
        }
    }

    /// Verify that `with_timeout` on `SolverStrategy::SequentialSubstitution` propagates.
    #[test]
    fn test_solver_strategy_picard_with_timeout() {
        let timeout = Duration::from_secs(1);
        let strategy =
            SolverStrategy::SequentialSubstitution(PicardConfig::default()).with_timeout(timeout);
        match strategy {
            SolverStrategy::SequentialSubstitution(cfg) => {
                assert_eq!(cfg.timeout, Some(timeout));
            }
            other => panic!(
                "Expected SequentialSubstitution after with_timeout, got {:?}",
                other
            ),
        }
    }

    /// Verify that `with_timeout` does not change other fields.
    #[test]
    fn test_with_timeout_preserves_other_fields() {
        let cfg = NewtonConfig {
            max_iterations: 50,
            tolerance: 1e-8,
            line_search: true,
            timeout: None,
            use_numerical_jacobian: false,
            line_search_armijo_c: 1e-4,
            line_search_max_backtracks: 20,
            divergence_threshold: 1e10,
            timeout_config: TimeoutConfig::default(),
            previous_state: None,
            previous_residual: None,
            initial_state: None,
            convergence_criteria: None,
            jacobian_freezing: None,
        }
        .with_timeout(Duration::from_millis(200));

        assert_eq!(cfg.max_iterations, 50);
        assert!((cfg.tolerance - 1e-8).abs() < 1e-20);
        assert!(cfg.line_search);
        assert_eq!(cfg.timeout, Some(Duration::from_millis(200)));
    }

    // ── AC #4: Error variants ─────────────────────────────────────────────────

    /// Verify `SolverError::NonConvergence` Display message.
    #[test]
    fn test_error_non_convergence_display() {
        let err = SolverError::NonConvergence {
            iterations: 42,
            final_residual: 1.23e-3,
        };
        let msg = err.to_string();
        assert!(
            msg.contains("42"),
            "NonConvergence message must contain iteration count: {}",
            msg
        );
        assert!(
            msg.contains("1.230e-3") || msg.contains("1.23e-3") || msg.contains("1.230e"),
            "NonConvergence message must contain residual: {}",
            msg
        );
    }

    /// Verify `SolverError::Timeout` Display message.
    #[test]
    fn test_error_timeout_display() {
        let err = SolverError::Timeout { timeout_ms: 500 };
        let msg = err.to_string();
        assert!(
            msg.contains("500"),
            "Timeout message must contain timeout_ms: {}",
            msg
        );
    }

    /// Verify `SolverError::Divergence` Display message.
    #[test]
    fn test_error_divergence_display() {
        let err = SolverError::Divergence {
            reason: "residual norm exceeded 1e10".to_string(),
        };
        let msg = err.to_string();
        assert!(
            msg.contains("residual norm exceeded 1e10"),
            "Divergence message must contain reason: {}",
            msg
        );
    }

    /// Verify `SolverError::InvalidSystem` Display message.
    #[test]
    fn test_error_invalid_system_display() {
        let err = SolverError::InvalidSystem {
            message: "empty system has no equations".to_string(),
        };
        let msg = err.to_string();
        assert!(
            msg.contains("empty system has no equations"),
            "InvalidSystem message must contain message: {}",
            msg
        );
    }

    /// Verify all error variants are distinct (no accidental equality).
    #[test]
    fn test_error_variants_distinct() {
        let e1 = SolverError::NonConvergence {
            iterations: 10,
            final_residual: 1e-3,
        };
        let e2 = SolverError::Timeout { timeout_ms: 100 };
        let e3 = SolverError::Divergence {
            reason: "test".to_string(),
        };
        let e4 = SolverError::InvalidSystem {
            message: "test".to_string(),
        };

        assert_ne!(e1, e2);
        assert_ne!(e1, e3);
        assert_ne!(e1, e4);
        assert_ne!(e2, e3);
        assert_ne!(e2, e4);
        assert_ne!(e3, e4);
    }

    // ── ConvergedState ────────────────────────────────────────────────────────

    /// Verify `ConvergedState` fields are accessible and `is_converged()` works.
    #[test]
    fn test_converged_state_fields_accessible() {
        let state =
            ConvergedState::new(vec![1.0, 2.0, 3.0], 15, 1e-8, ConvergenceStatus::Converged);

        assert_eq!(state.state, vec![1.0, 2.0, 3.0]);
        assert_eq!(state.iterations, 15);
        assert!((state.final_residual - 1e-8).abs() < 1e-20);
        assert_eq!(state.status, ConvergenceStatus::Converged);
        assert!(state.is_converged());
    }

    /// Verify `ConvergedState` with `TimedOutWithBestState` status.
    #[test]
    fn test_converged_state_timed_out_not_converged() {
        let state = ConvergedState::new(
            vec![0.0],
            50,
            1e-3,
            ConvergenceStatus::TimedOutWithBestState,
        );

        assert!(!state.is_converged());
        assert_eq!(state.status, ConvergenceStatus::TimedOutWithBestState);
    }

    // ── AC #2: SolverStrategy::solve() dispatch end-to-end ───────────────────

    /// Verify that `SolverStrategy::NewtonRaphson` dispatches to the Newton implementation.
    #[test]
    fn test_solver_strategy_newton_dispatch_reaches_stub() {
        let mut strategy = SolverStrategy::default(); // NewtonRaphson
        let mut system = System::new();
        system.finalize().unwrap();
        let result = strategy.solve(&mut system);
        // Empty system should return InvalidSystem
        assert!(
            result.is_err(),
            "Newton solver must return Err for empty system"
        );
        match result {
            Err(SolverError::InvalidSystem { ref message }) => {
                assert!(
                    message.contains("Empty") || message.contains("no state"),
                    "Newton dispatch must detect empty system, got: {}",
                    message
                );
            }
            other => panic!("Expected InvalidSystem from Newton solver, got {:?}", other),
        }
    }

    /// Verify that `SolverStrategy::SequentialSubstitution` dispatches to the Picard implementation.
    ///
    /// The Picard solver returns `SolverError::InvalidSystem` for an empty system.
    /// This confirms the enum dispatch reaches the correct implementation.
    #[test]
    fn test_solver_strategy_picard_dispatch_reaches_implementation() {
        let mut strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default());
        let mut system = System::new();
        system.finalize().unwrap();
        let result = strategy.solve(&mut system);
        assert!(
            result.is_err(),
            "Picard solver must return Err for empty system"
        );
        match result {
            Err(SolverError::InvalidSystem { ref message }) => {
                assert!(
                    message.contains("Empty") || message.contains("no state"),
                    "Picard dispatch must detect empty system, got: {}",
                    message
                );
            }
            other => panic!("Expected InvalidSystem from Picard solver, got {:?}", other),
        }
    }

    /// Verify that `Box<dyn Solver>` can call `solve()` through the vtable.
    ///
    /// This is the true object-safety test: dynamic dispatch via vtable must work.
    #[test]
    fn test_box_dyn_solver_can_call_solve() {
        let mut boxed: Box<dyn Solver> = Box::new(NewtonConfig::default());
        let mut system = System::new();
        system.finalize().unwrap();
        let result = boxed.solve(&mut system);
        // Empty system returns InvalidSystem — we verify it dispatches without panicking
        assert!(result.is_err(), "dyn Solver::solve must dispatch correctly");
    }

    // ── Default configs ───────────────────────────────────────────────────────

    /// Verify `NewtonConfig::default()` provides sensible values.
    #[test]
    fn test_newton_config_default_sensible() {
        let cfg = NewtonConfig::default();
        assert_eq!(
            cfg.max_iterations, 100,
            "default max_iterations should be 100"
        );
        assert!(
            cfg.tolerance > 0.0 && cfg.tolerance < 1e-3,
            "default tolerance should be small positive: {}",
            cfg.tolerance
        );
        assert!(!cfg.line_search, "line_search should default to false");
        assert!(cfg.timeout.is_none(), "timeout should default to None");
    }

    /// Verify `PicardConfig::default()` provides sensible values.
    #[test]
    fn test_picard_config_default_sensible() {
        let cfg = PicardConfig::default();
        assert_eq!(
            cfg.max_iterations, 100,
            "default max_iterations should be 100"
        );
        assert!(
            cfg.tolerance > 0.0 && cfg.tolerance < 1e-3,
            "default tolerance should be small positive: {}",
            cfg.tolerance
        );
        assert!(
            cfg.relaxation_factor > 0.0 && cfg.relaxation_factor <= 1.0,
            "relaxation_factor must be in (0, 1]: {}",
            cfg.relaxation_factor
        );
        assert!(cfg.timeout.is_none(), "timeout should default to None");
        assert_eq!(
            cfg.divergence_patience, 5,
            "default divergence_patience should be 5"
        );
        assert!(
            cfg.divergence_threshold > 1e9,
            "divergence_threshold should be large: {}",
            cfg.divergence_threshold
        );
    }

    // ── Story 4.3: Picard Solver Tests ────────────────────────────────────────

    /// Verify PicardConfig new fields have correct defaults.
    #[test]
    fn test_picard_config_new_fields() {
        let cfg = PicardConfig::default();
        assert_eq!(cfg.divergence_threshold, 1e10);
        assert_eq!(cfg.divergence_patience, 5);
    }

    /// Verify PicardConfig divergence_patience is higher than Newton's 3.
    #[test]
    fn test_picard_divergence_patience_higher_than_newton() {
        let picard = PicardConfig::default();
        // Newton uses hardcoded patience of 3; Picard should be more tolerant
        assert!(
            picard.divergence_patience >= 5,
            "Picard divergence_patience ({}) should be >= 5 (more tolerant than Newton's 3)",
            picard.divergence_patience
        );
    }

    /// Verify relaxation factor affects convergence behavior.
    #[test]
    fn test_picard_relaxation_factor_custom() {
        let cfg = PicardConfig {
            relaxation_factor: 0.1,
            ..Default::default()
        };
        assert!((cfg.relaxation_factor - 0.1).abs() < 1e-15);

        let cfg2 = PicardConfig {
            relaxation_factor: 1.0,
            ..Default::default()
        };
        assert!((cfg2.relaxation_factor - 1.0).abs() < 1e-15);
    }

    /// Verify divergence threshold is configurable.
    #[test]
    fn test_picard_divergence_threshold_configurable() {
        let cfg = PicardConfig {
            divergence_threshold: 1e5,
            ..Default::default()
        };
        assert_eq!(cfg.divergence_threshold, 1e5);
    }

    /// Verify divergence patience is configurable.
    #[test]
    fn test_picard_divergence_patience_configurable() {
        let cfg = PicardConfig {
            divergence_patience: 10,
            ..Default::default()
        };
        assert_eq!(cfg.divergence_patience, 10);
    }

    /// Verify PicardConfig with_timeout preserves other fields.
    #[test]
    fn test_picard_with_timeout_preserves_other_fields() {
        let cfg = PicardConfig {
            max_iterations: 200,
            tolerance: 1e-8,
            relaxation_factor: 0.3,
            timeout: None,
            divergence_threshold: 1e8,
            divergence_patience: 7,
            timeout_config: TimeoutConfig::default(),
            previous_state: None,
            previous_residual: None,
            initial_state: None,
            convergence_criteria: None,
        }
        .with_timeout(Duration::from_millis(500));

        assert_eq!(cfg.max_iterations, 200);
        assert!((cfg.tolerance - 1e-8).abs() < 1e-20);
        assert!((cfg.relaxation_factor - 0.3).abs() < 1e-15);
        assert_eq!(cfg.timeout, Some(Duration::from_millis(500)));
        assert_eq!(cfg.divergence_threshold, 1e8);
        assert_eq!(cfg.divergence_patience, 7);
    }

    /// Verify apply_relaxation produces correct update.
    #[test]
    fn test_picard_apply_relaxation_formula() {
        // x_new = x_old - omega * residual
        let mut state = vec![10.0, 20.0];
        let residuals = vec![1.0, 2.0];
        let omega = 0.5;

        PicardConfig::apply_relaxation(&mut state, &residuals, omega);

        // x_new[0] = 10.0 - 0.5 * 1.0 = 9.5
        // x_new[1] = 20.0 - 0.5 * 2.0 = 19.0
        assert!((state[0] - 9.5).abs() < 1e-15);
        assert!((state[1] - 19.0).abs() < 1e-15);
    }

    /// Verify apply_relaxation with omega=1.0 is full update.
    #[test]
    fn test_picard_apply_relaxation_full_update() {
        let mut state = vec![5.0, 10.0];
        let residuals = vec![2.0, 3.0];
        let omega = 1.0;

        PicardConfig::apply_relaxation(&mut state, &residuals, omega);

        // x_new = x_old - residual (full update)
        assert!((state[0] - 3.0).abs() < 1e-15);
        assert!((state[1] - 7.0).abs() < 1e-15);
    }

    /// Verify apply_relaxation with omega=0.1 is heavy damping.
    #[test]
    fn test_picard_apply_relaxation_heavy_damping() {
        let mut state = vec![100.0];
        let residuals = vec![10.0];
        let omega = 0.1;

        PicardConfig::apply_relaxation(&mut state, &residuals, omega);

        // x_new = 100.0 - 0.1 * 10.0 = 99.0
        assert!((state[0] - 99.0).abs() < 1e-15);
    }

    /// Verify residual_norm computes L2 norm correctly.
    #[test]
    fn test_picard_residual_norm() {
        let residuals = vec![3.0, 4.0];
        let norm = PicardConfig::residual_norm(&residuals);
        assert!((norm - 5.0).abs() < 1e-15); // sqrt(9 + 16) = 5
    }

    /// Verify residual_norm for zero residuals.
    #[test]
    fn test_picard_residual_norm_zero() {
        let residuals = vec![0.0; 5];
        let norm = PicardConfig::residual_norm(&residuals);
        assert!((norm - 0.0).abs() < 1e-15);
    }

    /// Verify check_divergence detects threshold exceedance.
    #[test]
    fn test_picard_check_divergence_threshold() {
        let cfg = PicardConfig {
            divergence_threshold: 100.0,
            ..Default::default()
        };
        let mut divergence_count = 0;

        let result = cfg.check_divergence(150.0, 50.0, &mut divergence_count);
        assert!(matches!(result, Some(SolverError::Divergence { .. })));
    }

    /// Verify check_divergence detects consecutive increases.
    #[test]
    fn test_picard_check_divergence_consecutive_increases() {
        let cfg = PicardConfig {
            divergence_patience: 3,
            ..Default::default()
        };
        let mut divergence_count = 2; // Already had 2 increases

        // Third consecutive increase should trigger divergence
        let result = cfg.check_divergence(2.0, 1.0, &mut divergence_count);
        assert!(matches!(result, Some(SolverError::Divergence { .. })));
    }

    /// Verify check_divergence resets counter on decrease.
    #[test]
    fn test_picard_check_divergence_resets_on_decrease() {
        let cfg = PicardConfig::default();
        let mut divergence_count = 4; // Close to patience

        // Residual decreased - should reset counter
        let result = cfg.check_divergence(1.0, 2.0, &mut divergence_count);
        assert!(result.is_none());
        assert_eq!(divergence_count, 0);
    }

    /// Verify check_divergence does not trigger below patience.
    #[test]
    fn test_picard_check_divergence_below_patience() {
        let cfg = PicardConfig {
            divergence_patience: 5,
            ..Default::default()
        };
        let mut divergence_count = 3; // Below patience

        let result = cfg.check_divergence(2.0, 1.0, &mut divergence_count);
        assert!(result.is_none());
        assert_eq!(divergence_count, 4); // Incremented but not triggered
    }

    // ── Story 4.4: Fallback Solver Tests ─────────────────────────────────────

    /// Verify FallbackConfig defaults are sensible.
    #[test]
    fn test_fallback_config_defaults() {
        let cfg = FallbackConfig::default();
        assert!(
            cfg.fallback_enabled,
            "fallback_enabled should default to true"
        );
        assert!(
            (cfg.return_to_newton_threshold - 1e-3).abs() < 1e-15,
            "return_to_newton_threshold should be 1e-3"
        );
        assert_eq!(
            cfg.max_fallback_switches, 2,
            "max_fallback_switches should be 2"
        );
    }

    /// Verify FallbackConfig fields are configurable.
    #[test]
    fn test_fallback_config_configurable() {
        let cfg = FallbackConfig {
            fallback_enabled: false,
            return_to_newton_threshold: 1e-2,
            max_fallback_switches: 5,
        };
        assert!(!cfg.fallback_enabled);
        assert!((cfg.return_to_newton_threshold - 1e-2).abs() < 1e-15);
        assert_eq!(cfg.max_fallback_switches, 5);
    }

    /// Verify FallbackSolver::new() creates solver with given config.
    #[test]
    fn test_fallback_solver_new() {
        let config = FallbackConfig {
            fallback_enabled: false,
            return_to_newton_threshold: 5e-4,
            max_fallback_switches: 3,
        };
        let solver = FallbackSolver::new(config.clone());
        assert_eq!(solver.config, config);
    }

    /// Verify FallbackSolver::default_solver() uses default config.
    #[test]
    fn test_fallback_solver_default() {
        let solver = FallbackSolver::default_solver();
        assert_eq!(solver.config, FallbackConfig::default());
    }

    /// Verify FallbackSolver::with_timeout() sets timeout on both configs.
    #[test]
    fn test_fallback_solver_with_timeout() {
        let timeout = Duration::from_millis(500);
        let solver = FallbackSolver::default_solver().with_timeout(timeout);
        assert_eq!(solver.newton_config.timeout, Some(timeout));
        assert_eq!(solver.picard_config.timeout, Some(timeout));
    }

    /// Verify FallbackSolver::with_newton_config() sets Newton config.
    #[test]
    fn test_fallback_solver_with_newton_config() {
        let newton_cfg = NewtonConfig {
            max_iterations: 50,
            tolerance: 1e-8,
            ..Default::default()
        };
        let solver = FallbackSolver::default_solver().with_newton_config(newton_cfg.clone());
        assert_eq!(solver.newton_config, newton_cfg);
    }

    /// Verify FallbackSolver::with_picard_config() sets Picard config.
    #[test]
    fn test_fallback_solver_with_picard_config() {
        let picard_cfg = PicardConfig {
            relaxation_factor: 0.3,
            ..Default::default()
        };
        let solver = FallbackSolver::default_solver().with_picard_config(picard_cfg.clone());
        assert_eq!(solver.picard_config, picard_cfg);
    }

    /// Verify FallbackSolver returns InvalidSystem for empty system.
    #[test]
    fn test_fallback_solver_empty_system() {
        let mut solver = FallbackSolver::default_solver();
        let mut system = System::new();
        system.finalize().unwrap();
        let result = solver.solve(&mut system);
        assert!(result.is_err());
        match result {
            Err(SolverError::InvalidSystem { ref message }) => {
                assert!(message.contains("Empty") || message.contains("no state"));
            }
            other => panic!("Expected InvalidSystem, got {:?}", other),
        }
    }

    /// Verify FallbackSolver with fallback_enabled=false behaves as pure Newton.
    #[test]
    fn test_fallback_solver_disabled_fallback() {
        let config = FallbackConfig {
            fallback_enabled: false,
            ..Default::default()
        };
        let mut solver = FallbackSolver::new(config);
        let mut system = System::new();
        system.finalize().unwrap();
        let result = solver.solve(&mut system);
        // Should behave exactly like Newton - return InvalidSystem for empty system
        assert!(result.is_err());
        match result {
            Err(SolverError::InvalidSystem { .. }) => {}
            other => panic!("Expected InvalidSystem, got {:?}", other),
        }
    }

    /// Verify FallbackSolver implements Solver trait (object safety).
    #[test]
    fn test_fallback_solver_trait_object() {
        let mut boxed: Box<dyn Solver> = Box::new(FallbackSolver::default_solver());
        let mut system = System::new();
        system.finalize().unwrap();
        let result = boxed.solve(&mut system);
        assert!(result.is_err());
    }

    /// Verify FallbackConfig return_to_newton_threshold default is reasonable.
    #[test]
    fn test_return_to_newton_threshold_reasonable() {
        let cfg = FallbackConfig::default();
        // Threshold should be larger than typical tolerance (1e-6) but smaller than
        // typical initial residual (often 1e-1 to 1e-2)
        assert!(
            cfg.return_to_newton_threshold > 1e-6,
            "threshold should be > tolerance"
        );
        assert!(
            cfg.return_to_newton_threshold < 1e-1,
            "threshold should be < typical initial residual"
        );
    }

    /// Verify max_fallback_switches default prevents oscillation.
    #[test]
    fn test_max_fallback_switches_prevents_oscillation() {
        let cfg = FallbackConfig::default();
        // With max 2 switches: Newton -> Picard -> Newton = 2 switches max
        // This prevents infinite oscillation
        assert!(
            cfg.max_fallback_switches >= 1,
            "should allow at least one switch"
        );
        assert!(
            cfg.max_fallback_switches <= 5,
            "should not allow excessive switches"
        );
    }

    // ─────────────────────────────────────────────────────────────────────────────
    // Story 5.6: Control Variable Step Clipping Tests
    // ─────────────────────────────────────────────────────────────────────────────

    #[test]
    fn test_bounded_variable_clipped_at_max() {
        let mut state = vec![0.5];
        let delta = vec![2.0]; // Proposed step: 0.5 + 2.0 = 2.5
        let mask = vec![Some((0.0, 1.0))];
        super::apply_newton_step(&mut state, &delta, &mask, 1.0);
        assert_eq!(state[0], 1.0, "Should be clipped to max bound");
    }

    #[test]
    fn test_bounded_variable_clipped_at_min() {
        let mut state = vec![0.5];
        let delta = vec![-2.0]; // Proposed step: 0.5 - 2.0 = -1.5
        let mask = vec![Some((0.0, 1.0))];
        super::apply_newton_step(&mut state, &delta, &mask, 1.0);
        assert_eq!(state[0], 0.0, "Should be clipped to min bound");
    }

    #[test]
    fn test_edge_states_not_clipped() {
        let mut state = vec![0.5, 10.0];
        let delta = vec![-2.0, 50.0];
        // Only first variable is bounded
        let mask = vec![Some((0.0, 1.0)), None];
        super::apply_newton_step(&mut state, &delta, &mask, 1.0);
        assert_eq!(state[0], 0.0, "Bounded variable should be clipped");
        assert_eq!(state[1], 60.0, "Unbounded variable should NOT be clipped");
    }

    #[test]
    fn test_saturation_detected_after_convergence() {
        use crate::inverse::{BoundedVariable, BoundedVariableId, SaturationType};

        let mut sys = System::new();
        // A saturated variable (value = max bound)
        sys.add_bounded_variable(
            BoundedVariable::new(BoundedVariableId::new("v1"), 1.0, 0.0, 1.0).unwrap(),
        )
        .unwrap();
        // An unsaturated variable
        sys.add_bounded_variable(
            BoundedVariable::new(BoundedVariableId::new("v2"), 0.5, 0.0, 1.0).unwrap(),
        )
        .unwrap();

        let saturated = sys.saturated_variables();
        assert_eq!(saturated.len(), 1, "Should detect 1 saturated variable");
        assert_eq!(
            saturated[0].saturation_type,
            SaturationType::UpperBound,
            "Variable v1 should be saturated at max"
        );
        assert_eq!(saturated[0].variable_id.as_str(), "v1");
    }
}
