//! Solver trait abstraction and core types for thermodynamic system solving.
//!
//! Provides the `Solver` trait (object-safe interface) and `SolverStrategy` enum
//! (zero-cost static dispatch) for solver strategies.

use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;

use entropyk_solver_core::{ConvergenceReason, SolveOutcome};

use crate::criteria::ConvergenceReport;
use crate::metadata::SimulationMetadata;
use crate::system::System;

// Re-export strategies and their types
pub use crate::strategies::{
    FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, SolverStrategy,
};

// ─────────────────────────────────────────────────────────────────────────────
// 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,
    },

    /// A recoverable component domain violation (KINSOL `> 0` convention)
    /// could not be absorbed by step reduction.
    ///
    /// Strategies convert recoverable violations into smaller trial steps and
    /// retry; this error is raised only when that retry budget is exhausted
    /// (or when no retry mechanism exists at the evaluation site). Recoverable
    /// exhaustion is an *outcome*, not a crash: it classifies as
    /// [`ConvergenceReason::DomainViolation`] and surfaces through
    /// [`Solver::solve_outcome`] as `Ok(SolveOutcome { .. })`.
    #[error("{0}")]
    DomainViolation(entropyk_solver_core::DomainViolation),

    /// Solver failure with attached post-mortem convergence diagnostics.
    ///
    /// This wrapper keeps the existing concrete error variants unchanged, so
    /// callers that do not need diagnostics can keep matching those variants.
    #[error("{error}")]
    WithDiagnostics {
        /// Original solver error.
        error: Box<SolverError>,
        /// Diagnostics captured before the failure was returned.
        diagnostics: Box<ConvergenceDiagnostics>,
    },
}

/// Converts a solver-core domain violation into the typed solver error.
///
/// Manual impl (instead of thiserror's `#[from]`) because thiserror 1.x
/// treats `#[from]` fields as error sources, which would require
/// `DomainViolation` to implement `std::error::Error` — it is an alloc-gated
/// `no_std` outcome carrier with only `Display`, by design.
impl From<entropyk_solver_core::DomainViolation> for SolverError {
    fn from(violation: entropyk_solver_core::DomainViolation) -> Self {
        Self::DomainViolation(violation)
    }
}

impl SolverError {
    /// Attach diagnostics to an error without changing its display text.
    pub fn with_diagnostics(self, diagnostics: ConvergenceDiagnostics) -> Self {
        if diagnostics.iteration_history.is_empty() {
            return self;
        }

        let base_error = self.without_diagnostics();
        Self::WithDiagnostics {
            error: Box::new(base_error),
            diagnostics: Box::new(diagnostics),
        }
    }

    /// Attach diagnostics only when they are available.
    pub fn with_optional_diagnostics(self, diagnostics: Option<ConvergenceDiagnostics>) -> Self {
        match diagnostics {
            Some(diagnostics) => self.with_diagnostics(diagnostics),
            None => self,
        }
    }

    /// Returns diagnostics captured for this failure, if any.
    pub fn diagnostics(&self) -> Option<&ConvergenceDiagnostics> {
        match self {
            Self::WithDiagnostics { diagnostics, .. } => Some(diagnostics),
            _ => None,
        }
    }

    /// Returns the original concrete error variant, ignoring diagnostics.
    pub fn base_error(&self) -> &SolverError {
        match self {
            Self::WithDiagnostics { error, .. } => error.base_error(),
            _ => self,
        }
    }

    /// Removes any diagnostics wrapper and returns the original error.
    pub fn without_diagnostics(self) -> SolverError {
        match self {
            Self::WithDiagnostics { error, .. } => error.without_diagnostics(),
            _ => self,
        }
    }

    /// Classifies this error as a typed convergence outcome, if it is one (FR8).
    ///
    /// Outcome-style terminations map to their [`ConvergenceReason`]:
    /// - `NonConvergence` → [`ConvergenceReason::MaxIters`]
    /// - `Timeout` → [`ConvergenceReason::TimedOut`]
    /// - `Divergence` → [`ConvergenceReason::Stalled`]
    /// - `DomainViolation` → [`ConvergenceReason::DomainViolation`]
    /// - `WithDiagnostics` delegates to the wrapped error.
    ///
    /// Hard errors (`InvalidSystem`, `Validation`) return `None`: they remain
    /// `Err` and never become outcome data.
    pub fn convergence_reason(&self) -> Option<ConvergenceReason> {
        match self {
            SolverError::NonConvergence { .. } => Some(ConvergenceReason::MaxIters),
            SolverError::Timeout { .. } => Some(ConvergenceReason::TimedOut),
            SolverError::Divergence { .. } => Some(ConvergenceReason::Stalled),
            SolverError::DomainViolation(_) => Some(ConvergenceReason::DomainViolation),
            SolverError::WithDiagnostics { error, .. } => error.convergence_reason(),
            SolverError::InvalidSystem { .. } | SolverError::Validation { .. } => None,
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 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>,

    /// Traceability metadata for reproducibility.
    pub metadata: SimulationMetadata,

    /// Optional convergence diagnostics (Story 7.4).
    ///
    /// `Some(diagnostics)` when verbose mode was enabled during solving.
    /// `None` when verbose mode was disabled (backward-compatible default).
    pub diagnostics: Option<ConvergenceDiagnostics>,
}

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,
        metadata: SimulationMetadata,
    ) -> Self {
        Self {
            state,
            iterations,
            final_residual,
            status,
            convergence_report: None,
            metadata,
            diagnostics: 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,
        metadata: SimulationMetadata,
    ) -> Self {
        Self {
            state,
            iterations,
            final_residual,
            status,
            convergence_report: Some(report),
            metadata,
            diagnostics: None,
        }
    }

    /// Creates a `ConvergedState` with attached diagnostics.
    pub fn with_diagnostics(
        state: Vec<f64>,
        iterations: usize,
        final_residual: f64,
        status: ConvergenceStatus,
        metadata: SimulationMetadata,
        diagnostics: ConvergenceDiagnostics,
    ) -> Self {
        Self {
            state,
            iterations,
            final_residual,
            status,
            convergence_report: None,
            metadata,
            diagnostics: Some(diagnostics),
        }
    }

    /// 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>;

    /// Solve the system and report the termination as typed outcome data (FR8).
    ///
    /// Unlike [`Solver::solve`], a non-converged termination (max iterations,
    /// stall, timeout) is **not** a hard `Err`: it returns
    /// `Ok(SolveOutcome { reason, .. })` with the matching
    /// [`ConvergenceReason`]. Hard errors (invalid system, validation) remain
    /// `Err`. "No solution" is an outcome, not an exception.
    ///
    /// The default implementation delegates to [`Solver::solve`] and
    /// classifies the result; strategies may override it to report richer
    /// outcomes (e.g. [`ConvergenceReason::LikelyNoSolution`]).
    ///
    /// When the underlying termination carried no iteration/residual payload,
    /// `iterations` is `0` and `final_residual` is `f64::NAN`.
    fn solve_outcome(&mut self, system: &mut System) -> Result<SolveOutcome, SolverError> {
        match self.solve(system) {
            Ok(state) => {
                let reason = match state.status {
                    ConvergenceStatus::TimedOutWithBestState => ConvergenceReason::TimedOut,
                    ConvergenceStatus::Converged | ConvergenceStatus::ControlSaturation => {
                        ConvergenceReason::Converged
                    }
                };
                Ok(SolveOutcome {
                    reason,
                    iterations: state.iterations,
                    final_residual: state.final_residual,
                    state: Some(state.state),
                })
            }
            Err(err) => match err.convergence_reason() {
                Some(reason) => {
                    let (iterations, final_residual) = match err.base_error() {
                        SolverError::NonConvergence {
                            iterations,
                            final_residual,
                        } => (*iterations, *final_residual),
                        _ => (0, f64::NAN),
                    };
                    Ok(SolveOutcome {
                        reason,
                        iterations,
                        final_residual,
                        state: None,
                    })
                }
                None => Err(err),
            },
        }
    }

    /// 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,
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Verbose Mode Configuration (Story 7.4)
// ─────────────────────────────────────────────────────────────────────────────

/// Output format for verbose diagnostics.
///
/// Controls how convergence diagnostics are presented to the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum VerboseOutputFormat {
    /// Output diagnostics via `tracing` logs only.
    Log,
    /// Output diagnostics as structured JSON.
    Json,
    /// Output via both logging and JSON.
    #[default]
    Both,
}

/// Configuration for debug verbose mode in solvers.
///
/// When enabled, provides detailed convergence diagnostics to help debug
/// non-converging thermodynamic systems. This includes per-iteration residuals,
/// Jacobian condition numbers, solver switch events, and final state dumps.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{VerboseConfig, VerboseOutputFormat};
///
/// // Enable all verbose features
/// let verbose = VerboseConfig {
///     enabled: true,
///     log_residuals: true,
///     log_jacobian_condition: true,
///     log_solver_switches: true,
///     dump_final_state: true,
///     output_format: VerboseOutputFormat::Both,
/// };
///
/// // Default: all features disabled (backward compatible)
/// let default_config = VerboseConfig::default();
/// assert!(!default_config.enabled);
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VerboseConfig {
    /// Master switch for verbose mode.
    ///
    /// When `false`, all verbose output is disabled regardless of other settings.
    /// Default: `false` (backward compatible).
    pub enabled: bool,

    /// Log residuals at each iteration.
    ///
    /// When `true`, emits `tracing::info!` logs with iteration number,
    /// residual norm, and delta from previous iteration.
    /// Default: `false`.
    pub log_residuals: bool,

    /// Report Jacobian condition number.
    ///
    /// When `true`, computes and logs the Jacobian condition number
    /// (ratio of largest to smallest singular values). Values > 1e10
    /// indicate an ill-conditioned system.
    /// Default: `false`.
    ///
    /// **Note:** Condition number estimation is O(n³) and may impact
    /// performance for large systems.
    pub log_jacobian_condition: bool,

    /// Log solver switch events.
    ///
    /// When `true`, logs when the fallback solver switches between
    /// Newton-Raphson and Sequential Substitution, including the reason.
    /// Default: `false`.
    pub log_solver_switches: bool,

    /// Dump final state on non-convergence.
    ///
    /// When `true`, dumps the final state vector and diagnostics
    /// when the solver fails to converge, for post-mortem analysis.
    /// Default: `false`.
    pub dump_final_state: bool,

    /// Output format for diagnostics.
    ///
    /// Default: `VerboseOutputFormat::Both`.
    pub output_format: VerboseOutputFormat,
}

impl Default for VerboseConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            log_residuals: false,
            log_jacobian_condition: false,
            log_solver_switches: false,
            dump_final_state: false,
            output_format: VerboseOutputFormat::default(),
        }
    }
}

impl VerboseConfig {
    /// Creates a new `VerboseConfig` with all features enabled.
    pub fn all_enabled() -> Self {
        Self {
            enabled: true,
            log_residuals: true,
            log_jacobian_condition: true,
            log_solver_switches: true,
            dump_final_state: true,
            output_format: VerboseOutputFormat::Both,
        }
    }

    /// Returns `true` if any verbose feature is enabled.
    pub fn is_any_enabled(&self) -> bool {
        self.enabled
            && (self.log_residuals
                || self.log_jacobian_condition
                || self.log_solver_switches
                || self.dump_final_state)
    }
}

/// Per-iteration diagnostics captured during solving.
///
/// Records the state of the solver at each iteration for debugging
/// and post-mortem analysis.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct IterationDiagnostics {
    /// Iteration number (0-indexed).
    pub iteration: usize,

    /// $\ell_2$ norm of the residual vector.
    pub residual_norm: f64,

    /// Norm of the change from previous iteration ($\|\Delta x\|$).
    pub delta_norm: f64,

    /// Line search step size (Newton-Raphson only).
    ///
    /// `None` for Sequential Substitution or if line search was not used.
    pub alpha: Option<f64>,

    /// Whether the Jacobian was reused (frozen) this iteration.
    pub jacobian_frozen: bool,

    /// Jacobian condition number (if computed).
    ///
    /// Only populated when `log_jacobian_condition` is enabled.
    pub jacobian_condition: Option<f64>,

    /// Index of the equation with the largest absolute residual this iteration.
    ///
    /// Inspired by IPM's NLPD toolchain: pinpoints which residual (and hence
    /// which component / state slot) is dominating convergence so a slow or
    /// stalled solve can be diagnosed. `None` if the residual vector is empty.
    #[serde(default)]
    pub max_residual_index: Option<usize>,

    /// Magnitude of the largest absolute residual this iteration
    /// ($\max_i |r_i|$, the $\ell_\infty$ residual norm).
    #[serde(default)]
    pub max_residual: f64,

    /// Number of recoverable component domain violations (KINSOL `> 0`
    /// events) absorbed by step reduction during this iteration.
    ///
    /// Populated by the Newton line search; `0` for strategies without a
    /// recoverable-aware retry path or when no trial evaluation left the
    /// component's physical domain.
    #[serde(default)]
    pub recoverable_events: u32,
}

/// Identifies the dominant (largest-magnitude) entry of a residual vector.
///
/// Returns the `(index, magnitude)` of the equation with the largest absolute
/// residual — the $\ell_\infty$ norm together with its location. Returns
/// `(None, 0.0)` for an empty vector. NaN entries are treated as dominant so
/// they are surfaced rather than hidden.
pub fn dominant_residual(residuals: &[f64]) -> (Option<usize>, f64) {
    let mut index = None;
    let mut max = 0.0_f64;
    for (i, &r) in residuals.iter().enumerate() {
        let magnitude = r.abs();
        if index.is_none() || magnitude.is_nan() || magnitude > max {
            index = Some(i);
            max = magnitude;
            if magnitude.is_nan() {
                break;
            }
        }
    }
    (index, max)
}

/// Type of solver being used.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SolverType {
    /// Newton-Raphson solver.
    NewtonRaphson,
    /// Sequential Substitution (Picard) solver.
    SequentialSubstitution,
    /// Newton-homotopy continuation solver.
    Homotopy,
}

impl std::fmt::Display for SolverType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SolverType::NewtonRaphson => write!(f, "Newton-Raphson"),
            SolverType::SequentialSubstitution => write!(f, "Sequential Substitution"),
            SolverType::Homotopy => write!(f, "Newton-Homotopy"),
        }
    }
}

/// Reason for solver switch in fallback strategy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SwitchReason {
    /// Newton-Raphson diverged (residual increasing).
    Divergence,
    /// Newton-Raphson converging too slowly.
    SlowConvergence,
    /// User explicitly requested switch.
    UserRequested,
    /// Returning to Newton-Raphson after Picard stabilized.
    ReturnToNewton,
}

impl std::fmt::Display for SwitchReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SwitchReason::Divergence => write!(f, "divergence detected"),
            SwitchReason::SlowConvergence => write!(f, "slow convergence"),
            SwitchReason::UserRequested => write!(f, "user requested"),
            SwitchReason::ReturnToNewton => write!(f, "returning to Newton after stabilization"),
        }
    }
}

/// Event record for solver switches in fallback strategy.
///
/// Captures when and why the solver switched between strategies.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SolverSwitchEvent {
    /// Solver being switched from.
    pub from_solver: SolverType,

    /// Solver being switched to.
    pub to_solver: SolverType,

    /// Reason for the switch.
    pub reason: SwitchReason,

    /// Iteration number at which the switch occurred.
    pub iteration: usize,

    /// Residual norm at the time of switch.
    pub residual_at_switch: f64,
}

/// Comprehensive convergence diagnostics for a solve attempt.
///
/// Contains all diagnostic information collected during solving,
/// suitable for JSON serialization and post-mortem analysis.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ConvergenceDiagnostics {
    /// Total iterations performed.
    pub iterations: usize,

    /// Final residual norm.
    pub final_residual: f64,

    /// Best residual norm achieved during iteration.
    pub best_residual: f64,

    /// Whether the solver converged.
    pub converged: bool,

    /// Per-iteration diagnostics history.
    pub iteration_history: Vec<IterationDiagnostics>,

    /// Solver switch events (fallback strategy only).
    pub solver_switches: Vec<SolverSwitchEvent>,

    /// Final state vector (populated on non-convergence if `dump_final_state` enabled).
    pub final_state: Option<Vec<f64>>,

    /// Jacobian condition number at final iteration.
    pub jacobian_condition_final: Option<f64>,

    /// Total solve time in milliseconds.
    pub timing_ms: u64,

    /// Solver type used for the final iteration.
    pub final_solver: Option<SolverType>,
}

impl ConvergenceDiagnostics {
    /// Creates a new empty `ConvergenceDiagnostics`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Pre-allocates iteration history for `max_iterations` entries.
    pub fn with_capacity(max_iterations: usize) -> Self {
        Self {
            iteration_history: Vec::with_capacity(max_iterations),
            ..Self::default()
        }
    }

    /// Adds an iteration's diagnostics to the history.
    pub fn push_iteration(&mut self, diagnostics: IterationDiagnostics) {
        self.iteration_history.push(diagnostics);
    }

    /// Records a solver switch event.
    pub fn push_switch(&mut self, event: SolverSwitchEvent) {
        self.solver_switches.push(event);
    }

    /// Returns the location and magnitude of the dominant residual at the final
    /// recorded iteration: `(equation_index, magnitude)`.
    ///
    /// This is the NLPD-style "bottleneck equation" — the residual that was
    /// hardest to drive to zero when the solve stopped. Returns `None` if no
    /// iterations were recorded.
    pub fn final_dominant_residual(&self) -> Option<(usize, f64)> {
        self.iteration_history
            .last()
            .and_then(|it| it.max_residual_index.map(|i| (i, it.max_residual)))
    }

    /// Returns a human-readable summary of the diagnostics.
    pub fn summary(&self) -> String {
        let converged_str = if self.converged { "YES" } else { "NO" };
        let switch_count = self.solver_switches.len();

        let mut summary = format!(
            "Convergence Diagnostics Summary\n\
             ===============================\n\
             Converged: {}\n\
             Iterations: {}\n\
             Final Residual: {:.3e}\n\
             Best Residual: {:.3e}\n\
             Solver Switches: {}\n\
             Timing: {} ms",
            converged_str,
            self.iterations,
            self.final_residual,
            self.best_residual,
            switch_count,
            self.timing_ms
        );

        if let Some(cond) = self.jacobian_condition_final {
            summary.push_str(&format!("\nJacobian Condition: {:.3e}", cond));
            if cond > 1e10 {
                summary.push_str(" (WARNING: ill-conditioned)");
            }
        }

        if let Some(ref solver) = self.final_solver {
            summary.push_str(&format!("\nFinal Solver: {}", solver));
        }

        if let Some((idx, mag)) = self.final_dominant_residual() {
            summary.push_str(&format!("\nDominant Residual: eq[{}] = {:.3e}", idx, mag));
        }

        summary
    }

    /// Dumps diagnostics to the configured output format.
    ///
    /// Returns JSON string if `format` is `Json` or `Both`, suitable for
    /// file output or structured logging.
    pub fn dump_diagnostics(&self, format: VerboseOutputFormat) -> String {
        match format {
            VerboseOutputFormat::Log => self.summary(),
            VerboseOutputFormat::Json | VerboseOutputFormat::Both => {
                serde_json::to_string_pretty(self).unwrap_or_else(|e| {
                    format!("{{\"error\": \"Failed to serialize diagnostics: {}\"}}", e)
                })
            }
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Helper functions
// ─────────────────────────────────────────────────────────────────────────────

/// Applies a Newton step to the state vector, clamping bounded variables.
///
/// Update formula: x_new = clamp(x_old + alpha * delta)
pub(crate) 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] {
            // `f64::clamp` panics if min > max or either bound is NaN. Guard the
            // bounds explicitly to honour the zero-panic policy: an invalid bound
            // pair is treated as "no constraint" rather than crashing the solver.
            Some((min, max)) if min.is_finite() && max.is_finite() && min <= max => {
                proposed.clamp(*min, *max)
            }
            _ => proposed,
        };
    }
}

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

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

    /// Verify that `Box<dyn Solver>` compiles (trait is object-safe for `solve`).
    #[test]
    fn test_solver_trait_object_safety() {
        fn accepts_dyn_solver(_solver: &mut dyn Solver) {}
        let mut newton = NewtonConfig::default();
        accepts_dyn_solver(&mut newton);
    }

    /// Inverted or NaN bound pairs must not panic `apply_newton_step` (the
    /// `f64::clamp` zero-panic guard); they are treated as "no constraint".
    #[test]
    fn test_apply_newton_step_invalid_bounds_do_not_panic() {
        let mut state = vec![0.0, 0.0, 0.0];
        let delta = vec![1.0, 1.0, 1.0];
        let mask = vec![
            Some((10.0, -10.0)),   // inverted: min > max
            Some((f64::NAN, 1.0)), // NaN bound
            Some((-5.0, 5.0)),     // valid: should clamp normally
        ];
        apply_newton_step(&mut state, &delta, &mask, 1.0);
        // Invalid bounds → unconstrained update applied.
        assert_eq!(state[0], 1.0);
        assert_eq!(state[1], 1.0);
        // Valid bounds → normal clamp (1.0 is within [-5, 5]).
        assert_eq!(state[2], 1.0);
    }

    /// 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());
    }

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

    /// 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,
            SimulationMetadata::new("mock_hash".to_string()),
        );
        assert_eq!(state.state, vec![1.0, 2.0, 3.0]);
        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,
            SimulationMetadata::new("mock_hash".to_string()),
        );
        assert!(!state.is_converged());
    }

    /// Verify `Box<dyn Solver>` can call `solve()` through the vtable.
    #[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);
        assert!(result.is_err(), "dyn Solver::solve must dispatch correctly");
    }

    // ── 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];
        let mask = vec![Some((0.0, 1.0))];
        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];
        let mask = vec![Some((0.0, 1.0))];
        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];
        let mask = vec![Some((0.0, 1.0)), None];
        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");
    }

    // ── NLPD-style dominant-residual diagnostics ──────────────────────────────

    #[test]
    fn test_dominant_residual_picks_largest_magnitude() {
        let residuals = vec![0.1, -5.0, 2.0, -0.3];
        let (idx, mag) = dominant_residual(&residuals);
        assert_eq!(idx, Some(1), "index 1 has the largest |residual|");
        assert!((mag - 5.0).abs() < 1e-15);
    }

    #[test]
    fn test_dominant_residual_empty_vector() {
        let (idx, mag) = dominant_residual(&[]);
        assert_eq!(idx, None);
        assert_eq!(mag, 0.0);
    }

    #[test]
    fn test_dominant_residual_surfaces_nan() {
        let residuals = vec![1.0, f64::NAN, 0.5];
        let (idx, _mag) = dominant_residual(&residuals);
        assert_eq!(idx, Some(1), "NaN residual must be surfaced, not hidden");
    }

    #[test]
    fn test_final_dominant_residual_reports_last_iteration() {
        let mut diag = ConvergenceDiagnostics::new();
        assert_eq!(diag.final_dominant_residual(), None);

        diag.push_iteration(IterationDiagnostics {
            iteration: 0,
            residual_norm: 10.0,
            max_residual_index: Some(2),
            max_residual: 8.0,
            ..Default::default()
        });
        diag.push_iteration(IterationDiagnostics {
            iteration: 1,
            residual_norm: 1.0,
            max_residual_index: Some(5),
            max_residual: 0.9,
            ..Default::default()
        });

        assert_eq!(diag.final_dominant_residual(), Some((5, 0.9)));
    }

    #[test]
    fn test_diagnostics_dominant_residual_serializes_to_json() {
        let mut diag = ConvergenceDiagnostics::new();
        diag.push_iteration(IterationDiagnostics {
            iteration: 0,
            max_residual_index: Some(3),
            max_residual: 4.2,
            ..Default::default()
        });
        let json = diag.dump_diagnostics(VerboseOutputFormat::Json);
        assert!(json.contains("max_residual_index"));
        assert!(json.contains("max_residual"));
    }

    #[test]
    fn test_solver_error_exposes_failure_diagnostics() {
        let mut diag = ConvergenceDiagnostics::new();
        diag.iterations = 2;
        diag.final_residual = 12.0;
        diag.push_iteration(IterationDiagnostics {
            iteration: 2,
            residual_norm: 12.0,
            max_residual_index: Some(7),
            max_residual: 11.5,
            ..Default::default()
        });

        let err = SolverError::NonConvergence {
            iterations: 2,
            final_residual: 12.0,
        }
        .with_diagnostics(diag);

        assert!(matches!(
            err.base_error(),
            SolverError::NonConvergence { .. }
        ));
        let diagnostics = err.diagnostics().expect("diagnostics should be attached");
        assert_eq!(diagnostics.final_residual, 12.0);
        assert_eq!(diagnostics.final_dominant_residual(), Some((7, 11.5)));
    }
}
