//! Typed convergence taxonomy (FR8).
//!
//! [`ConvergenceReason`] reports *how* a solve terminated as first-class data.
//! It is pure-`core`: copyable, allocation-free, and available in every
//! feature tier (`std` / `alloc` / `core`).
//!
//! Design sources (PETSc/IPOPT consensus, see the 2026-07-18 solver research
//! document): converged-reason is separate from hard errors; "no solution
//! exists" is a reason, not an exception. The enum is `#[non_exhaustive]` so
//! new reasons can be added as a semver-minor change — downstream `match`
//! expressions must include a wildcard arm.

use core::fmt;

use serde::{Deserialize, Serialize};

/// Why a solve stopped.
///
/// Every variant is a normal termination *outcome*, not a failure condition:
/// callers are expected to branch on the reason (retry with another strategy,
/// tighten a specification, report to the user) rather than catch it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ConvergenceReason {
    /// Residual norm reached the configured tolerance within budget.
    Converged,
    /// Iteration budget exhausted before reaching tolerance.
    MaxIters,
    /// Iterates stopped making progress (residual decrease below the
    /// stagnation threshold, or divergence detected).
    Stalled,
    /// The globalization line search failed to find an acceptable step.
    LineSearchFailed,
    /// The Jacobian became numerically singular at the current iterate.
    SingularJacobian,
    /// A component evaluation left its physical domain and step reduction
    /// did not recover (see KINSOL recoverable-violation convention).
    DomainViolation,
    /// The configured time budget was exhausted.
    TimedOut,
    /// Evidence indicates the system has no physical solution (e.g. a
    /// structural infeasibility certificate from matching analysis).
    LikelyNoSolution,
}

impl ConvergenceReason {
    /// Returns `true` only when the solve fully converged.
    #[inline]
    pub const fn is_converged(&self) -> bool {
        matches!(self, Self::Converged)
    }

    /// Stable machine-readable key for this reason.
    ///
    /// Used as histogram buckets by the robustness-sweep runner (Story 2.6)
    /// and in serialized diagnostics; the strings are part of the public
    /// contract and must not change.
    #[inline]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::Converged => "converged",
            Self::MaxIters => "max_iters",
            Self::Stalled => "stalled",
            Self::LineSearchFailed => "line_search_failed",
            Self::SingularJacobian => "singular_jacobian",
            Self::DomainViolation => "domain_violation",
            Self::TimedOut => "timed_out",
            Self::LikelyNoSolution => "likely_no_solution",
        }
    }
}

impl fmt::Display for ConvergenceReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

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

    const ALL: [ConvergenceReason; 8] = [
        ConvergenceReason::Converged,
        ConvergenceReason::MaxIters,
        ConvergenceReason::Stalled,
        ConvergenceReason::LineSearchFailed,
        ConvergenceReason::SingularJacobian,
        ConvergenceReason::DomainViolation,
        ConvergenceReason::TimedOut,
        ConvergenceReason::LikelyNoSolution,
    ];

    #[test]
    fn only_converged_is_converged() {
        for reason in ALL {
            assert_eq!(
                reason.is_converged(),
                reason == ConvergenceReason::Converged
            );
        }
    }

    #[test]
    fn as_str_keys_are_stable() {
        assert_eq!(ConvergenceReason::Converged.as_str(), "converged");
        assert_eq!(ConvergenceReason::MaxIters.as_str(), "max_iters");
        assert_eq!(ConvergenceReason::Stalled.as_str(), "stalled");
        assert_eq!(
            ConvergenceReason::LineSearchFailed.as_str(),
            "line_search_failed"
        );
        assert_eq!(
            ConvergenceReason::SingularJacobian.as_str(),
            "singular_jacobian"
        );
        assert_eq!(
            ConvergenceReason::DomainViolation.as_str(),
            "domain_violation"
        );
        assert_eq!(ConvergenceReason::TimedOut.as_str(), "timed_out");
        assert_eq!(
            ConvergenceReason::LikelyNoSolution.as_str(),
            "likely_no_solution"
        );
    }

    #[test]
    fn display_matches_as_str() {
        for reason in ALL {
            assert_eq!(reason.to_string(), reason.as_str());
        }
    }

    #[test]
    fn serde_roundtrip_preserves_variant() {
        for reason in ALL {
            let json = serde_json::to_string(&reason).expect("serialize");
            let back: ConvergenceReason = serde_json::from_str(&json).expect("deserialize");
            assert_eq!(reason, back);
        }
    }
}
