//! Total, C¹ tube two-phase ΔP evaluation and exact momentum-row Jacobian
//! pieces for the DX heat exchangers (condenser / evaporator).
//!
//! ## Why this module exists (Epic-0 / Story 0.2 standard)
//!
//! The tube two-phase ΔP previously relied on `Option`-returning saturation
//! queries that silently fell through to the lumped-quadratic (or zero) model
//! whenever a backend query failed at an extreme Newton iterate — a residual
//! *model switch* mid-solve — and its momentum-row Jacobian entries were
//! whole-ΔP central finite differences through the fluid backend (~72 CoolProp
//! calls per assembly, straddling every clamp and blend in the path).
//!
//! This module makes the evaluation **total** and **C¹** (see
//! `docs/components/phantom-gradient-regularization.md`):
//!
//! * the query pressure is clamped into the detected saturation domain with a
//!   C¹ [`smooth_clamp`] (derivative exposed for the chain rule);
//! * the latent heat in the quality ratio is floored with [`smooth_max`] so
//!   near-critical iterates stay finite and smooth (qualities are *not*
//!   clamped — the correlations accept `x < 0` / `x > 1`, see
//!   [`tube_two_phase_delta_p`]);
//! * surface tension keeps its bounded fallback (`0.008 N/m`), and a hard
//!   backend failure is a **recoverable** [`ComponentError::DomainViolation`]
//!   (Story 1.3) — never a silent switch to a different ΔP model;
//! * the Jacobian pieces are **analytic** through the whole composition
//!   (ṁ → mass flux → friction + acceleration; h → quality; P → saturation
//!   properties), with *narrow* domain-safe finite differences only for the
//!   individual saturation-property pressure derivatives that the thin
//!   CoolProp FFI does not expose (NFR9 deviation, recorded in rustdoc on
//!   [`sat_dp_full_state`]).
//!
//! The only path that still selects the legacy lumped arm is **solve
//! invariant** (no backend, empty refrigerant id, or no detectable saturation
//! domain on this fluid): the active model can never change during a solve.

use std::sync::Arc;

use entropyk_core::smoothing::{smooth_clamp, smooth_clamp_derivative, smooth_max};
use entropyk_core::Pressure;
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};

use crate::heat_exchanger::sat_domain;
use crate::heat_exchanger::two_phase_dp::{
    tube_two_phase_delta_p_partials, SatTransportProps, TubeChannelGeometry, TwoPhaseDpCorrelation,
};
use crate::ComponentError;

/// Relative width of the C¹ clamp band at the saturation-domain bounds
/// (Story 0.2 guidance: physically small — ~4 kPa on the R134a domain).
const CLAMP_REL_WIDTH: f64 = 1e-3;

/// Floor on the latent heat used in the quality ratio [J/kg].
///
/// Well below any physical latent heat away from the critical point (R134a:
/// ~150–220 kJ/kg), so it only regularizes near-critical iterates where the
/// quality ratio would otherwise explode; see [`smooth_max`] selection
/// guidance in the Story 0.2 standard.
const H_FG_FLOOR_JKG: f64 = 1.0e3;

/// Sharpness of the [`smooth_max`] latent-heat floor [J/kg] (overshoot
/// `k/2 = 50 J/kg`, negligible against the 1 kJ/g floor).
const H_FG_FLOOR_K: f64 = 1.0e2;

/// Bounded fallback for the surface tension when the backend cannot provide
/// it (same legacy value as the former `sat_transport_at_p`; used by Friedel
/// only, MSH does not consume σ).
const SIGMA_FALLBACK: f64 = 0.008;

/// Saturated state backing a tube-ΔP **residual** evaluation (value path).
#[derive(Debug, Clone, Copy)]
pub struct SatDpValue {
    /// Clamped pressure actually used for the saturation queries [Pa].
    pub p_sat: f64,
    /// Saturated-liquid enthalpy `h_f(p_sat)` [J/kg].
    pub h_f: f64,
    /// Smooth-floored latent heat `max⁺(h_g − h_f, H_FG_FLOOR)` [J/kg].
    pub h_fg_eff: f64,
    /// Saturated transport properties at `p_sat`.
    pub props: SatTransportProps,
}

impl SatDpValue {
    /// Thermodynamic quality `(h − h_f) / h_fg_eff` — **unclamped** (linear
    /// extrapolation outside the dome; the correlations accept `x < 0` and
    /// `x > 1`).
    #[inline]
    pub fn quality(&self, h: f64) -> f64 {
        (h - self.h_f) / self.h_fg_eff
    }
}

/// [`SatDpValue`] plus the pressure derivatives needed by the momentum-row
/// Jacobian (narrow, domain-safe FD — see [`sat_dp_full_state`]).
#[derive(Debug, Clone, Copy)]
pub struct SatDpFull {
    /// Value-path state.
    pub value: SatDpValue,
    /// `d(p_sat)/dp` through the C¹ clamp (1 inside the domain, 0 far outside).
    pub dp_sat_dp: f64,
    /// `d h_f / dp` at `p_sat` [J/kg/Pa].
    pub dh_f_dp: f64,
    /// `d(h_fg_eff)/dp` at `p_sat` (through the smooth floor) [J/kg/Pa].
    pub dh_fg_eff_dp: f64,
    /// Pressure derivatives of the transport properties at `p_sat`.
    pub dprops_dp: SatTransportProps,
}

/// Value + exact partials of the tube ΔP for the momentum-row Jacobian.
#[derive(Debug, Clone, Copy, Default)]
pub struct TubeDpEvaluation {
    /// ΔP value [Pa].
    pub value: f64,
    /// ∂ΔP/∂ṁ [Pa·s/kg].
    pub d_dm: f64,
    /// ∂ΔP/∂P_in [Pa/Pa].
    pub d_dp: f64,
    /// ∂ΔP/∂h_in [Pa/(J/kg)].
    pub d_dh_in: f64,
    /// ∂ΔP/∂h_out [Pa/(J/kg)].
    pub d_dh_out: f64,
}

/// Raw saturated values shared by the value and full paths.
struct RawSat {
    p_sat: f64,
    dp_sat_dp: f64,
    h_f: f64,
    h_g: f64,
    props: SatTransportProps,
}

/// Queries the raw saturated state at the C¹-clamped pressure.
///
/// Returns `Ok(None)` only for **solve-invariant** gaps: the fluid has no
/// detectable saturation domain on this backend (deterministic per fluid, see
/// [`sat_domain`]). A hard backend failure on a clamped query is a recoverable
/// [`ComponentError::DomainViolation`] — never a silent model switch.
fn raw_sat_state(
    backend: &Arc<dyn FluidBackend>,
    refrigerant_id: &str,
    p_pa: f64,
) -> Result<Option<RawSat>, ComponentError> {
    if refrigerant_id.is_empty() {
        return Ok(None);
    }
    let Some((p_min, p_max)) = sat_domain::saturation_pressure_domain(backend, refrigerant_id)
    else {
        return Ok(None);
    };
    let width = (p_max - p_min) * CLAMP_REL_WIDTH;
    let (p_sat, dp_sat_dp) = if !p_pa.is_finite() {
        // Non-finite probe pressures (NaN/±inf from a blown-up iterate) are
        // pinned to the nearest bound with zero slope, mirroring
        // `sat_domain::clamp_to_saturation_domain`.
        (
            if p_pa.is_sign_negative() {
                p_min
            } else {
                p_max
            },
            0.0,
        )
    } else {
        (
            smooth_clamp(p_pa, p_min, p_max, width),
            smooth_clamp_derivative(p_pa, p_min, p_max, width),
        )
    };

    let p = Pressure::from_pascals(p_sat);
    let query = |x: f64, prop: Property, what: &str| -> Result<f64, ComponentError> {
        backend
            .property(
                FluidId::new(refrigerant_id),
                prop,
                FluidState::from_px(p, Quality::new(x)),
            )
            .map_err(|e| {
                ComponentError::from_fluid_error_context(
                    &format!("tube ΔP saturation query {what} failed at P={p_sat:.6e} Pa"),
                    e,
                )
            })
    };

    let h_f = query(0.0, Property::Enthalpy, "h_f")?;
    let h_g = query(1.0, Property::Enthalpy, "h_g")?;
    let rho_liquid = query(0.0, Property::Density, "rho_liquid")?;
    let rho_vapor = query(1.0, Property::Density, "rho_vapor")?;
    let mu_liquid = query(0.0, Property::Viscosity, "mu_liquid")?;
    let mu_vapor = query(1.0, Property::Viscosity, "mu_vapor")?;
    // Bounded fallback for σ (Friedel only) — never a model switch.
    let sigma = query(0.5, Property::SurfaceTension, "sigma").unwrap_or(SIGMA_FALLBACK);

    Ok(Some(RawSat {
        p_sat,
        dp_sat_dp,
        h_f,
        h_g,
        props: SatTransportProps {
            rho_liquid,
            rho_vapor,
            mu_liquid,
            mu_vapor,
            sigma,
        },
    }))
}

/// Value-path saturated state for the residual (no derivative FDs).
///
/// `Ok(None)` only on solve-invariant gaps (see [`raw_sat_state`]).
pub fn sat_dp_value_state(
    backend: &Arc<dyn FluidBackend>,
    refrigerant_id: &str,
    p_pa: f64,
) -> Result<Option<SatDpValue>, ComponentError> {
    Ok(
        raw_sat_state(backend, refrigerant_id, p_pa)?.map(|raw| SatDpValue {
            p_sat: raw.p_sat,
            h_f: raw.h_f,
            h_fg_eff: smooth_max(raw.h_g - raw.h_f, H_FG_FLOOR_JKG, H_FG_FLOOR_K),
            props: raw.props,
        }),
    )
}

/// Central FD of a single saturation property on the *clamped* pressure,
/// with one-sided fallbacks at the domain bounds.
///
/// This is the **narrow** domain-safe FD allowed by NFR9: it differentiates
/// one scalar backend property (`h_f`, `h_g`, `ρ_l`, `ρ_v`, `μ_l`, `μ_v`, `σ`)
/// rather than the whole ΔP composition. The thin CoolProp FFI
/// (`crates/fluids/coolprop-sys`) only exposes `PropsSI` values, so analytic
/// saturation derivatives are unavailable; the step (`max(p·1e-6, 1 Pa)`)
/// stays inside the detected domain and degenerates to one-sided differences
/// at the bounds (zero slope when the query pressure is pinned).
fn sat_prop_dp(
    backend: &Arc<dyn FluidBackend>,
    refrigerant_id: &str,
    prop: Property,
    quality: f64,
    p_sat: f64,
    p_min: f64,
    p_max: f64,
) -> f64 {
    let query = |p_pa: f64| -> Option<f64> {
        backend
            .property(
                FluidId::new(refrigerant_id),
                prop,
                FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(quality)),
            )
            .ok()
            .filter(|v| v.is_finite())
    };
    let eps = (p_sat * 1e-6).max(1.0);
    let p_hi = (p_sat + eps).min(p_max);
    let p_lo = (p_sat - eps).max(p_min);
    let v_hi = query(p_hi);
    let v_lo = query(p_lo);
    if let (Some(hi), Some(lo)) = (v_hi, v_lo) {
        if p_hi > p_lo {
            return (hi - lo) / (p_hi - p_lo);
        }
    }
    // One-sided fallbacks at the domain bounds.
    let v_c = query(p_sat);
    if let (Some(hi), Some(c)) = (v_hi, v_c) {
        if p_hi > p_sat {
            return (hi - c) / (p_hi - p_sat);
        }
    }
    if let (Some(lo), Some(c)) = (v_lo, v_c) {
        if p_sat > p_lo {
            return (c - lo) / (p_sat - p_lo);
        }
    }
    0.0
}

/// Full saturated state for the momentum-row Jacobian: value-path data plus
/// the saturation-property pressure derivatives.
///
/// `Ok(None)` only on solve-invariant gaps (see [`raw_sat_state`]).
pub fn sat_dp_full_state(
    backend: &Arc<dyn FluidBackend>,
    refrigerant_id: &str,
    p_pa: f64,
) -> Result<Option<SatDpFull>, ComponentError> {
    let Some((p_min, p_max)) = sat_domain::saturation_pressure_domain(backend, refrigerant_id)
    else {
        return Ok(None);
    };
    let Some(raw) = raw_sat_state(backend, refrigerant_id, p_pa)? else {
        return Ok(None);
    };
    let h_fg = raw.h_g - raw.h_f;
    let h_fg_eff = smooth_max(h_fg, H_FG_FLOOR_JKG, H_FG_FLOOR_K);
    // Narrow domain-safe FDs on the individual saturation properties (NFR9
    // recorded deviation — the only non-analytic pieces of the composition).
    let dh_f_dp = sat_prop_dp(
        backend,
        refrigerant_id,
        Property::Enthalpy,
        0.0,
        raw.p_sat,
        p_min,
        p_max,
    );
    let dh_g_dp = sat_prop_dp(
        backend,
        refrigerant_id,
        Property::Enthalpy,
        1.0,
        raw.p_sat,
        p_min,
        p_max,
    );
    // d(h_fg_eff)/dp through the smooth floor: d(smooth_max)/dp = s·(h_g'−h_f')
    // with s = smooth_max_derivative(h_fg, floor, k).
    let floor_slope =
        entropyk_core::smoothing::smooth_max_derivative(h_fg, H_FG_FLOOR_JKG, H_FG_FLOOR_K);
    let dprops_dp = SatTransportProps {
        rho_liquid: sat_prop_dp(
            backend,
            refrigerant_id,
            Property::Density,
            0.0,
            raw.p_sat,
            p_min,
            p_max,
        ),
        rho_vapor: sat_prop_dp(
            backend,
            refrigerant_id,
            Property::Density,
            1.0,
            raw.p_sat,
            p_min,
            p_max,
        ),
        mu_liquid: sat_prop_dp(
            backend,
            refrigerant_id,
            Property::Viscosity,
            0.0,
            raw.p_sat,
            p_min,
            p_max,
        ),
        mu_vapor: sat_prop_dp(
            backend,
            refrigerant_id,
            Property::Viscosity,
            1.0,
            raw.p_sat,
            p_min,
            p_max,
        ),
        sigma: if raw.props.sigma == SIGMA_FALLBACK {
            0.0 // constant fallback active: no pressure dependence
        } else {
            sat_prop_dp(
                backend,
                refrigerant_id,
                Property::SurfaceTension,
                0.5,
                raw.p_sat,
                p_min,
                p_max,
            )
        },
    };
    Ok(Some(SatDpFull {
        value: SatDpValue {
            p_sat: raw.p_sat,
            h_f: raw.h_f,
            h_fg_eff,
            props: raw.props,
        },
        dp_sat_dp: raw.dp_sat_dp,
        dh_f_dp,
        dh_fg_eff_dp: floor_slope * (dh_g_dp - dh_f_dp),
        dprops_dp,
    }))
}

/// Tube two-phase ΔP value + **exact** partials (∂/∂ṁ, ∂/∂P_in, ∂/∂h_in,
/// ∂/∂h_out) for the momentum-row Jacobian (NFR9).
///
/// The composition is fully analytic except the individual
/// saturation-property pressure derivatives (narrow domain-safe FDs, see
/// [`sat_dp_full_state`]). Qualities are unclamped linear extrapolations
/// `(h − h_f)/h_fg_eff`, matching the residual evaluation exactly.
pub fn tube_dp_evaluation(
    correlation: TwoPhaseDpCorrelation,
    geom: &TubeChannelGeometry,
    mass_flow: f64,
    h_in: f64,
    h_out: f64,
    state: &SatDpFull,
) -> TubeDpEvaluation {
    let st = &state.value;
    let x_in = st.quality(h_in);
    let x_out = st.quality(h_out);
    let p = tube_two_phase_delta_p_partials(correlation, geom, mass_flow, x_in, x_out, &st.props);

    // ∂ΔP/∂h = (∂ΔP/∂x) · (1 / h_fg_eff).
    let d_dh_in = p.d_dx_in / st.h_fg_eff;
    let d_dh_out = p.d_dx_out / st.h_fg_eff;

    // ∂x/∂P at fixed h, with x = (h − h_f)/E and E = h_fg_eff:
    //   ∂x/∂P = (−h_f'·E − (h − h_f)·E') / E²
    let e2 = st.h_fg_eff * st.h_fg_eff;
    let dx_in_dp = (-state.dh_f_dp * st.h_fg_eff - (h_in - st.h_f) * state.dh_fg_eff_dp) / e2;
    let dx_out_dp = (-state.dh_f_dp * st.h_fg_eff - (h_out - st.h_f) * state.dh_fg_eff_dp) / e2;

    // ∂ΔP/∂P through the saturated state, then through the C¹ pressure clamp.
    let dprops = &state.dprops_dp;
    let d_dp_sat = p.d_dx_in * dx_in_dp
        + p.d_dx_out * dx_out_dp
        + p.d_drho_liquid * dprops.rho_liquid
        + p.d_drho_vapor * dprops.rho_vapor
        + p.d_dmu_liquid * dprops.mu_liquid
        + p.d_dmu_vapor * dprops.mu_vapor
        + p.d_dsigma * dprops.sigma;

    TubeDpEvaluation {
        value: p.value,
        d_dm: p.d_dm,
        d_dp: d_dp_sat * state.dp_sat_dp,
        d_dh_in,
        d_dh_out,
    }
}

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

    /// The value-path quality must be the plain unclamped ratio (matching the
    /// residual) and stay finite with the latent-heat floor.
    #[test]
    fn quality_is_unclamped_and_floored() {
        let v = SatDpValue {
            p_sat: 1.0e6,
            h_f: 200_000.0,
            h_fg_eff: 150_000.0,
            props: SatTransportProps {
                rho_liquid: 1200.0,
                rho_vapor: 20.0,
                mu_liquid: 200e-6,
                mu_vapor: 11e-6,
                sigma: 0.008,
            },
        };
        assert!((v.quality(275_000.0) - 0.5).abs() < 1e-12);
        // Subcooled / superheated extrapolation is linear and unclamped.
        assert!(v.quality(150_000.0) < 0.0);
        assert!(v.quality(500_000.0) > 1.0);
        let near_critical = SatDpValue {
            h_fg_eff: smooth_max(1.0, H_FG_FLOOR_JKG, H_FG_FLOOR_K),
            ..v
        };
        assert!(near_critical.quality(500_000.0).is_finite());
    }

    /// ∂ΔP/∂P from [`tube_dp_evaluation`] must match a whole-ΔP central FD
    /// through the backend (the exact composition the residual path uses).
    #[test]
    fn tube_dp_evaluation_dp_matches_whole_fd() {
        use crate::heat_exchanger::two_phase_dp::{
            tube_two_phase_delta_p, TubeChannelGeometry, TwoPhaseDpCorrelation,
        };
        use std::sync::Arc;
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let corr = TwoPhaseDpCorrelation::MullerSteinhagenHeck1986;
        let geom = TubeChannelGeometry::dx_default();
        let (m, p_pa, h_in, h_out) = (0.10_f64, 350_000.0_f64, 250_000.0_f64, 410_000.0_f64);

        let value = |p: f64| -> f64 {
            let st = sat_dp_value_state(&backend, "R134a", p).unwrap().unwrap();
            tube_two_phase_delta_p(
                corr,
                &geom,
                m,
                st.quality(h_in),
                st.quality(h_out),
                &st.props,
            )
        };
        let eps = (p_pa * 1e-6).max(1.0);
        let fd = (value(p_pa + eps) - value(p_pa - eps)) / (2.0 * eps);

        let full = sat_dp_full_state(&backend, "R134a", p_pa).unwrap().unwrap();
        let eval = tube_dp_evaluation(corr, &geom, m, h_in, h_out, &full);
        // The value path is identical to the residual composition.
        assert!((eval.value - value(p_pa)).abs() < 1e-9 * value(p_pa).abs().max(1.0));
        assert!(
            (eval.d_dp - fd).abs() < 1e-3 * fd.abs().max(1e-12),
            "d_dp mismatch: analytic={} fd={}",
            eval.d_dp,
            fd
        );
    }

    /// ∂ΔP/∂h_in and ∂ΔP/∂h_out must match whole-ΔP central FDs on the
    /// enthalpies (through the unclamped qualities).
    #[test]
    fn tube_dp_evaluation_dh_matches_whole_fd() {
        use crate::heat_exchanger::two_phase_dp::{
            tube_two_phase_delta_p, TubeChannelGeometry, TwoPhaseDpCorrelation,
        };
        use std::sync::Arc;
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let corr = TwoPhaseDpCorrelation::MullerSteinhagenHeck1986;
        let geom = TubeChannelGeometry::dx_default();
        let (m, p_pa, h_in, h_out) = (0.10_f64, 350_000.0_f64, 250_000.0_f64, 410_000.0_f64);

        let value = |hi: f64, ho: f64| -> f64 {
            let st = sat_dp_value_state(&backend, "R134a", p_pa)
                .unwrap()
                .unwrap();
            tube_two_phase_delta_p(corr, &geom, m, st.quality(hi), st.quality(ho), &st.props)
        };
        let full = sat_dp_full_state(&backend, "R134a", p_pa).unwrap().unwrap();
        let eval = tube_dp_evaluation(corr, &geom, m, h_in, h_out, &full);

        let eps_h = (h_in.abs() * 1e-6).max(1.0);
        let fd_in = (value(h_in + eps_h, h_out) - value(h_in - eps_h, h_out)) / (2.0 * eps_h);
        assert!(
            (eval.d_dh_in - fd_in).abs() < 1e-3 * fd_in.abs().max(1e-12),
            "d_dh_in mismatch: analytic={} fd={}",
            eval.d_dh_in,
            fd_in
        );
        let fd_out = (value(h_in, h_out + eps_h) - value(h_in, h_out - eps_h)) / (2.0 * eps_h);
        assert!(
            (eval.d_dh_out - fd_out).abs() < 1e-3 * fd_out.abs().max(1e-12),
            "d_dh_out mismatch: analytic={} fd={}",
            eval.d_dh_out,
            fd_out
        );
    }

    /// ∂ΔP/∂ṁ must match a whole-ΔP central FD on the mass flow, and stay
    /// continuous (C¹) through ṁ = 0.
    #[test]
    fn tube_dp_evaluation_dm_matches_whole_fd_and_is_c1_at_zero_flow() {
        use crate::heat_exchanger::two_phase_dp::{
            tube_two_phase_delta_p, TubeChannelGeometry, TwoPhaseDpCorrelation,
        };
        use std::sync::Arc;
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let corr = TwoPhaseDpCorrelation::MullerSteinhagenHeck1986;
        let geom = TubeChannelGeometry::dx_default();
        let (p_pa, h_in, h_out) = (350_000.0_f64, 250_000.0_f64, 410_000.0_f64);

        let full = sat_dp_full_state(&backend, "R134a", p_pa).unwrap().unwrap();
        let value = |m: f64| -> f64 {
            let st = sat_dp_value_state(&backend, "R134a", p_pa)
                .unwrap()
                .unwrap();
            tube_two_phase_delta_p(
                corr,
                &geom,
                m,
                st.quality(h_in),
                st.quality(h_out),
                &st.props,
            )
        };
        for m in [0.02, 0.1, 0.2] {
            let eval = tube_dp_evaluation(corr, &geom, m, h_in, h_out, &full);
            let eps = (1e-6 * m.abs()).max(1e-8);
            let fd = (value(m + eps) - value(m - eps)) / (2.0 * eps);
            assert!(
                (eval.d_dm - fd).abs() < 1e-3 * fd.abs().max(1e-12),
                "d_dm mismatch at m={m}: analytic={} fd={}",
                eval.d_dm,
                fd
            );
        }
        // C¹ through ṁ = 0: left/right FD slopes of ΔP(ṁ) agree and d_dm(0)=0.
        let h = 1e-6;
        let left = (value(-h) - value(-2.0 * h)) / h;
        let right = (value(2.0 * h) - value(h)) / h;
        let scale = left.abs().max(right.abs()).max(1.0);
        assert!(
            (left - right).abs() / scale < 1e-2,
            "d_dm C¹ at zero flow: left={left} right={right}"
        );
        let eval0 = tube_dp_evaluation(corr, &geom, 0.0, h_in, h_out, &full);
        assert_eq!(eval0.d_dm, 0.0);
        assert_eq!(eval0.value, 0.0);
    }
}
