//! `FaerLuSolver`: dense faer LU backend behind the [`LinearSolver`] lifecycle
//! (FR3, Story 1.5 — strangler migration; nalgebra stays the default).
//!
//! Same contract and pipeline as [`crate::linear::NalgebraLuSolver`] (post-1.4
//! audit): identical triplet assembly (zero-fill + `+=`), identical Ruiz
//! equilibration (`crate::scaling::equilibrate`), factorization computed once
//! in `set_linearisation`, bounds validated BEFORE mutation with the factor
//! invalidated on out-of-bounds, zero-panic error paths, zero heap allocation
//! in `solve_in_place` (faer solves in place on the stored factorization,
//! over a column-major view of the pre-allocated scratch). The only
//! intentional difference is the factorization engine: faer's partial-pivot
//! LU instead of nalgebra's LU — results match within tolerance, not bitwise.

use entropyk_solver_core::{LinearSolver, SolverCoreError};
use faer::linalg::solvers::{PartialPivLu, SolveCore};
use nalgebra::DMatrix;

/// Stored faer factorization plus the equilibration scalings used to build it.
struct Factorized {
    d_r: Vec<f64>,
    d_c: Vec<f64>,
    lu: PartialPivLu<f64>,
}

/// Dense LU backend (faer) behind the object-safe [`LinearSolver`] trait.
///
/// Scratch buffers are allocated once in `set_problem` so `solve_in_place`
/// performs zero heap allocation.
pub struct FaerLuSolver {
    n_rows: usize,
    n_cols: usize,
    /// Assembled (unscaled) matrix (nalgebra storage, shared with the
    /// equilibration pipeline).
    matrix: Option<DMatrix<f64>>,
    factor: Option<Factorized>,
    /// Scratch: scaled rhs / unscaled step (allocated in `set_problem`).
    delta: Vec<f64>,
}

impl Default for FaerLuSolver {
    fn default() -> Self {
        Self::new()
    }
}

impl FaerLuSolver {
    /// Creates an unconfigured backend (call [`LinearSolver::set_problem`]).
    pub fn new() -> Self {
        Self {
            n_rows: 0,
            n_cols: 0,
            matrix: None,
            factor: None,
            delta: Vec::new(),
        }
    }

    /// Inherent fast path: copy an already-assembled dense matrix and factor
    /// it (one `copy_from`, no triplet conversion).
    pub fn set_matrix(&mut self, matrix: &DMatrix<f64>) -> Result<(), SolverCoreError> {
        let stored = self.matrix.as_mut().ok_or_else(|| SolverCoreError::Usage {
            message: "set_problem must be called before set_matrix".to_string(),
        })?;
        if stored.nrows() != matrix.nrows() || stored.ncols() != matrix.ncols() {
            return Err(SolverCoreError::Usage {
                message: format!(
                    "matrix shape {}x{} does not match problem {}x{}",
                    matrix.nrows(),
                    matrix.ncols(),
                    stored.nrows(),
                    stored.ncols()
                ),
            });
        }
        stored.copy_from(matrix);
        self.factorize()
    }

    /// Equilibrate + scale + factorize the stored matrix (square path).
    fn factorize(&mut self) -> Result<(), SolverCoreError> {
        let Some(matrix) = self.matrix.as_ref() else {
            self.factor = None;
            return Err(SolverCoreError::Usage {
                message: "set_problem must be called before factorizing".to_string(),
            });
        };
        if matrix.nrows() != matrix.ncols() {
            // Non-square problems have no LU path; solve_in_place reports Usage.
            self.factor = None;
            return Ok(());
        }
        let n = matrix.nrows();
        let (d_r, d_c) = crate::scaling::equilibrate(matrix);
        // Scaled matrix as a faer Mat (column-major, one copy at factor time).
        let scaled = faer::Mat::from_fn(n, n, |i, j| matrix[(i, j)] * d_r[i] * d_c[j]);
        self.factor = Some(Factorized {
            d_r,
            d_c,
            lu: PartialPivLu::new(scaled.as_ref()),
        });
        Ok(())
    }
}

impl LinearSolver for FaerLuSolver {
    fn set_problem(
        &mut self,
        n_equations: usize,
        n_unknowns: usize,
    ) -> Result<(), SolverCoreError> {
        if n_equations == 0 || n_unknowns == 0 {
            return Err(SolverCoreError::Usage {
                message: format!("zero-sized problem {n_equations}x{n_unknowns}"),
            });
        }
        self.n_rows = n_equations;
        self.n_cols = n_unknowns;
        self.matrix = Some(DMatrix::zeros(n_equations, n_unknowns));
        self.factor = None;
        // Scratch buffer for the zero-allocation solve path.
        self.delta = vec![0.0; n_unknowns];
        Ok(())
    }

    fn set_linearisation(
        &mut self,
        entries: &[(usize, usize, f64)],
    ) -> Result<(), SolverCoreError> {
        let (n_rows, n_cols) = (self.n_rows, self.n_cols);
        if self.matrix.is_none() {
            return Err(SolverCoreError::Usage {
                message: "set_problem must be called before set_linearisation".to_string(),
            });
        }
        // Validate bounds BEFORE touching the matrix (1.4 audit contract).
        for &(row, col, _) in entries {
            if row >= n_rows || col >= n_cols {
                self.factor = None;
                return Err(SolverCoreError::Usage {
                    message: format!("entry ({row},{col}) out of bounds {n_rows}x{n_cols}"),
                });
            }
        }
        let matrix = self.matrix.as_mut().ok_or_else(|| SolverCoreError::Usage {
            message: "set_problem must be called before set_linearisation".to_string(),
        })?;
        matrix.fill(0.0);
        for &(row, col, value) in entries {
            matrix[(row, col)] += value;
        }
        self.factorize()
    }

    fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError> {
        if rhs.len() != self.n_rows || self.n_rows != self.n_cols {
            return Err(SolverCoreError::Usage {
                message: format!(
                    "solve_in_place needs a square rhs of len {}, got {} (problem {}x{})",
                    self.n_rows,
                    rhs.len(),
                    self.n_rows,
                    self.n_cols
                ),
            });
        }
        let n = self.n_rows;
        // Disjoint field borrows: factorization + scratch (zero alloc).
        let Self { factor, delta, .. } = self;
        let factor = factor.as_ref().ok_or_else(|| SolverCoreError::Usage {
            message: "set_linearisation must be called before solve_in_place".to_string(),
        })?;
        // Scaled right-hand side: D_r · b (into the scratch).
        for i in 0..n {
            delta[i] = rhs[i] * factor.d_r[i];
        }
        // faer solves in place on a column-major view of the scratch.
        let rhs_view = faer::MatMut::from_column_major_slice_mut(&mut delta[..n], n, 1);
        factor.lu.solve_in_place_with_conj(faer::Conj::No, rhs_view);
        // Undo the column scaling in place: x_i = D_c,i · y_i.
        for i in 0..n {
            delta[i] *= factor.d_c[i];
        }
        if delta[..n].iter().all(|v| v.is_finite()) {
            rhs.copy_from_slice(&delta[..n]);
            Ok(())
        } else {
            tracing::warn!(
                "faer LU solve produced a non-finite step - Jacobian may contain NaN/Inf"
            );
            Err(SolverCoreError::InvalidSystem {
                message: "linear solve produced a non-finite step".to_string(),
            })
        }
    }

    fn n(&self) -> usize {
        self.n_rows
    }
}

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

    /// Deterministic dense system (well-conditioned, diagonally dominant).
    fn dense_entries(n: usize) -> Vec<(usize, usize, f64)> {
        let mut entries = Vec::new();
        for i in 0..n {
            for j in 0..n {
                let v = if i == j {
                    4.0 + 0.1 * (i as f64)
                } else {
                    1.0 / (1.0 + (i as f64 - j as f64).abs())
                };
                entries.push((i, j, v));
            }
        }
        entries
    }

    #[test]
    fn parity_with_jacobian_matrix_solve() {
        for n in [1, 2, 5, 12] {
            let entries = dense_entries(n);
            let jm = JacobianMatrix::from_builder(&entries, n, n);
            let residuals: Vec<f64> = (0..n)
                .map(|i| (i as f64 * 0.7 - 1.3).sin() * 100.0)
                .collect();

            let legacy = jm.solve(&residuals).expect("legacy solve");

            let mut backend = FaerLuSolver::new();
            backend.set_problem(n, n).unwrap();
            backend.set_linearisation(&entries).unwrap();
            let mut rhs: Vec<f64> = residuals.iter().map(|r| -*r).collect();
            backend.solve_in_place(&mut rhs).unwrap();

            for i in 0..n {
                // faer ≠ nalgebra bitwise: tolerance, not exact equality.
                assert!(
                    (legacy[i] - rhs[i]).abs() <= 1e-9 * legacy[i].abs().max(1.0),
                    "n={n} i={i}: legacy={} faer={}",
                    legacy[i],
                    rhs[i]
                );
            }
        }
    }

    #[test]
    fn factorization_reuse_across_rhs() {
        let n = 6;
        let entries = dense_entries(n);
        let jm = JacobianMatrix::from_builder(&entries, n, n);
        let mut backend = FaerLuSolver::new();
        backend.set_problem(n, n).unwrap();
        backend.set_matrix(jm.as_matrix()).unwrap();

        for k in 0..2 {
            let residuals: Vec<f64> = (0..n).map(|i| (i + k) as f64 * 3.1 - 5.0).collect();
            let legacy = jm.solve(&residuals).expect("legacy solve");
            let mut rhs: Vec<f64> = residuals.iter().map(|r| -*r).collect();
            backend.solve_in_place(&mut rhs).unwrap();
            for i in 0..n {
                assert!(
                    (legacy[i] - rhs[i]).abs() <= 1e-9 * legacy[i].abs().max(1.0),
                    "reuse k={k} i={i}"
                );
            }
        }
    }

    #[test]
    fn error_paths_and_stale_factor_invalidation() {
        let mut backend = FaerLuSolver::new();
        assert!(backend.set_problem(0, 2).is_err());
        backend.set_problem(2, 2).unwrap();
        // Solve before set_linearisation → Usage.
        assert!(matches!(
            backend.solve_in_place(&mut [1.0, 2.0]),
            Err(SolverCoreError::Usage { .. })
        ));
        // Factor a good matrix, then an OOB entry: the stale factorization
        // must be invalidated (1.4 audit regression).
        backend
            .set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0)])
            .unwrap();
        assert!(backend.set_linearisation(&[(2, 0, 1.0)]).is_err());
        assert!(matches!(
            backend.solve_in_place(&mut [1.0, 2.0]),
            Err(SolverCoreError::Usage { .. })
        ));
        assert_eq!(backend.n(), 2);
    }

    #[test]
    fn non_finite_matrix_entries_are_rejected() {
        let mut backend = FaerLuSolver::new();
        backend.set_problem(2, 2).unwrap();
        backend
            .set_linearisation(&[(0, 0, f64::NAN), (1, 1, 1.0)])
            .unwrap();
        let mut rhs = vec![1.0, 2.0];
        assert!(matches!(
            backend.solve_in_place(&mut rhs),
            Err(SolverCoreError::InvalidSystem { .. })
        ));
    }
}
