//! Shared error types for the Entropyk solver crates (FR8 foundation, FR9
//! groundwork).
//!
//! Two families live here:
//!
//! - **Hard errors** ([`SolverCoreError`]): usage mistakes, invalid system
//!   definitions, internal invariant violations. These stay `Err`.
//! - **Outcome carriers** ([`SolveOutcome`], [`DomainViolation`]): structured
//!   data describing *how* a solve terminated. A non-converged solve is
//!   reported through [`ConvergenceReason`], never as a hard `Err`.
//!
//! This module requires the `alloc` feature (messages and state vectors are
//! heap-allocated). The pure-`core` tier provides [`ConvergenceReason`] only.
//!
//! The recoverable/fatal split follows the KINSOL callback convention: an
//! evaluation returning a recoverable signal (`> 0`) lets the strategy shrink
//! the step and retry, while a fatal signal (`< 0`) aborts. [`DomainViolation`]
//! is the type home for the recoverable signal; the step-reduction wiring
//! lives in `entropyk-components` (`ComponentError::is_recoverable`) and the
//! `entropyk-solver` globalization strategies (line search et al.).

use alloc::string::String;
use alloc::vec::Vec;

use thiserror::Error;

use crate::reason::ConvergenceReason;

/// Hard solver errors shared across the solver crates.
///
/// These represent defects in *how the solver was invoked* (bad
/// configuration, ill-formed system, broken internal invariant) — conditions
/// where returning an outcome would hide a bug. Non-convergence is **not**
/// here by design: it is a [`ConvergenceReason`] outcome.
///
/// `#[non_exhaustive]`: new hard-error kinds may be added as a semver-minor
/// change; downstream `match` expressions must include a wildcard arm.
#[derive(Error, Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum SolverCoreError {
    /// API or configuration misuse (invalid argument, inconsistent options).
    #[error("invalid usage: {message}")]
    Usage {
        /// Human-readable description of the misuse.
        message: String,
    },

    /// The system definition is invalid and cannot be solved (empty system,
    /// inconsistent constraints, structural singularity detected at setup).
    #[error("invalid system: {message}")]
    InvalidSystem {
        /// Human-readable description of the system defect.
        message: String,
    },

    /// An internal invariant was violated. Indicates a solver bug; please
    /// report it with the attached context.
    #[error("internal solver error: {message}")]
    Internal {
        /// Human-readable description of the violated invariant.
        message: String,
    },
}

/// A recoverable component domain violation (KINSOL `> 0` convention).
///
/// Raised when a component evaluation leaves its physical domain (e.g. a
/// fluid-property backend queried outside its range). Globalization
/// strategies may convert this into step reduction and retry instead of
/// aborting the solve. When retries are exhausted, the solve terminates with
/// [`ConvergenceReason::DomainViolation`].
///
/// The recoverable plumbing is implemented in `entropyk-components`
/// (`ComponentError::DomainViolation` / `ComponentError::is_recoverable`)
/// and in `entropyk-solver` (the Newton line search and the other
/// globalization strategies shrink the step on recoverable errors and map
/// exhaustion to `SolverError::DomainViolation` →
/// [`ConvergenceReason::DomainViolation`]).
#[derive(Debug, Clone, PartialEq)]
pub struct DomainViolation {
    /// Name of the component that left its domain, when known.
    pub component: Option<String>,
    /// Human-readable detail (which quantity, what bound was crossed).
    pub detail: String,
}

impl core::fmt::Display for DomainViolation {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match &self.component {
            Some(component) => write!(f, "domain violation in '{component}': {}", self.detail),
            None => write!(f, "domain violation: {}", self.detail),
        }
    }
}

#[cfg(feature = "std")]
impl std::error::Error for DomainViolation {}

/// The outcome of a solve, reported as data (FR8).
///
/// This is the data-first return shape consumed by strategy composition
/// (fallback cascade, Epic 3) and the robustness-sweep histogram (Story 2.6).
/// The legacy `Solver::solve` signature is unchanged; `Solver::solve_outcome`
/// adapts between the two.
#[derive(Debug, Clone, PartialEq)]
pub struct SolveOutcome {
    /// Why the solve stopped.
    pub reason: ConvergenceReason,

    /// Number of iterations performed. `0` when the termination carried no
    /// iteration payload (e.g. timeout before the first iteration).
    pub iterations: usize,

    /// ℓ₂ norm of the residual vector at termination. `f64::NAN` when the
    /// termination carried no residual payload.
    pub final_residual: f64,

    /// Final state vector when available (converged or best-effort state);
    /// `None` when the solve produced no usable state.
    pub state: Option<Vec<f64>>,
}

impl SolveOutcome {
    /// Returns `true` only when the solve fully converged.
    #[inline]
    pub fn is_converged(&self) -> bool {
        self.reason.is_converged()
    }
}

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

    #[test]
    fn hard_error_display_messages() {
        let usage = SolverCoreError::Usage {
            message: "negative tolerance".to_string(),
        };
        assert!(usage.to_string().contains("negative tolerance"));

        let invalid = SolverCoreError::InvalidSystem {
            message: "empty system".to_string(),
        };
        assert!(invalid.to_string().contains("empty system"));

        let internal = SolverCoreError::Internal {
            message: "jacobian dimension drift".to_string(),
        };
        assert!(internal.to_string().contains("jacobian dimension drift"));
    }

    #[test]
    fn domain_violation_carries_context() {
        let violation = DomainViolation {
            component: Some("condenser".to_string()),
            detail: "pressure below backend range".to_string(),
        };
        assert_eq!(violation.component.as_deref(), Some("condenser"));
        assert!(violation.detail.contains("pressure"));
    }

    #[test]
    fn domain_violation_display_includes_component_and_detail() {
        let attributed = DomainViolation {
            component: Some("condenser".to_string()),
            detail: "pressure below backend range".to_string(),
        };
        assert_eq!(
            attributed.to_string(),
            "domain violation in 'condenser': pressure below backend range"
        );

        let unattributed = DomainViolation {
            component: None,
            detail: "line search exhausted".to_string(),
        };
        assert_eq!(
            unattributed.to_string(),
            "domain violation: line search exhausted"
        );
    }

    #[test]
    fn solve_outcome_delegates_convergence_flag() {
        let converged = SolveOutcome {
            reason: ConvergenceReason::Converged,
            iterations: 7,
            final_residual: 1e-9,
            state: Some(alloc::vec![1.0, 2.0]),
        };
        assert!(converged.is_converged());

        let stalled = SolveOutcome {
            reason: ConvergenceReason::Stalled,
            iterations: 0,
            final_residual: f64::NAN,
            state: None,
        };
        assert!(!stalled.is_converged());
    }
}
