use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use std::panic;
use std::time::Duration;

use crate::components::AnyPyComponent;

// =============================================================================
// System
// =============================================================================

/// The thermodynamic system graph.
///
/// Components are added as nodes, flow connections as edges.
/// Call ``finalize()`` before solving.
///
/// Example::
///
///     system = System()
///     comp_idx = system.add_component(Compressor())
///     cond_idx = system.add_component(Condenser(ua=5000.0))
///     system.add_edge(comp_idx, cond_idx)
///     system.finalize()
#[pyclass(name = "System", module = "entropyk", unsendable)]
pub struct PySystem {
    pub(crate) inner: entropyk_solver::System,
}

#[pyclass(name = "Constraint")]
#[derive(Clone)]
pub struct PyConstraint {
    pub(crate) inner: entropyk_solver::inverse::Constraint,
}

#[pymethods]
impl PyConstraint {
    #[staticmethod]
    #[pyo3(signature = (id, component_id, target_value, tolerance=1e-6))]
    fn superheat(id: String, component_id: String, target_value: f64, tolerance: f64) -> Self {
        use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
        Self {
            inner: Constraint::with_tolerance(
                ConstraintId::new(id),
                ComponentOutput::Superheat { component_id },
                target_value,
                tolerance,
            )
            .unwrap(),
        }
    }

    #[staticmethod]
    #[pyo3(signature = (id, component_id, target_value, tolerance=1e-6))]
    fn subcooling(id: String, component_id: String, target_value: f64, tolerance: f64) -> Self {
        use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
        Self {
            inner: Constraint::with_tolerance(
                ConstraintId::new(id),
                ComponentOutput::Subcooling { component_id },
                target_value,
                tolerance,
            )
            .unwrap(),
        }
    }

    #[staticmethod]
    #[pyo3(signature = (id, component_id, target_value, tolerance=1e-6))]
    fn capacity(id: String, component_id: String, target_value: f64, tolerance: f64) -> Self {
        use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
        Self {
            inner: Constraint::with_tolerance(
                ConstraintId::new(id),
                ComponentOutput::Capacity { component_id },
                target_value,
                tolerance,
            )
            .unwrap(),
        }
    }

    fn __repr__(&self) -> String {
        format!(
            "Constraint(id='{}', target={}, tol={})",
            self.inner.id(),
            self.inner.target_value(),
            self.inner.tolerance()
        )
    }
}

#[pyclass(name = "BoundedVariable")]
#[derive(Clone)]
pub struct PyBoundedVariable {
    pub(crate) inner: entropyk_solver::inverse::BoundedVariable,
}

#[pymethods]
impl PyBoundedVariable {
    #[new]
    #[pyo3(signature = (id, value, min, max, component_id=None))]
    fn new(
        id: String,
        value: f64,
        min: f64,
        max: f64,
        component_id: Option<String>,
    ) -> PyResult<Self> {
        use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
        let inner = match component_id {
            Some(cid) => {
                BoundedVariable::with_component(BoundedVariableId::new(id), cid, value, min, max)
            }
            None => BoundedVariable::new(BoundedVariableId::new(id), value, min, max),
        };
        match inner {
            Ok(v) => Ok(Self { inner: v }),
            Err(e) => Err(PyValueError::new_err(e.to_string())),
        }
    }

    fn __repr__(&self) -> String {
        // use is_saturated if available but simpler:
        format!(
            "BoundedVariable(id='{}', value={}, bounds=[{}, {}])",
            self.inner.id(),
            self.inner.value(),
            self.inner.min(),
            self.inner.max()
        )
    }
}

#[pymethods]
impl PySystem {
    #[new]
    fn new() -> Self {
        PySystem {
            inner: entropyk_solver::System::new(),
        }
    }

    /// Add a component to the system. Returns the node index.
    ///
    /// Args:
    ///     component: A component (Compressor, Condenser, Evaporator, etc.).
    ///
    /// Returns:
    ///     int: The node index of the added component.
    fn add_component(&mut self, component: &Bound<'_, PyAny>) -> PyResult<usize> {
        let py_comp = extract_component(component)?;
        let boxed = py_comp.build();
        let idx = self.inner.add_component(boxed);
        Ok(idx.index())
    }

    /// Add a flow edge from source to target.
    ///
    /// Args:
    ///     source: Source node index (from ``add_component``).
    ///     target: Target node index (from ``add_component``).
    ///
    /// Returns:
    ///     int: The edge index.
    fn add_edge(&mut self, source: usize, target: usize) -> PyResult<usize> {
        let src = petgraph::graph::NodeIndex::new(source);
        let tgt = petgraph::graph::NodeIndex::new(target);
        let edge = self
            .inner
            .add_edge(src, tgt)
            .map_err(|e| crate::errors::TopologyError::new_err(e.to_string()))?;
        Ok(edge.index())
    }

    /// Register a human-readable name for a component node to be used in Constraints.
    fn register_component_name(&mut self, name: &str, node_idx: usize) -> PyResult<()> {
        let node = petgraph::graph::NodeIndex::new(node_idx);
        self.inner.register_component_name(name, node);
        Ok(())
    }

    /// Add a constraint to the system.
    fn add_constraint(&mut self, constraint: &PyConstraint) -> PyResult<()> {
        self.inner
            .add_constraint(constraint.inner.clone())
            .map_err(|e| PyValueError::new_err(e.to_string()))
    }

    /// Add a bounded variable to the system.
    fn add_bounded_variable(&mut self, variable: &PyBoundedVariable) -> PyResult<()> {
        self.inner
            .add_bounded_variable(variable.inner.clone())
            .map_err(|e| PyValueError::new_err(e.to_string()))
    }

    /// Link a constraint to a control variable for the inverse solver.
    fn link_constraint_to_control(
        &mut self,
        constraint_id: &str,
        control_id: &str,
    ) -> PyResult<()> {
        use entropyk_solver::inverse::{BoundedVariableId, ConstraintId};
        self.inner
            .link_constraint_to_control(
                &ConstraintId::new(constraint_id),
                &BoundedVariableId::new(control_id),
            )
            .map_err(|e| PyValueError::new_err(e.to_string()))
    }

    /// Finalize the system graph: build state index mapping and validate topology.
    ///
    /// Must be called before ``solve()``.
    fn finalize(&mut self) -> PyResult<()> {
        self.inner
            .finalize()
            .map_err(|e| crate::errors::TopologyError::new_err(e.to_string()))
    }

    /// Number of nodes (components) in the system graph.
    #[getter]
    fn node_count(&self) -> usize {
        self.inner.node_count()
    }

    /// Number of edges in the system graph.
    #[getter]
    fn edge_count(&self) -> usize {
        self.inner.edge_count()
    }

    /// Length of the state vector after finalization.
    #[getter]
    fn state_vector_len(&self) -> usize {
        self.inner.state_vector_len()
    }

    fn __repr__(&self) -> String {
        format!(
            "System(nodes={}, edges={})",
            self.inner.node_count(),
            self.inner.edge_count()
        )
    }
}

/// Extract a Python component wrapper into our internal enum.
fn extract_component(obj: &Bound<'_, PyAny>) -> PyResult<AnyPyComponent> {
    if let Ok(c) = obj.extract::<crate::components::PyCompressor>() {
        return Ok(AnyPyComponent::Compressor(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyScrewEconomizerCompressor>() {
        return Ok(AnyPyComponent::ScrewEconomizerCompressor(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyCondenser>() {
        return Ok(AnyPyComponent::Condenser(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyEvaporator>() {
        return Ok(AnyPyComponent::Evaporator(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyEconomizer>() {
        return Ok(AnyPyComponent::Economizer(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyExpansionValve>() {
        return Ok(AnyPyComponent::ExpansionValve(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyPipe>() {
        return Ok(AnyPyComponent::Pipe(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyPump>() {
        return Ok(AnyPyComponent::Pump(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyFan>() {
        return Ok(AnyPyComponent::Fan(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyFlowSplitter>() {
        return Ok(AnyPyComponent::FlowSplitter(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyFlowMerger>() {
        return Ok(AnyPyComponent::FlowMerger(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyFlowSource>() {
        return Ok(AnyPyComponent::FlowSource(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyFlowSink>() {
        return Ok(AnyPyComponent::FlowSink(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyRefrigerantSource>() {
        return Ok(AnyPyComponent::RefrigerantSource(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyRefrigerantSink>() {
        return Ok(AnyPyComponent::RefrigerantSink(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyBrineSource>() {
        return Ok(AnyPyComponent::BrineSource(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyBrineSink>() {
        return Ok(AnyPyComponent::BrineSink(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyAirSource>() {
        return Ok(AnyPyComponent::AirSource(c));
    }
    if let Ok(c) = obj.extract::<crate::components::PyAirSink>() {
        return Ok(AnyPyComponent::AirSink(c));
    }
    Err(PyValueError::new_err(
        "Expected a component (Compressor, ScrewEconomizerCompressor, Condenser, Evaporator, ExpansionValve, Pipe, Pump, Fan, Economizer, FlowSplitter, FlowMerger, FlowSource, FlowSink, RefrigerantSource, RefrigerantSink, BrineSource, BrineSink, AirSource, AirSink)",
    ))
}

/// Convert a `SolverError` into a Python exception using the appropriate type.
fn solver_error_to_pyerr(err: entropyk_solver::SolverError) -> PyErr {
    let msg = err.to_string();
    match &err {
        entropyk_solver::SolverError::Timeout { .. } => crate::errors::TimeoutError::new_err(msg),
        _ => crate::errors::SolverError::new_err(msg),
    }
}

// =============================================================================
// Supporting Types (Story 6.6)
// =============================================================================

#[pyclass(name = "ConvergenceCriteria", module = "entropyk")]
#[derive(Clone, Default)]
pub struct PyConvergenceCriteria {
    pub(crate) inner: entropyk_solver::criteria::ConvergenceCriteria,
}

#[pymethods]
impl PyConvergenceCriteria {
    #[new]
    #[pyo3(signature = (pressure_tolerance_pa=1.0, mass_balance_tolerance_kgs=1e-9, energy_balance_tolerance_w=1e-3))]
    fn new(
        pressure_tolerance_pa: f64,
        mass_balance_tolerance_kgs: f64,
        energy_balance_tolerance_w: f64,
    ) -> Self {
        PyConvergenceCriteria {
            inner: entropyk_solver::criteria::ConvergenceCriteria {
                pressure_tolerance_pa,
                mass_balance_tolerance_kgs,
                energy_balance_tolerance_w,
            },
        }
    }

    #[getter]
    fn pressure_tolerance_pa(&self) -> f64 {
        self.inner.pressure_tolerance_pa
    }

    #[getter]
    fn mass_balance_tolerance_kgs(&self) -> f64 {
        self.inner.mass_balance_tolerance_kgs
    }

    #[getter]
    fn energy_balance_tolerance_w(&self) -> f64 {
        self.inner.energy_balance_tolerance_w
    }

    fn __repr__(&self) -> String {
        format!(
            "ConvergenceCriteria(dP={:.1e} Pa, dM={:.1e} kg/s, dE={:.1e} W)",
            self.inner.pressure_tolerance_pa,
            self.inner.mass_balance_tolerance_kgs,
            self.inner.energy_balance_tolerance_w
        )
    }
}

#[pyclass(name = "JacobianFreezingConfig", module = "entropyk")]
#[derive(Clone, Default)]
pub struct PyJacobianFreezingConfig {
    pub(crate) inner: entropyk_solver::solver::JacobianFreezingConfig,
}

#[pymethods]
impl PyJacobianFreezingConfig {
    #[new]
    #[pyo3(signature = (max_frozen_iters=3, threshold=0.1))]
    fn new(max_frozen_iters: usize, threshold: f64) -> Self {
        PyJacobianFreezingConfig {
            inner: entropyk_solver::solver::JacobianFreezingConfig {
                max_frozen_iters,
                threshold,
            },
        }
    }

    #[getter]
    fn max_frozen_iters(&self) -> usize {
        self.inner.max_frozen_iters
    }

    #[getter]
    fn threshold(&self) -> f64 {
        self.inner.threshold
    }

    fn __repr__(&self) -> String {
        format!(
            "JacobianFreezingConfig(max_iters={}, threshold={:.2})",
            self.inner.max_frozen_iters, self.inner.threshold
        )
    }
}

#[pyclass(name = "TimeoutConfig", module = "entropyk")]
#[derive(Clone, Default)]
pub struct PyTimeoutConfig {
    pub(crate) inner: entropyk_solver::solver::TimeoutConfig,
}

#[pymethods]
impl PyTimeoutConfig {
    #[new]
    #[pyo3(signature = (return_best_state_on_timeout=true, zoh_fallback=false))]
    fn new(return_best_state_on_timeout: bool, zoh_fallback: bool) -> Self {
        PyTimeoutConfig {
            inner: entropyk_solver::solver::TimeoutConfig {
                return_best_state_on_timeout,
                zoh_fallback,
            },
        }
    }

    #[getter]
    fn return_best_state_on_timeout(&self) -> bool {
        self.inner.return_best_state_on_timeout
    }

    #[getter]
    fn zoh_fallback(&self) -> bool {
        self.inner.zoh_fallback
    }

    fn __repr__(&self) -> String {
        format!(
            "TimeoutConfig(return_best={}, zoh={})",
            self.inner.return_best_state_on_timeout, self.inner.zoh_fallback
        )
    }
}

// =============================================================================
// NewtonConfig
// =============================================================================

/// Configuration for the Newton-Raphson solver.
///
/// Example::
///
///     config = NewtonConfig(max_iterations=100, tolerance=1e-6)
///     result = config.solve(system)
#[pyclass(name = "NewtonConfig", module = "entropyk")]
#[derive(Clone, Default)]
pub struct PyNewtonConfig {
    #[pyo3(get, set)]
    pub(crate) max_iterations: usize,
    #[pyo3(get, set)]
    pub(crate) tolerance: f64,
    #[pyo3(get, set)]
    pub(crate) line_search: bool,
    #[pyo3(get, set)]
    pub(crate) timeout_ms: Option<u64>,
    #[pyo3(get, set)]
    pub(crate) initial_state: Option<Vec<f64>>,
    #[pyo3(get, set)]
    pub(crate) use_numerical_jacobian: bool,
    #[pyo3(get, set)]
    pub(crate) jacobian_freezing: Option<PyJacobianFreezingConfig>,
    #[pyo3(get, set)]
    pub(crate) convergence_criteria: Option<PyConvergenceCriteria>,
    #[pyo3(get, set)]
    pub(crate) timeout_config: PyTimeoutConfig,
    #[pyo3(get, set)]
    pub(crate) previous_state: Option<Vec<f64>>,
    #[pyo3(get, set)]
    pub(crate) line_search_armijo_c: f64,
    #[pyo3(get, set)]
    pub(crate) line_search_max_backtracks: usize,
    #[pyo3(get, set)]
    pub(crate) divergence_threshold: f64,
}

#[pymethods]
impl PyNewtonConfig {
    #[new]
    #[pyo3(signature = (
        max_iterations=100,
        tolerance=1e-6,
        line_search=false,
        timeout_ms=None,
        initial_state=None,
        use_numerical_jacobian=false,
        jacobian_freezing=None,
        convergence_criteria=None,
        timeout_config=None,
        previous_state=None,
        line_search_armijo_c=1e-4,
        line_search_max_backtracks=20,
        divergence_threshold=1e10
    ))]
    fn new(
        max_iterations: usize,
        tolerance: f64,
        line_search: bool,
        timeout_ms: Option<u64>,
        initial_state: Option<Vec<f64>>,
        use_numerical_jacobian: bool,
        jacobian_freezing: Option<PyJacobianFreezingConfig>,
        convergence_criteria: Option<PyConvergenceCriteria>,
        timeout_config: Option<PyTimeoutConfig>,
        previous_state: Option<Vec<f64>>,
        line_search_armijo_c: f64,
        line_search_max_backtracks: usize,
        divergence_threshold: f64,
    ) -> PyResult<Self> {
        if tolerance <= 0.0 {
            return Err(PyValueError::new_err("tolerance must be greater than 0"));
        }
        if divergence_threshold <= tolerance {
            return Err(PyValueError::new_err(
                "divergence_threshold must be greater than tolerance",
            ));
        }
        Ok(PyNewtonConfig {
            max_iterations,
            tolerance,
            line_search,
            timeout_ms,
            initial_state,
            use_numerical_jacobian,
            jacobian_freezing,
            convergence_criteria,
            timeout_config: timeout_config.unwrap_or_default(),
            previous_state,
            line_search_armijo_c,
            line_search_max_backtracks,
            divergence_threshold,
        })
    }

    /// Solve the system. Returns a ConvergedState on success.
    ///
    /// The GIL is released during solving so other Python threads can run.
    ///
    /// Args:
    ///     system: A finalized System.
    ///
    /// Returns:
    ///     ConvergedState: The solution.
    ///
    /// Raises:
    ///     SolverError: If the solver fails to converge.
    ///     TimeoutError: If the solver times out.
    fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
        let mut config = entropyk_solver::NewtonConfig {
            max_iterations: self.max_iterations,
            tolerance: self.tolerance,
            line_search: self.line_search,
            timeout: self.timeout_ms.map(Duration::from_millis),
            use_numerical_jacobian: self.use_numerical_jacobian,
            line_search_armijo_c: self.line_search_armijo_c,
            line_search_max_backtracks: self.line_search_max_backtracks,
            divergence_threshold: self.divergence_threshold,
            timeout_config: self.timeout_config.inner.clone(),
            previous_state: self.previous_state.clone(),
            previous_residual: None,
            initial_state: self.initial_state.clone(),
            convergence_criteria: self
                .convergence_criteria
                .as_ref()
                .map(|cc| cc.inner.clone()),
            jacobian_freezing: self.jacobian_freezing.as_ref().map(|jf| jf.inner.clone()),
            verbose_config: Default::default(),
        };

        // Catch any Rust panic to prevent it from reaching Python (Task 5.4)
        use entropyk_solver::Solver;
        let solve_result =
            panic::catch_unwind(panic::AssertUnwindSafe(|| config.solve(&mut system.inner)));

        match solve_result {
            Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
            Ok(Err(e)) => Err(solver_error_to_pyerr(e)),
            Err(_) => Err(PyRuntimeError::new_err(
                "Internal error: solver panicked. This is a bug — please report it.",
            )),
        }
    }

    fn __repr__(&self) -> String {
        format!(
            "NewtonConfig(max_iter={}, tol={:.1e}, line_search={})",
            self.max_iterations, self.tolerance, self.line_search
        )
    }
}

// =============================================================================
// PicardConfig
// =============================================================================

/// Configuration for the Picard (Sequential Substitution) solver.
///
/// Example::
///
///     config = PicardConfig(max_iterations=500, tolerance=1e-4)
///     result = config.solve(system)
#[pyclass(name = "PicardConfig", module = "entropyk")]
#[derive(Clone, Default)]
pub struct PyPicardConfig {
    #[pyo3(get, set)]
    pub(crate) max_iterations: usize,
    #[pyo3(get, set)]
    pub(crate) tolerance: f64,
    #[pyo3(get, set)]
    pub(crate) relaxation: f64,
    #[pyo3(get, set)]
    pub(crate) initial_state: Option<Vec<f64>>,
    #[pyo3(get, set)]
    pub(crate) timeout_ms: Option<u64>,
    #[pyo3(get, set)]
    pub(crate) convergence_criteria: Option<PyConvergenceCriteria>,
}

#[pymethods]
impl PyPicardConfig {
    #[new]
    #[pyo3(signature = (
        max_iterations=500,
        tolerance=1e-4,
        relaxation=0.5,
        initial_state=None,
        timeout_ms=None,
        convergence_criteria=None
    ))]
    fn new(
        max_iterations: usize,
        tolerance: f64,
        relaxation: f64,
        initial_state: Option<Vec<f64>>,
        timeout_ms: Option<u64>,
        convergence_criteria: Option<PyConvergenceCriteria>,
    ) -> PyResult<Self> {
        if tolerance <= 0.0 {
            return Err(PyValueError::new_err("tolerance must be greater than 0"));
        }
        if !(0.0..=1.0).contains(&relaxation) {
            return Err(PyValueError::new_err(
                "relaxation must be between 0.0 and 1.0",
            ));
        }
        Ok(PyPicardConfig {
            max_iterations,
            tolerance,
            relaxation,
            initial_state,
            timeout_ms,
            convergence_criteria,
        })
    }

    /// Solve the system using Picard iteration. Returns a ConvergedState on success.
    ///
    /// The GIL is released during solving so other Python threads can run.
    ///
    /// Args:
    ///     system: A finalized System.
    ///
    /// Returns:
    ///     ConvergedState: The solution.
    ///
    /// Raises:
    ///     SolverError: If the solver fails to converge.
    fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
        let mut config = entropyk_solver::PicardConfig {
            max_iterations: self.max_iterations,
            tolerance: self.tolerance,
            relaxation_factor: self.relaxation,
            timeout: self.timeout_ms.map(Duration::from_millis),
            initial_state: self.initial_state.clone(),
            convergence_criteria: self
                .convergence_criteria
                .as_ref()
                .map(|cc| cc.inner.clone()),
            ..Default::default()
        };

        use entropyk_solver::Solver;
        let solve_result =
            panic::catch_unwind(panic::AssertUnwindSafe(|| config.solve(&mut system.inner)));

        match solve_result {
            Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
            Ok(Err(e)) => Err(solver_error_to_pyerr(e)),
            Err(_) => Err(PyRuntimeError::new_err(
                "Internal error: solver panicked. This is a bug — please report it.",
            )),
        }
    }

    fn __repr__(&self) -> String {
        format!(
            "PicardConfig(max_iter={}, tol={:.1e}, relax={:.2})",
            self.max_iterations, self.tolerance, self.relaxation
        )
    }
}

// =============================================================================
// FallbackConfig
// =============================================================================

/// Configuration for the fallback solver (Newton → Picard).
///
/// Starts with Newton-Raphson and falls back to Picard on divergence.
///
/// Example::
///
///     config = FallbackConfig()
///     result = config.solve(system)
#[pyclass(name = "FallbackConfig", module = "entropyk")]
#[derive(Clone)]
pub struct PyFallbackConfig {
    pub(crate) newton: PyNewtonConfig,
    pub(crate) picard: PyPicardConfig,
}

#[pymethods]
impl PyFallbackConfig {
    #[new]
    #[pyo3(signature = (newton=None, picard=None))]
    fn new(newton: Option<PyNewtonConfig>, picard: Option<PyPicardConfig>) -> PyResult<Self> {
        Ok(PyFallbackConfig {
            newton: newton.unwrap_or_else(|| {
                PyNewtonConfig::new(
                    100, 1e-6, false, None, None, false, None, None, None, None, 1e-4, 20, 1e10,
                )
                .unwrap()
            }),
            picard: picard
                .unwrap_or_else(|| PyPicardConfig::new(500, 1e-4, 0.5, None, None, None).unwrap()),
        })
    }

    /// Solve the system using fallback strategy (Newton → Picard).
    ///
    /// The GIL is released during solving so other Python threads can run.
    ///
    /// Args:
    ///     system: A finalized System.
    ///
    /// Returns:
    ///     ConvergedState: The solution.
    ///
    /// Raises:
    ///     SolverError: If both solvers fail to converge.
    fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
        let newton_config = entropyk_solver::NewtonConfig {
            max_iterations: self.newton.max_iterations,
            tolerance: self.newton.tolerance,
            line_search: self.newton.line_search,
            timeout: self.newton.timeout_ms.map(Duration::from_millis),
            use_numerical_jacobian: self.newton.use_numerical_jacobian,
            line_search_armijo_c: self.newton.line_search_armijo_c,
            line_search_max_backtracks: self.newton.line_search_max_backtracks,
            divergence_threshold: self.newton.divergence_threshold,
            timeout_config: self.newton.timeout_config.inner.clone(),
            previous_state: self.newton.previous_state.clone(),
            previous_residual: None,
            initial_state: self.newton.initial_state.clone(),
            convergence_criteria: self
                .newton
                .convergence_criteria
                .as_ref()
                .map(|cc| cc.inner.clone()),
            jacobian_freezing: self
                .newton
                .jacobian_freezing
                .as_ref()
                .map(|jf| jf.inner.clone()),
            verbose_config: Default::default(),
        };
        let picard_config = entropyk_solver::PicardConfig {
            max_iterations: self.picard.max_iterations,
            tolerance: self.picard.tolerance,
            relaxation_factor: self.picard.relaxation,
            timeout: self.picard.timeout_ms.map(Duration::from_millis),
            initial_state: self.picard.initial_state.clone(),
            convergence_criteria: self
                .picard
                .convergence_criteria
                .as_ref()
                .map(|cc| cc.inner.clone()),
            ..Default::default()
        };
        let mut fallback =
            entropyk_solver::FallbackSolver::new(entropyk_solver::FallbackConfig::default())
                .with_newton_config(newton_config)
                .with_picard_config(picard_config);

        use entropyk_solver::Solver;
        let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
            fallback.solve(&mut system.inner)
        }));

        match solve_result {
            Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
            Ok(Err(e)) => Err(solver_error_to_pyerr(e)),
            Err(_) => Err(PyRuntimeError::new_err(
                "Internal error: solver panicked. This is a bug — please report it.",
            )),
        }
    }

    fn __repr__(&self) -> String {
        format!(
            "FallbackConfig(newton={}, picard={})",
            self.newton.__repr__(),
            self.picard.__repr__()
        )
    }
}

// =============================================================================
// ConvergenceStatus
// =============================================================================

/// Convergence status of a completed solve.
#[pyclass(name = "ConvergenceStatus", module = "entropyk")]
#[derive(Clone)]
pub struct PyConvergenceStatus {
    inner: entropyk_solver::ConvergenceStatus,
}

#[pymethods]
impl PyConvergenceStatus {
    /// Whether the solver fully converged.
    #[getter]
    fn converged(&self) -> bool {
        matches!(
            self.inner,
            entropyk_solver::ConvergenceStatus::Converged
                | entropyk_solver::ConvergenceStatus::ControlSaturation
        )
    }

    fn __repr__(&self) -> String {
        format!("{:?}", self.inner)
    }

    fn __str__(&self) -> String {
        match &self.inner {
            entropyk_solver::ConvergenceStatus::Converged => "Converged".to_string(),
            entropyk_solver::ConvergenceStatus::TimedOutWithBestState => "TimedOut".to_string(),
            entropyk_solver::ConvergenceStatus::ControlSaturation => {
                "ControlSaturation".to_string()
            }
        }
    }

    fn __eq__(&self, other: &str) -> bool {
        match other {
            "Converged" => matches!(self.inner, entropyk_solver::ConvergenceStatus::Converged),
            "TimedOut" => matches!(
                self.inner,
                entropyk_solver::ConvergenceStatus::TimedOutWithBestState
            ),
            "ControlSaturation" => {
                matches!(
                    self.inner,
                    entropyk_solver::ConvergenceStatus::ControlSaturation
                )
            }
            _ => false,
        }
    }
}

// =============================================================================
// ConvergedState
// =============================================================================

/// Result of a solved system.
///
/// Attributes:
///     state_vector (list[float]): Final state vector [P0, h0, P1, h1, ...].
///     iterations (int): Number of solver iterations.
///     final_residual (float): L2 norm of the final residual.
///     status (ConvergenceStatus): Convergence status.
///     is_converged (bool): True if fully converged.
#[pyclass(name = "ConvergedState", module = "entropyk")]
#[derive(Clone)]
pub struct PyConvergedState {
    state: Vec<f64>,
    iterations: usize,
    final_residual: f64,
    status: entropyk_solver::ConvergenceStatus,
}

impl PyConvergedState {
    pub(crate) fn from_rust(cs: entropyk_solver::ConvergedState) -> Self {
        PyConvergedState {
            state: cs.state,
            iterations: cs.iterations,
            final_residual: cs.final_residual,
            status: cs.status,
        }
    }
}

#[pymethods]
impl PyConvergedState {
    /// Final state vector as a Python list of floats.
    #[getter]
    fn state_vector(&self) -> Vec<f64> {
        self.state.clone()
    }

    /// Number of iterations performed.
    #[getter]
    fn iterations(&self) -> usize {
        self.iterations
    }

    /// L2 norm of the final residual vector.
    #[getter]
    fn final_residual(&self) -> f64 {
        self.final_residual
    }

    /// Convergence status.
    #[getter]
    fn status(&self) -> PyConvergenceStatus {
        PyConvergenceStatus {
            inner: self.status.clone(),
        }
    }

    /// True if the solver fully converged.
    #[getter]
    fn is_converged(&self) -> bool {
        matches!(
            self.status,
            entropyk_solver::ConvergenceStatus::Converged
                | entropyk_solver::ConvergenceStatus::ControlSaturation
        )
    }

    fn __repr__(&self) -> String {
        format!(
            "ConvergedState(status={:?}, iterations={}, residual={:.2e})",
            self.status, self.iterations, self.final_residual
        )
    }

    /// Return the state vector as a NumPy array (zero-copy when possible).
    ///
    /// Returns:
    ///     numpy.ndarray: 1-D float64 array of state values.
    fn to_numpy<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, numpy::PyArray1<f64>>> {
        Ok(numpy::PyArray1::from_vec(py, self.state.clone()))
    }
}

// =============================================================================
// SolverStrategy
// =============================================================================

#[pyclass(name = "SolverStrategy", module = "entropyk")]
#[derive(Clone)]
pub struct PySolverStrategy {
    pub(crate) inner: entropyk_solver::SolverStrategy,
}

#[pymethods]
impl PySolverStrategy {
    #[staticmethod]
    #[pyo3(signature = (
        max_iterations=100,
        tolerance=1e-6,
        line_search=false,
        timeout_ms=None,
        initial_state=None,
        use_numerical_jacobian=false,
        jacobian_freezing=None,
        convergence_criteria=None,
        timeout_config=None,
        previous_state=None,
        line_search_armijo_c=1e-4,
        line_search_max_backtracks=20,
        divergence_threshold=1e10
    ))]
    fn newton(
        max_iterations: usize,
        tolerance: f64,
        line_search: bool,
        timeout_ms: Option<u64>,
        initial_state: Option<Vec<f64>>,
        use_numerical_jacobian: bool,
        jacobian_freezing: Option<PyJacobianFreezingConfig>,
        convergence_criteria: Option<PyConvergenceCriteria>,
        timeout_config: Option<PyTimeoutConfig>,
        previous_state: Option<Vec<f64>>,
        line_search_armijo_c: f64,
        line_search_max_backtracks: usize,
        divergence_threshold: f64,
    ) -> PyResult<Self> {
        let py_config = PyNewtonConfig::new(
            max_iterations,
            tolerance,
            line_search,
            timeout_ms,
            initial_state,
            use_numerical_jacobian,
            jacobian_freezing,
            convergence_criteria,
            timeout_config,
            previous_state,
            line_search_armijo_c,
            line_search_max_backtracks,
            divergence_threshold,
        )?;
        let config = entropyk_solver::NewtonConfig {
            max_iterations: py_config.max_iterations,
            tolerance: py_config.tolerance,
            line_search: py_config.line_search,
            timeout: py_config.timeout_ms.map(Duration::from_millis),
            use_numerical_jacobian: py_config.use_numerical_jacobian,
            line_search_armijo_c: py_config.line_search_armijo_c,
            line_search_max_backtracks: py_config.line_search_max_backtracks,
            divergence_threshold: py_config.divergence_threshold,
            timeout_config: py_config.timeout_config.inner.clone(),
            previous_state: py_config.previous_state.clone(),
            previous_residual: None,
            initial_state: py_config.initial_state.clone(),
            convergence_criteria: py_config
                .convergence_criteria
                .as_ref()
                .map(|cc| cc.inner.clone()),
            jacobian_freezing: py_config
                .jacobian_freezing
                .as_ref()
                .map(|jf| jf.inner.clone()),
            verbose_config: Default::default(),
        };
        Ok(PySolverStrategy {
            inner: entropyk_solver::SolverStrategy::NewtonRaphson(config),
        })
    }

    #[staticmethod]
    #[pyo3(signature = (
        max_iterations=500,
        tolerance=1e-4,
        relaxation=0.5,
        initial_state=None,
        timeout_ms=None,
        convergence_criteria=None
    ))]
    fn picard(
        max_iterations: usize,
        tolerance: f64,
        relaxation: f64,
        initial_state: Option<Vec<f64>>,
        timeout_ms: Option<u64>,
        convergence_criteria: Option<PyConvergenceCriteria>,
    ) -> PyResult<Self> {
        let py_config = PyPicardConfig::new(
            max_iterations,
            tolerance,
            relaxation,
            initial_state,
            timeout_ms,
            convergence_criteria,
        )?;
        let config = entropyk_solver::PicardConfig {
            max_iterations: py_config.max_iterations,
            tolerance: py_config.tolerance,
            relaxation_factor: py_config.relaxation,
            timeout: py_config.timeout_ms.map(Duration::from_millis),
            initial_state: py_config.initial_state.clone(),
            convergence_criteria: py_config
                .convergence_criteria
                .as_ref()
                .map(|cc| cc.inner.clone()),
            ..Default::default()
        };
        Ok(PySolverStrategy {
            inner: entropyk_solver::SolverStrategy::SequentialSubstitution(config),
        })
    }

    #[staticmethod]
    fn default() -> Self {
        PySolverStrategy {
            inner: entropyk_solver::SolverStrategy::default(),
        }
    }

    fn solve(&mut self, system: &mut PySystem) -> PyResult<PyConvergedState> {
        use entropyk_solver::Solver;

        let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
            self.inner.solve(&mut system.inner)
        }));

        match solve_result {
            Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
            Ok(Err(e)) => Err(solver_error_to_pyerr(e)),
            Err(_) => Err(PyRuntimeError::new_err(
                "Internal error: solver panicked. This is a bug — please report it.",
            )),
        }
    }
}
