//! Central finite-difference Jacobian verification harness.
//!
//! Compares a component's analytic [`Component::jacobian_entries`] against
//! central finite differences of [`Component::compute_residuals`]. Intended for
//! tests and Jacobian-health audits (Epic 0) — **not** for production Newton
//! assembly (see `JacobianMatrix::numerical` in the solver crate for forward FD).

use crate::{Component, ComponentError, JacobianBuilder};

/// Default relative step factor for central FD: `h = (|x| · ε).max(h_floor)`.
pub const DEFAULT_REL_EPSILON: f64 = 1e-6;

/// Default absolute floor on the FD step (matches condenser FD tests).
pub const DEFAULT_H_FLOOR: f64 = 1e-3;

/// Default relative tolerance for analytic vs FD agreement.
pub const DEFAULT_REL_TOL: f64 = 1e-4;

/// Analytic entry treated as zero when `|analytic| < this`.
pub const DEFAULT_ANALYTIC_ATOL: f64 = 1e-12;

/// FD magnitude treated as "informative physics" when `|fd| > this`.
pub const DEFAULT_FD_INFORMATIVE: f64 = 1e-8;

/// Configuration for a Jacobian health check.
#[derive(Debug, Clone, Copy)]
pub struct JacobianFdConfig {
    /// Relative step factor ε in `h = (|x_j| · ε).max(h_floor)`.
    pub rel_epsilon: f64,
    /// Absolute floor on the central-FD step.
    pub h_floor: f64,
    /// Relative tolerance: `|fd − an| / (1 + max(|fd|,|an|)) < rel_tol`.
    pub rel_tol: f64,
    /// Analytic absolute tolerance for zero-gradient detection.
    pub analytic_atol: f64,
    /// FD magnitude above which a zero analytic entry is a killer.
    pub fd_informative: f64,
}

impl Default for JacobianFdConfig {
    fn default() -> Self {
        Self {
            rel_epsilon: DEFAULT_REL_EPSILON,
            h_floor: DEFAULT_H_FLOOR,
            rel_tol: DEFAULT_REL_TOL,
            analytic_atol: DEFAULT_ANALYTIC_ATOL,
            fd_informative: DEFAULT_FD_INFORMATIVE,
        }
    }
}

/// Analytic vs FD mismatch at a single Jacobian entry.
#[derive(Debug, Clone, PartialEq)]
pub struct JacobianMismatch {
    /// Residual / equation row.
    pub row: usize,
    /// State variable column.
    pub col: usize,
    /// Analytic ∂r/∂x.
    pub analytic: f64,
    /// Central finite-difference estimate.
    pub fd: f64,
    /// `|fd − an| / (1 + max(|fd|,|an|))`.
    pub relative_error: f64,
}

/// Analytic ≈ 0 while central FD shows an informative derivative.
///
/// Classic phantom / clamp signature: Newton sees a dead column while the
/// residual surface still has slope under finite perturbation.
#[derive(Debug, Clone, PartialEq)]
pub struct ZeroGradientKiller {
    /// Residual / equation row.
    pub row: usize,
    /// State variable column.
    pub col: usize,
    /// Analytic ∂r/∂x (near zero).
    pub analytic: f64,
    /// Central finite-difference estimate (informative).
    pub fd: f64,
}

/// Results of comparing analytic and central-FD Jacobians at one state point.
#[derive(Debug, Clone, Default)]
pub struct JacobianHealthReport {
    /// Number of residual equations.
    pub n_equations: usize,
    /// Number of state variables compared.
    pub n_variables: usize,
    /// Entries exceeding [`JacobianFdConfig::rel_tol`].
    pub mismatches: Vec<JacobianMismatch>,
    /// Entries with analytic ≈ 0 and informative FD.
    pub zero_gradient_killers: Vec<ZeroGradientKiller>,
}

impl JacobianHealthReport {
    /// True when no mismatches and no zero-gradient killers were found.
    pub fn is_clean(&self) -> bool {
        self.mismatches.is_empty() && self.zero_gradient_killers.is_empty()
    }

    /// True when at least one zero-gradient killer was detected.
    pub fn has_zero_gradient_killers(&self) -> bool {
        !self.zero_gradient_killers.is_empty()
    }
}

/// Kind of Jacobian-health finding that an allow-list entry may suppress.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AllowKind {
    /// Analytic ≈ 0 while FD is informative.
    ZeroGradientKiller,
    /// Analytic vs FD relative error exceeds tolerance.
    Mismatch,
}

/// Explicit allow-list entry for deferred debt (Story 0.5 gate).
///
/// Epic-0 P0 surfaces (valve / EXV / condenser actuators / two-phase quality)
/// must **not** appear here. Prefer an empty list; shrink over time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AllowListEntry {
    /// Gated surface id (e.g. `"exv_orifice"`, `"condenser_fan"`).
    pub surface_id: &'static str,
    /// Region id within the surface (e.g. `"opening_0"`, `"dp_le_0"`).
    pub region_id: &'static str,
    /// Optional residual row filter (`None` = any row).
    pub row: Option<usize>,
    /// Optional state column filter (`None` = any column).
    pub col: Option<usize>,
    /// Finding kind this entry covers.
    pub kind: AllowKind,
    /// One-line rationale (must also appear in the audit / health report).
    pub rationale: &'static str,
}

/// Outcome of evaluating a health report against an allow-list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateOutcome {
    /// No mismatches and no zero-gradient killers.
    Clean,
    /// Findings exist but every one is covered by the allow-list.
    AllowListed,
}

fn entry_matches(
    entry: &AllowListEntry,
    surface_id: &str,
    region_id: &str,
    kind: AllowKind,
    row: usize,
    col: usize,
) -> bool {
    entry.surface_id == surface_id
        && entry.region_id == region_id
        && entry.kind == kind
        && entry.row.map(|r| r == row).unwrap_or(true)
        && entry.col.map(|c| c == col).unwrap_or(true)
}

/// Evaluate a health report: clean, allow-listed, or hard failure.
///
/// Returns [`Err`] with a debug message when any finding is not covered by
/// `allow_list` for `(surface_id, region_id)`.
pub fn evaluate_with_allow_list(
    report: &JacobianHealthReport,
    surface_id: &str,
    region_id: &str,
    allow_list: &[AllowListEntry],
) -> Result<GateOutcome, String> {
    if report.is_clean() {
        return Ok(GateOutcome::Clean);
    }

    let mut uncovered = Vec::new();

    for k in &report.zero_gradient_killers {
        let covered = allow_list.iter().any(|e| {
            entry_matches(
                e,
                surface_id,
                region_id,
                AllowKind::ZeroGradientKiller,
                k.row,
                k.col,
            )
        });
        if !covered {
            uncovered.push(format!(
                "killer row={} col={} analytic={} fd={}",
                k.row, k.col, k.analytic, k.fd
            ));
        }
    }

    for m in &report.mismatches {
        let covered = allow_list
            .iter()
            .any(|e| entry_matches(e, surface_id, region_id, AllowKind::Mismatch, m.row, m.col));
        if !covered {
            uncovered.push(format!(
                "mismatch row={} col={} analytic={} fd={} rel_err={}",
                m.row, m.col, m.analytic, m.fd, m.relative_error
            ));
        }
    }

    if uncovered.is_empty() {
        Ok(GateOutcome::AllowListed)
    } else {
        Err(format!(
            "Jacobian health gate failed for {surface_id}/{region_id}: {}",
            uncovered.join("; ")
        ))
    }
}

/// Run [`check_jacobian_health`] and assert the report is clean or allow-listed.
pub fn assert_jacobian_healthy(
    component: &dyn Component,
    state: &[f64],
    config: JacobianFdConfig,
    surface_id: &str,
    region_id: &str,
    allow_list: &[AllowListEntry],
) {
    let report = check_jacobian_health(component, state, config).unwrap_or_else(|e| {
        panic!("Jacobian health check errored for {surface_id}/{region_id}: {e:?}")
    });
    evaluate_with_allow_list(&report, surface_id, region_id, allow_list)
        .unwrap_or_else(|msg| panic!("{msg}; full_report={report:?}"));
}

/// Dense analytic Jacobian accumulated from sparse builder entries.
pub fn dense_analytic_jacobian(
    component: &dyn Component,
    state: &[f64],
) -> Result<Vec<Vec<f64>>, ComponentError> {
    let n_eq = component.n_equations();
    let n_var = state.len();
    let mut builder = JacobianBuilder::new();
    component.jacobian_entries(state, &mut builder)?;
    let mut analytic = vec![vec![0.0; n_var]; n_eq];
    for &(row, col, val) in builder.entries() {
        if row >= n_eq || col >= n_var {
            return Err(ComponentError::InvalidState(format!(
                "Out-of-bounds Jacobian entry: row {} (max {}), col {} (max {})",
                row, n_eq, col, n_var
            )));
        }
        analytic[row][col] += val;
    }
    Ok(analytic)
}

/// Compare analytic Jacobian entries to central finite differences of residuals.
///
/// Step size: `h_j = (|state[j]| · rel_epsilon).max(h_floor)`.
/// Relative error: `|fd − an| / (1 + max(|fd|,|an|))`.
///
/// If a boundary violation occurs (residuals evaluation fails on one side),
/// the harness automatically falls back to one-sided differences using the other direction.
pub fn check_jacobian_health(
    component: &dyn Component,
    state: &[f64],
    config: JacobianFdConfig,
) -> Result<JacobianHealthReport, ComponentError> {
    let n_eq = component.n_equations();
    let n_var = state.len();
    let analytic = dense_analytic_jacobian(component, state)?;

    let mut report = JacobianHealthReport {
        n_equations: n_eq,
        n_variables: n_var,
        mismatches: Vec::new(),
        zero_gradient_killers: Vec::new(),
    };

    let mut sp = state.to_vec();
    let mut sm = state.to_vec();
    let mut rp = vec![0.0; n_eq];
    let mut rm = vec![0.0; n_eq];
    let mut r0 = vec![0.0; n_eq];
    let mut r0_computed = false;

    for col in 0..n_var {
        let h = (state[col].abs() * config.rel_epsilon).max(config.h_floor);
        if h <= 0.0 || !h.is_finite() {
            return Err(ComponentError::NumericalError(format!(
                "Step size h is zero or non-finite: {} at col {}",
                h, col
            )));
        }

        sp.copy_from_slice(state);
        sm.copy_from_slice(state);
        sp[col] += h;
        sm[col] -= h;

        let mut sp_ok = true;
        let mut sm_ok = true;

        if component.compute_residuals(&sp, &mut rp).is_err() {
            sp_ok = false;
        }
        if component.compute_residuals(&sm, &mut rm).is_err() {
            sm_ok = false;
        }

        if !sp_ok && !sm_ok {
            // Both directions failed. We propagate the error by evaluating sp again.
            component.compute_residuals(&sp, &mut rp)?;
        }

        for row in 0..n_eq {
            let fd = if sp_ok && !sm_ok {
                // Backward step failed. Fall back to forward one-sided difference.
                if !r0_computed {
                    component.compute_residuals(state, &mut r0)?;
                    r0_computed = true;
                }
                (rp[row] - r0[row]) / h
            } else if !sp_ok && sm_ok {
                // Forward step failed. Fall back to backward one-sided difference.
                if !r0_computed {
                    component.compute_residuals(state, &mut r0)?;
                    r0_computed = true;
                }
                (r0[row] - rm[row]) / h
            } else {
                // Both steps succeeded. Use central difference.
                (rp[row] - rm[row]) / (2.0 * h)
            };

            let an = analytic[row][col];

            let relative_error = if fd.is_nan() || an.is_nan() {
                f64::NAN
            } else if fd.is_infinite() || an.is_infinite() {
                f64::INFINITY
            } else {
                let scale = 1.0 + fd.abs().max(an.abs());
                (fd - an).abs() / scale
            };

            let is_mismatch = relative_error.is_nan()
                || relative_error.is_infinite()
                || relative_error >= config.rel_tol;

            if is_mismatch {
                report.mismatches.push(JacobianMismatch {
                    row,
                    col,
                    analytic: an,
                    fd,
                    relative_error: if relative_error.is_nan() {
                        f64::INFINITY
                    } else {
                        relative_error
                    },
                });
            }

            if an.abs() < config.analytic_atol && fd.abs() > config.fd_informative {
                report.zero_gradient_killers.push(ZeroGradientKiller {
                    row,
                    col,
                    analytic: an,
                    fd,
                });
            }
        }
    }

    Ok(report)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{ConnectedPort, ResidualVector, StateSlice};

    /// r0 = x0², r1 = 2·x1 — exact analytic Jacobian available.
    struct QuadraticComponent;

    impl Component for QuadraticComponent {
        fn compute_residuals(
            &self,
            state: &StateSlice,
            residuals: &mut ResidualVector,
        ) -> Result<(), ComponentError> {
            residuals[0] = state[0] * state[0];
            residuals[1] = 2.0 * state[1];
            Ok(())
        }

        fn jacobian_entries(
            &self,
            state: &StateSlice,
            jacobian: &mut JacobianBuilder,
        ) -> Result<(), ComponentError> {
            jacobian.add_entry(0, 0, 2.0 * state[0]);
            jacobian.add_entry(1, 1, 2.0);
            Ok(())
        }

        fn n_equations(&self) -> usize {
            2
        }

        fn get_ports(&self) -> &[ConnectedPort] {
            &[]
        }
    }

    /// Same residuals, but jacobian claims ∂r0/∂x0 = 0 (injected killer).
    struct ZeroGradientLiar;

    impl Component for ZeroGradientLiar {
        fn compute_residuals(
            &self,
            state: &StateSlice,
            residuals: &mut ResidualVector,
        ) -> Result<(), ComponentError> {
            residuals[0] = state[0] * state[0];
            Ok(())
        }

        fn jacobian_entries(
            &self,
            _state: &StateSlice,
            _jacobian: &mut JacobianBuilder,
        ) -> Result<(), ComponentError> {
            // Deliberately omit the true 2·x0 entry.
            Ok(())
        }

        fn n_equations(&self) -> usize {
            1
        }

        fn get_ports(&self) -> &[ConnectedPort] {
            &[]
        }
    }

    #[test]
    fn harness_clean_on_exact_analytic() {
        let report = check_jacobian_health(
            &QuadraticComponent,
            &[3.0, -1.5],
            JacobianFdConfig::default(),
        )
        .unwrap();
        assert!(
            report.is_clean(),
            "unexpected mismatches={:?} killers={:?}",
            report.mismatches,
            report.zero_gradient_killers
        );
    }

    #[test]
    fn harness_detects_zero_gradient_killer() {
        let report =
            check_jacobian_health(&ZeroGradientLiar, &[2.0], JacobianFdConfig::default()).unwrap();
        assert!(
            report.has_zero_gradient_killers(),
            "expected killer, got {:?}",
            report
        );
        let k = &report.zero_gradient_killers[0];
        assert_eq!((k.row, k.col), (0, 0));
        assert!(k.fd.abs() > 1.0, "FD should see ~4.0, got {}", k.fd);
    }

    #[test]
    fn allow_list_covers_known_killer() {
        let report =
            check_jacobian_health(&ZeroGradientLiar, &[2.0], JacobianFdConfig::default()).unwrap();
        // Liar injects both a zero-gradient killer and a mismatch on the same entry.
        let allow = [
            AllowListEntry {
                surface_id: "liar",
                region_id: "x2",
                row: Some(0),
                col: Some(0),
                kind: AllowKind::ZeroGradientKiller,
                rationale: "unit-test fixture",
            },
            AllowListEntry {
                surface_id: "liar",
                region_id: "x2",
                row: Some(0),
                col: Some(0),
                kind: AllowKind::Mismatch,
                rationale: "unit-test fixture",
            },
        ];
        assert_eq!(
            evaluate_with_allow_list(&report, "liar", "x2", &allow).unwrap(),
            GateOutcome::AllowListed
        );
        assert!(evaluate_with_allow_list(&report, "liar", "other", &allow).is_err());
    }

    #[test]
    fn assert_jacobian_healthy_passes_on_clean() {
        assert_jacobian_healthy(
            &QuadraticComponent,
            &[3.0, -1.5],
            JacobianFdConfig::default(),
            "quadratic",
            "nominal",
            &[],
        );
    }
}
