//! BphxExchanger - Brazed Plate Heat Exchanger Component
//!
//! A heat exchanger component that uses geometry-based heat transfer correlations
//! for brazed plate heat exchangers. Supports evaporation, condensation, and
//! generic heat transfer applications.
//!
//! ## Key Features
//!
//! - Geometry-based heat transfer coefficient calculation
//! - Multiple correlation support (Longo2004, Shah, etc.)
//! - Calib factor support (f_ua, f_dp)
//! - Single-phase and two-phase flow handling
//!
//! ## Example
//!
//! ```ignore
//! use entropyk_components::heat_exchanger::{BphxExchanger, BphxGeometry, BphxCorrelation};
//!
//! let geo = BphxGeometry::new(30)
//!     .with_plate_dimensions(0.5, 0.1)
//!     .with_chevron_angle(60.0)
//!     .build()
//!     .unwrap();
//!
//! let hx = BphxExchanger::new(geo)
//!     .with_correlation(BphxCorrelation::Longo2004)
//!     .with_refrigerant("R410A");
//!
//! assert_eq!(hx.n_equations(), 3);
//! ```

use super::bphx_correlation::{
    BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
    CorrelationSelector, ValidityStatus,
};
use super::bphx_dp::{evaluate_channel_pressure_drop, BphxDpCorrelation, BphxDpEval};
use super::bphx_geometry::{BphxGeometry, BphxType};
use super::correlation_registry::{
    CorrelationSelectionError, ExchangerGeometryType, FlowRegime, SelectionOutcome,
};
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::{HeatExchanger, HxSideConditions};
use super::phase_change_entu::{condenser_duty, evaporator_duty};
use super::sat_domain;
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
    Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure};
use entropyk_fluids::{FluidId, FluidState, Property, Quality};
use std::cell::{Cell, RefCell};
use std::sync::Arc;

/// BphxExchanger - Brazed Plate Heat Exchanger component
///
/// Uses geometry-based correlations to compute heat transfer coefficients.
/// Wraps a generic `HeatExchanger<EpsNtuModel>` for residual computation.
pub struct BphxExchanger {
    inner: HeatExchanger<EpsNtuModel>,
    geometry: BphxGeometry,
    correlation_selector: CorrelationSelector,
    dp_correlation: BphxDpCorrelation,
    refrigerant_id: String,
    secondary_fluid_id: String,
    fluid_backend: Option<Arc<dyn entropyk_fluids::FluidBackend>>,
    last_htc: Cell<f64>,
    last_htc_result: Cell<Option<CorrelationResult>>,
    last_selection: RefCell<Option<SelectionOutcome>>,
    last_validity_warning: Cell<bool>,
}

impl std::fmt::Debug for BphxExchanger {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("BphxExchanger")
            .field("ua", &self.ua())
            .field("geometry", &self.geometry)
            .field("correlation", &self.correlation_selector.correlation)
            .field("dp_correlation", &self.dp_correlation)
            .field("refrigerant_id", &self.refrigerant_id)
            .field("secondary_fluid_id", &self.secondary_fluid_id)
            .field("has_fluid_backend", &self.fluid_backend.is_some())
            .finish()
    }
}

impl BphxExchanger {
    /// Minimum valid UA value (W/K)
    #[allow(dead_code)]
    const MIN_UA: f64 = 0.0;

    /// Creates a new BphxExchanger with the specified geometry.
    ///
    /// The UA value is estimated from geometry and typical heat transfer coefficients.
    ///
    /// # Arguments
    ///
    /// * `geometry` - BPHX geometry specification
    ///
    /// # Example
    ///
    /// ```
    /// use entropyk_components::heat_exchanger::{BphxExchanger, BphxGeometry};
    /// use entropyk_components::Component;
    ///
    /// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
    /// let hx = BphxExchanger::new(geo);
    /// assert_eq!(hx.n_equations(), 2);
    /// ```
    pub fn new(geometry: BphxGeometry) -> Self {
        let ua_estimate = Self::estimate_ua(&geometry);
        let model = EpsNtuModel::new(ua_estimate, ExchangerType::CounterFlow);

        Self {
            inner: HeatExchanger::new(model, "BphxExchanger"),
            geometry,
            correlation_selector: CorrelationSelector::default(),
            dp_correlation: BphxDpCorrelation::default(),
            refrigerant_id: String::new(),
            secondary_fluid_id: String::new(),
            fluid_backend: None,
            last_htc: Cell::new(0.0),
            last_htc_result: Cell::new(None),
            last_selection: RefCell::new(None),
            last_validity_warning: Cell::new(false),
        }
    }

    /// Creates a BphxExchanger with a specified nominal UA value.
    pub fn with_ua(geometry: BphxGeometry, ua: f64) -> Self {
        let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
        Self {
            inner: HeatExchanger::new(model, "BphxExchanger"),
            geometry,
            correlation_selector: CorrelationSelector::default(),
            dp_correlation: BphxDpCorrelation::default(),
            refrigerant_id: String::new(),
            secondary_fluid_id: String::new(),
            fluid_backend: None,
            last_htc: Cell::new(0.0),
            last_htc_result: Cell::new(None),
            last_selection: RefCell::new(None),
            last_validity_warning: Cell::new(false),
        }
    }

    /// Estimates UA from geometry and typical HTC values.
    fn estimate_ua(geometry: &BphxGeometry) -> f64 {
        let h_typical = match geometry.exchanger_type {
            BphxType::Evaporator => 5000.0,
            BphxType::Condenser => 4000.0,
            BphxType::Generic => 3000.0,
        };
        h_typical * geometry.area
    }

    /// Sets the heat transfer correlation.
    pub fn with_correlation(mut self, correlation: BphxCorrelation) -> Self {
        self.correlation_selector = CorrelationSelector::new().with_correlation(correlation);
        self
    }

    /// Sets the channel frictional pressure-drop correlation (independent of HTC).
    pub fn with_dp_correlation(mut self, correlation: BphxDpCorrelation) -> Self {
        self.dp_correlation = correlation;
        self
    }

    /// Returns the configured pressure-drop correlation.
    pub fn dp_correlation(&self) -> BphxDpCorrelation {
        self.dp_correlation
    }

    /// Sets the refrigerant fluid identifier.
    pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
        self.set_refrigerant_id(fluid);
        self.inner.set_hot_fluid(self.refrigerant_id.clone());
        self
    }

    /// Sets the refrigerant identifier used by correlation applicability selection.
    pub fn set_refrigerant_id(&mut self, fluid: impl Into<String>) {
        self.refrigerant_id = fluid.into();
    }

    /// Returns the configured refrigerant identifier, when present.
    pub fn refrigerant_id(&self) -> Option<&str> {
        (!self.refrigerant_id.is_empty()).then_some(self.refrigerant_id.as_str())
    }

    /// Sets the secondary fluid identifier (water, brine, etc.).
    pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
        self.secondary_fluid_id = fluid.into();
        self.inner.set_cold_fluid(self.secondary_fluid_id.clone());
        self
    }

    /// Attaches a fluid backend for property queries.
    pub fn with_fluid_backend(mut self, backend: Arc<dyn entropyk_fluids::FluidBackend>) -> Self {
        self.inner
            .set_fluid_backend_from_builder(Arc::clone(&backend));
        self.fluid_backend = Some(backend);
        self
    }

    /// Declares the hot-side live-port fluid for the delegated four-port exchanger.
    pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
        self.inner.set_hot_fluid(fluid);
    }

    /// Declares the cold-side live-port fluid for the delegated four-port exchanger.
    pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
        self.inner.set_cold_fluid(fluid);
    }

    /// Returns the component name.
    pub fn name(&self) -> &str {
        self.inner.name()
    }

    /// Returns the geometry specification.
    pub fn geometry(&self) -> &BphxGeometry {
        &self.geometry
    }

    /// Returns the configured formula name, or `Automatic` before/while auto-selecting.
    pub fn correlation_name(&self) -> &'static str {
        if self.correlation_selector.automatic {
            "Automatic"
        } else {
            self.correlation_selector.correlation.name()
        }
    }

    /// Returns the effective UA value (W/K).
    pub fn ua(&self) -> f64 {
        self.inner.ua()
    }

    /// Returns calibration factors.
    pub fn calib(&self) -> &Calib {
        self.inner.calib()
    }

    /// Sets calibration factors.
    pub fn set_calib(&mut self, calib: Calib) {
        self.inner.set_calib(calib);
    }

    /// Returns the last computed heat transfer coefficient (W/(m²·K)).
    pub fn last_htc(&self) -> f64 {
        self.last_htc.get()
    }

    /// Returns the last correlation result (if available).
    pub fn last_htc_result(&self) -> Option<CorrelationResult> {
        self.last_htc_result.take()
    }

    /// Returns the latest ranked selection report.
    pub fn last_selection_report(&self) -> Option<SelectionOutcome> {
        self.last_selection.borrow().clone()
    }

    /// Returns whether a validity warning was issued.
    pub fn had_validity_warning(&self) -> bool {
        self.last_validity_warning.get()
    }

    /// Sets the hot side (refrigerant for evaporator, secondary for condenser) conditions.
    pub fn set_hot_conditions(&mut self, conditions: HxSideConditions) {
        self.inner.set_hot_conditions(conditions);
    }

    /// Sets the cold side conditions.
    pub fn set_cold_conditions(&mut self, conditions: HxSideConditions) {
        self.inner.set_cold_conditions(conditions);
    }

    /// Computes the heat transfer coefficient using the configured correlation.
    ///
    /// # Arguments
    ///
    /// * `mass_flux` - Mass flux (kg/(m²·s))
    /// * `quality` - Vapor quality (0-1)
    /// * `rho_l` - Liquid density (kg/m³)
    /// * `rho_v` - Vapor density (kg/m³)
    /// * `mu_l` - Liquid dynamic viscosity (Pa·s)
    /// * `mu_v` - Vapor dynamic viscosity (Pa·s)
    /// * `k_l` - Liquid thermal conductivity (W/(m·K))
    /// * `pr_l` - Liquid Prandtl number
    /// * `t_sat` - Saturation temperature (K)
    /// * `t_wall` - Wall temperature (K)
    #[allow(clippy::too_many_arguments)]
    pub fn compute_htc(
        &self,
        mass_flux: f64,
        quality: f64,
        rho_l: f64,
        rho_v: f64,
        mu_l: f64,
        mu_v: f64,
        k_l: f64,
        pr_l: f64,
        t_sat: f64,
        t_wall: f64,
    ) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
        let regime = match self.geometry.exchanger_type {
            BphxType::Evaporator => FlowRegime::Evaporation,
            BphxType::Condenser => FlowRegime::Condensation,
            BphxType::Generic => FlowRegime::SinglePhaseLiquid,
        };

        let params = CorrelationParams {
            mass_flux,
            quality,
            dh: self.geometry.dh,
            rho_l,
            rho_v,
            mu_l,
            mu_v,
            k_l,
            pr_l,
            t_sat,
            t_wall,
            regime,
            chevron_angle: self.geometry.chevron_angle,
            p_reduced: 0.2,
        };

        let evaluation = match self.correlation_selector.select_and_compute(
            &params,
            ExchangerGeometryType::BrazedPlate,
            self.refrigerant_id(),
        ) {
            Ok(evaluation) => evaluation,
            Err(error) => {
                self.clear_last_htc_evaluation();
                return Err(error);
            }
        };
        let result = &evaluation.result;

        self.last_htc.set(result.h);
        self.last_htc_result.set(Some(result.clone()));
        self.last_selection
            .replace(Some(evaluation.selection.clone()));
        self.last_validity_warning
            .set(result.validity != ValidityStatus::Valid);

        Ok(evaluation)
    }

    fn clear_last_htc_evaluation(&self) {
        self.last_htc.set(0.0);
        self.last_htc_result.set(None);
        self.last_selection.replace(None);
        self.last_validity_warning.set(false);
    }

    /// Computes the channel frictional pressure drop for the configured correlation.
    ///
    /// ΔP = z_dp × (2 × f × L × G²) / (ρ × d_h) with Fanning-like `f`
    /// (SimplifiedChannel default; Martin1996 when selected).
    ///
    /// # Arguments
    ///
    /// * `mass_flux` - Mass flux (kg/(m²·s))
    /// * `rho` - Fluid density (kg/m³)
    ///
    /// # Returns
    ///
    /// Pressure drop in Pa, scaled by `calib().z_dp`. Returns 0 on invalid inputs
    /// or (for Martin) invalid chevron — callers that need errors should use
    /// [`evaluate_channel_pressure_drop`].
    pub fn compute_pressure_drop(&self, mass_flux: f64, rho: f64) -> f64 {
        evaluate_channel_pressure_drop(
            self.dp_correlation,
            &self.geometry,
            mass_flux,
            rho,
            self.calib().z_dp,
        )
        .map(|e| e.delta_p_pa)
        .unwrap_or(0.0)
    }

    /// Live `z_dp`: free actuator from state when indexed, else calib value.
    fn live_z_dp(&self, state: &StateSlice) -> f64 {
        self.inner
            .calib_indices_ref()
            .z_dp
            .and_then(|idx| state.get(idx).copied())
            .unwrap_or_else(|| self.calib().z_dp)
    }

    fn side_channel_dp(
        &self,
        mass_flow: f64,
        rho: f64,
        z_dp: f64,
    ) -> Result<BphxDpEval, ComponentError> {
        let g = self.geometry.mass_flux(mass_flow);
        evaluate_channel_pressure_drop(self.dp_correlation, &self.geometry, g, rho, z_dp)
    }

    /// Overwrites the two 4-port pressure rows with `P_out − P_in + ΔP = 0`.
    fn overwrite_pressure_closures_with_dp(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        let Some(edges) = self.inner.four_port_edges() else {
            return Ok(());
        };
        let row0 = self.inner.model_n_equations();
        if residuals.len() < row0 + 2 {
            return Err(ComponentError::InvalidResidualDimensions {
                expected: row0 + 2,
                actual: residuals.len(),
            });
        }

        let z_dp = self.live_z_dp(state);
        let (m_h, p_h_in, h_h_in) = edges.hot_in;
        let (_, p_h_out, _) = edges.hot_out;
        let (m_c, p_c_in, h_c_in) = edges.cold_in;
        let (_, p_c_out, _) = edges.cold_out;

        let max_idx = [m_h, p_h_in, h_h_in, p_h_out, m_c, p_c_in, h_c_in, p_c_out]
            .into_iter()
            .max()
            .unwrap_or(0);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }

        let rho_hot = self.inner.side_density(
            "hot",
            self.inner.hot_fluid_id_str(),
            state[p_h_in],
            state[h_h_in],
        )?;
        let rho_cold = self.inner.side_density(
            "cold",
            self.inner.cold_fluid_id_str(),
            state[p_c_in],
            state[h_c_in],
        )?;

        let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
        let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;

        residuals[row0] = state[p_h_out] - state[p_h_in] + dp_hot.delta_p_pa;
        residuals[row0 + 1] = state[p_c_out] - state[p_c_in] + dp_cold.delta_p_pa;
        Ok(())
    }

    /// Adds analytic ∂r/∂ṁ and ∂r/∂z_dp for BPHX pressure rows (P entries already set).
    fn append_pressure_dp_jacobian(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        let Some(edges) = self.inner.four_port_edges() else {
            return Ok(());
        };
        let row0 = self.inner.model_n_equations();
        let z_dp = self.live_z_dp(state);
        let (m_h, p_h_in, h_h_in) = edges.hot_in;
        let (_, p_h_out, _) = edges.hot_out;
        let (m_c, p_c_in, h_c_in) = edges.cold_in;
        let (_, p_c_out, _) = edges.cold_out;

        let max_idx = [m_h, p_h_in, h_h_in, p_h_out, m_c, p_c_in, h_c_in, p_c_out]
            .into_iter()
            .max()
            .unwrap_or(0);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }

        let rho_hot = self.inner.side_density(
            "hot",
            self.inner.hot_fluid_id_str(),
            state[p_h_in],
            state[h_h_in],
        )?;
        let rho_cold = self.inner.side_density(
            "cold",
            self.inner.cold_fluid_id_str(),
            state[p_c_in],
            state[h_c_in],
        )?;

        let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
        let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;

        let a_flow = self.geometry.channel_flow_area() * self.geometry.n_channels_per_side() as f64;
        if a_flow <= 1e-30 {
            return Err(ComponentError::InvalidState(
                "BPHX channel flow area too small for pressure-drop Jacobian".into(),
            ));
        }
        let d_g_d_m = 1.0 / a_flow;

        // r = P_out − P_in + ΔP(G(ṁ), z_dp)  ⇒  ∂r/∂ṁ = ∂ΔP/∂G · ∂G/∂ṁ
        jacobian.add_entry(row0, m_h, dp_hot.d_delta_p_d_g * d_g_d_m);
        jacobian.add_entry(row0 + 1, m_c, dp_cold.d_delta_p_d_g * d_g_d_m);

        if let Some(z_idx) = self.inner.calib_indices_ref().z_dp {
            // ΔP = z_dp · ΔP_corr  ⇒  ∂r/∂z_dp = ΔP_corr = ΔP / z_dp
            // Keep the column even near z_dp→0 so a free actuator stays visible to Newton.
            let z_safe = z_dp.max(1e-30);
            jacobian.add_entry(row0, z_idx, dp_hot.delta_p_pa / z_safe);
            jacobian.add_entry(row0 + 1, z_idx, dp_cold.delta_p_pa / z_safe);
        }
        Ok(())
    }

    /// Updates UA based on computed HTC.
    ///
    /// UA_eff = h × A × f_ua
    pub fn update_ua_from_htc(&mut self, h: f64) {
        let ua = h * self.geometry.area * self.calib().z_ua;
        self.inner.set_ua_scale(ua / self.inner.ua_nominal());
    }

    /// Condenser / evaporator plate HX use Shah \(C^*\to 0\) duty instead of
    /// two-stream sensible ε-NTU (see `phase_change_entu`).
    fn uses_phase_change_duty(&self) -> bool {
        matches!(
            self.geometry.exchanger_type,
            BphxType::Condenser | BphxType::Evaporator
        ) && self.fluid_backend.is_some()
    }

    /// \(T_{\mathrm{sat}}(P)\) [K] for the refrigerant fluid id, domain-clamped.
    fn tsat_k(&self, p_pa: f64, refrigerant_id: &str) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed(
                "BphxExchanger: FluidBackend required for Tsat".into(),
            )
        })?;
        let p_pa =
            sat_domain::clamp_to_saturation_domain(backend, refrigerant_id, p_pa).unwrap_or(p_pa);
        backend
            .property(
                FluidId::new(refrigerant_id),
                Property::Temperature,
                FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)),
            )
            .map_err(ComponentError::from_fluid_error)
    }

    /// Secondary inlet \((T [K], C_{\mathrm{sec}} [W/K])\) without querying refrigerant \(c_p\).
    fn secondary_inlet_capacity(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> {
        let edges = self.inner.four_port_edges().ok_or_else(|| {
            ComponentError::InvalidState(
                "BphxExchanger: phase-change duty needs live four-port edges".into(),
            )
        })?;
        let (m_idx, p_idx, h_idx) = match self.geometry.exchanger_type {
            // Condenser: cold = secondary. Evaporator (remapped): hot = secondary.
            BphxType::Condenser => edges.cold_in,
            BphxType::Evaporator => edges.hot_in,
            BphxType::Generic => {
                return Err(ComponentError::InvalidState(
                    "BphxExchanger: Generic type has no phase-change secondary stream".into(),
                ));
            }
        };
        let max_idx = m_idx.max(p_idx).max(h_idx);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }
        let m = state[m_idx].max(0.0);
        let p = state[p_idx];
        let h = state[h_idx];
        let (t, cp) = match self.geometry.exchanger_type {
            BphxType::Condenser => (
                self.inner.cold_side_temperature(p, h)?,
                self.inner.cold_side_cp(p, h)?,
            ),
            BphxType::Evaporator => (
                self.inner.hot_side_temperature(p, h)?,
                self.inner.hot_side_cp(p, h)?,
            ),
            BphxType::Generic => unreachable!(),
        };
        Ok((t, m * cp))
    }

    /// Replace sensible ε-NTU energy rows with phase-change duty.
    ///
    /// Layout (unchanged): `r0` = hot energy, `r1` = cold energy, then DP rows.
    fn overwrite_energy_with_phase_change(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        if residuals.len() < 2 {
            return Err(ComponentError::InvalidResidualDimensions {
                expected: 2,
                actual: residuals.len(),
            });
        }
        let edges = self.inner.four_port_edges().ok_or_else(|| {
            ComponentError::InvalidState(
                "BphxExchanger: phase-change duty needs live four-port edges".into(),
            )
        })?;
        let (m_h, p_h_in, h_h_in) = edges.hot_in;
        let (_, _, h_h_out) = edges.hot_out;
        let (m_c, p_c_in, h_c_in) = edges.cold_in;
        let (_, _, h_c_out) = edges.cold_out;
        let max_idx = [m_h, p_h_in, h_h_in, h_h_out, m_c, p_c_in, h_c_in, h_c_out]
            .into_iter()
            .max()
            .unwrap_or(0);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }

        let (t_sec, c_sec) = self.secondary_inlet_capacity(state)?;
        // Calibration redesign (WS-4): when a z_ua calibration variable is
        // wired, the residual must track `state[z_ua]` (plain embedding), not
        // the cached parameter — otherwise ∂Q/∂z_ua = 0 and the calibration
        // Jacobian is singular. Mirrors exchanger.rs's `dynamic_f_ua` pattern.
        let ua = match self.inner.calib_indices_ref().z_ua {
            Some(z_idx) if z_idx < state.len() => self.inner.ua_nominal() * state[z_idx],
            _ => self.ua(),
        };
        let m_hot = state[m_h].max(0.0);
        let m_cold = state[m_c].max(0.0);

        let q = match self.geometry.exchanger_type {
            BphxType::Condenser => {
                // hot = refrigerant, cold = secondary
                let t_sat = self.tsat_k(state[p_h_in], self.inner.hot_fluid_id_str())?;
                condenser_duty(ua, c_sec, t_sat, t_sec)
            }
            BphxType::Evaporator => {
                // remapped: hot = secondary, cold = refrigerant
                let t_sat = self.tsat_k(state[p_c_in], self.inner.cold_fluid_id_str())?;
                evaporator_duty(ua, c_sec, t_sat, t_sec)
            }
            BphxType::Generic => 0.0,
        };

        // Same sign convention as EpsNtuModel::compute_residuals.
        residuals[0] = m_hot * (state[h_h_in] - state[h_h_out]) - q;
        residuals[1] = m_cold * (state[h_c_out] - state[h_c_in]) - q;
        Ok(())
    }

    /// Finite-difference Jacobian through `self.compute_residuals` so energy
    /// rows see phase-change duty (inner HeatExchanger FD would stay sensible).
    fn fd_jacobian_via_self(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        let Some(edges) = self.inner.four_port_edges() else {
            return Ok(());
        };
        let (m_h, p_h_in, h_h_in) = edges.hot_in;
        let (_, p_h_out, h_h_out) = edges.hot_out;
        let (m_c, p_c_in, h_c_in) = edges.cold_in;
        let (_, p_c_out, h_c_out) = edges.cold_out;

        let mut cols = vec![
            m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
        ];
        if let Some(z_idx) = self.inner.calib_indices_ref().z_dp {
            cols.push(z_idx);
        }
        if let Some(z_idx) = self.inner.calib_indices_ref().z_ua {
            cols.push(z_idx);
        }
        cols.sort_unstable();
        cols.dedup();
        cols.retain(|c| *c < state.len());

        let n = self.n_equations();
        let compute = |s: &[f64]| -> Result<Vec<f64>, ComponentError> {
            let mut r = vec![0.0; n];
            self.compute_residuals(s, &mut r)?;
            Ok(r)
        };

        for &col in &cols {
            let h = (state[col].abs() * 1e-6).max(1e-3);
            let mut sp = state.to_vec();
            sp[col] += h;
            let rp = compute(&sp)?;
            let mut sm = state.to_vec();
            sm[col] -= h;
            let rm = compute(&sm)?;
            for row in 0..n {
                let fd = (rp[row] - rm[row]) / (2.0 * h);
                if fd.abs() > 1e-15 {
                    jacobian.add_entry(row, col, fd);
                }
            }
        }
        Ok(())
    }
}

impl Component for BphxExchanger {
    fn n_equations(&self) -> usize {
        self.inner.n_equations()
    }

    fn compute_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        if self.uses_phase_change_duty() {
            // Skip HeatExchanger sensible ε-NTU (needs refrigerant cp, fails in
            // the two-phase dome). Write Shah C*→0 energy + channel ΔP only.
            let n = self.n_equations();
            if residuals.len() < n {
                return Err(ComponentError::InvalidResidualDimensions {
                    expected: n,
                    actual: residuals.len(),
                });
            }
            for r in residuals.iter_mut().take(n) {
                *r = 0.0;
            }
            self.overwrite_energy_with_phase_change(state, residuals)?;
            self.overwrite_pressure_closures_with_dp(state, residuals)?;
            return Ok(());
        }
        self.inner.compute_residuals(state, residuals)?;
        // Replace isobaric P_out − P_in with channel ΔP on both sides.
        self.overwrite_pressure_closures_with_dp(state, residuals)
    }

    fn jacobian_entries(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        if self.uses_phase_change_duty() {
            // FD must call this Component's residuals (phase-change energy + DP).
            return self.fd_jacobian_via_self(state, jacobian);
        }
        self.inner.jacobian_entries(state, jacobian)?;
        // Inner already wrote ∂r/∂P_out=+1, ∂r/∂P_in=−1; add ṁ and z_dp terms.
        self.append_pressure_dp_jacobian(state, jacobian)
    }

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

    fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
        self.inner.set_port_context(port_edges);
    }

    fn port_names(&self) -> Vec<String> {
        self.inner.port_names()
    }

    fn flow_paths(&self) -> Vec<(usize, usize)> {
        self.inner.flow_paths()
    }

    fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
        self.inner.set_calib_indices(indices);
    }

    fn port_mass_flows(&self, state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
        self.inner.port_mass_flows(state)
    }

    fn port_enthalpies(&self, state: &StateSlice) -> Result<Vec<Enthalpy>, ComponentError> {
        self.inner.port_enthalpies(state)
    }

    fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
        self.inner.energy_transfers(state)
    }

    fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
        use crate::MeasuredOutput::*;
        let edges = self.inner.four_port_edges()?;
        let (m_h, p_h_in, h_h_in) = edges.hot_in;
        let (_, p_h_out, h_h_out) = edges.hot_out;
        let (m_c, p_c_in, h_c_in) = edges.cold_in;
        let (_, _, h_c_out) = edges.cold_out;
        let max_idx = [
            m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, h_c_out,
        ]
        .into_iter()
        .max()?;
        if max_idx >= state.len() {
            return None;
        }
        // Refrigerant side: hot for a condenser, cold (remapped) for an evaporator.
        let (p_ref_in, p_ref_out, h_ref_out, m_ref, ref_fluid) = match self.geometry.exchanger_type
        {
            BphxType::Condenser => (p_h_in, p_h_out, h_h_out, m_h, self.inner.hot_fluid_id_str()),
            BphxType::Evaporator => (p_c_in, p_c_in, h_c_out, m_c, self.inner.cold_fluid_id_str()),
            BphxType::Generic => return None,
        };
        match kind {
            SaturationTemperature => self.tsat_k(state[p_ref_in], ref_fluid).ok(),
            Superheat | Subcooling => {
                let backend = self.fluid_backend.as_ref()?;
                let tsat = self.tsat_k(state[p_ref_out], ref_fluid).ok()?;
                let t = backend
                    .property(
                        FluidId::new(ref_fluid),
                        Property::Temperature,
                        FluidState::from_ph(
                            entropyk_core::Pressure::from_pascals(state[p_ref_out]),
                            entropyk_core::Enthalpy::from_joules_per_kg(state[h_ref_out]),
                        ),
                    )
                    .ok()?;
                if !t.is_finite() || t <= 0.0 {
                    return None;
                }
                match kind {
                    Superheat => Some(t - tsat),
                    _ => Some(tsat - t),
                }
            }
            Capacity | HeatTransferRate => {
                let q_hot = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
                let q_cold = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
                match (q_hot.is_finite(), q_cold.is_finite()) {
                    (true, true) => Some(0.5 * (q_hot + q_cold)),
                    (true, false) => Some(q_hot),
                    (false, true) => Some(q_cold),
                    _ => None,
                }
            }
            MassFlowRate => Some(state[m_ref].abs()),
            Pressure => Some(state[p_ref_in]),
            Temperature => {
                let backend = self.fluid_backend.as_ref()?;
                backend
                    .property(
                        FluidId::new(ref_fluid),
                        Property::Temperature,
                        FluidState::from_ph(
                            entropyk_core::Pressure::from_pascals(state[p_ref_out]),
                            entropyk_core::Enthalpy::from_joules_per_kg(state[h_ref_out]),
                        ),
                    )
                    .ok()
                    .filter(|t| t.is_finite() && *t > 0.0)
            }
        }
    }

    fn set_fluid_backend_from_builder(
        &mut self,
        backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
    ) {
        if self.fluid_backend.is_none() {
            self.fluid_backend = Some(Arc::clone(&backend));
        }
        self.inner.set_fluid_backend_from_builder(backend);
    }

    fn signature(&self) -> String {
        format!(
            "BphxExchanger({} plates, dh={:.2}mm, A={:.3}m², {})",
            self.geometry.n_plates,
            self.geometry.dh * 1000.0,
            self.geometry.area,
            self.correlation_name()
        )
    }

    fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
        self.inner.update_calib_factor(factor, value)
    }
}

impl StateManageable for BphxExchanger {
    fn state(&self) -> OperationalState {
        self.inner.state()
    }

    fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
        self.inner.set_state(state)
    }

    fn can_transition_to(&self, target: OperationalState) -> bool {
        self.inner.can_transition_to(target)
    }

    fn circuit_id(&self) -> &CircuitId {
        self.inner.circuit_id()
    }

    fn set_circuit_id(&mut self, circuit_id: CircuitId) {
        self.inner.set_circuit_id(circuit_id);
    }
}

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

    fn test_geometry() -> BphxGeometry {
        BphxGeometry::from_dh_area(0.003, 0.5, 20)
    }

    #[test]
    fn test_bphx_exchanger_creation() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);
        assert_eq!(hx.n_equations(), 2);
        assert!(hx.ua() > 0.0);
    }

    #[test]
    fn test_bphx_exchanger_with_ua() {
        let geo = test_geometry();
        let hx = BphxExchanger::with_ua(geo, 5000.0);
        assert!((hx.ua() - 5000.0).abs() < 1e-6);
    }

    #[test]
    fn test_bphx_exchanger_with_correlation() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo).with_correlation(BphxCorrelation::Shah1979);

        assert_eq!(
            hx.correlation_selector.correlation,
            BphxCorrelation::Shah1979
        );
    }

    #[test]
    fn test_bphx_exchanger_with_refrigerant() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo).with_refrigerant("R410A");

        assert_eq!(hx.refrigerant_id, "R410A");
    }

    #[test]
    fn test_bphx_exchanger_compute_residuals() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);
        let state = vec![0.0; 10];
        let mut residuals = vec![0.0; 3];

        let result = hx.compute_residuals(&state, &mut residuals);
        assert!(matches!(result, Err(ComponentError::InvalidState(_))));
    }

    #[test]
    fn test_bphx_exchanger_exposes_four_port_metadata() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);

        assert_eq!(
            hx.port_names(),
            vec![
                "hot_inlet".to_string(),
                "hot_outlet".to_string(),
                "cold_inlet".to_string(),
                "cold_outlet".to_string(),
            ]
        );
        assert_eq!(hx.flow_paths(), vec![(0, 1), (2, 3)]);
    }

    #[test]
    fn test_bphx_exchanger_state_manageable() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);
        assert_eq!(hx.state(), OperationalState::On);
        assert!(hx.can_transition_to(OperationalState::Off));
    }

    #[test]
    fn test_bphx_exchanger_set_state() {
        let geo = test_geometry();
        let mut hx = BphxExchanger::new(geo);

        let result = hx.set_state(OperationalState::Off);
        assert!(result.is_ok());
        assert_eq!(hx.state(), OperationalState::Off);

        let result = hx.set_state(OperationalState::Bypass);
        assert!(result.is_ok());
        assert_eq!(hx.state(), OperationalState::Bypass);
    }

    #[test]
    fn test_bphx_exchanger_compute_htc() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);

        let result = hx
            .compute_htc(
                30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
            )
            .unwrap();

        assert!(result.h > 0.0);
        assert!(result.re > 0.0);
        assert!(result.nu > 0.0);
        assert_eq!(
            result.selection.selected,
            super::super::correlation_registry::CorrelationId::Longo2004
        );
        assert_eq!(result.selection.assessments.len(), 10);
        assert_eq!(
            hx.last_selection_report().unwrap().selected,
            result.selection.selected
        );
    }

    #[test]
    fn test_bphx_exchanger_last_htc() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);

        assert_eq!(hx.last_htc(), 0.0);

        let result = hx
            .compute_htc(
                30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
            )
            .unwrap();

        assert!((hx.last_htc() - result.h).abs() < 1e-10);
    }

    #[test]
    fn test_bphx_exchanger_signature() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);

        let sig = hx.signature();
        assert!(sig.contains("BphxExchanger"));
        assert!(sig.contains("20 plates"));
        assert!(sig.contains("Automatic"));
    }

    #[test]
    fn test_bphx_exchanger_energy_transfers() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);
        let state = vec![0.0; 10];
        let (heat, work) = hx.energy_transfers(&state).unwrap();
        assert_eq!(heat.to_watts(), 0.0);
        assert_eq!(work.to_watts(), 0.0);
    }

    #[test]
    fn test_bphx_exchanger_calib_default() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo);
        let calib = hx.calib();
        assert_eq!(calib.z_ua, 1.0);
    }

    #[test]
    fn test_bphx_exchanger_set_calib() {
        let geo = test_geometry();
        let mut hx = BphxExchanger::new(geo);
        let mut calib = Calib::default();
        calib.z_ua = 0.9;
        hx.set_calib(calib);
        assert_eq!(hx.calib().z_ua, 0.9);
    }

    #[test]
    fn test_bphx_exchanger_geometry() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo.clone());
        assert_eq!(hx.geometry().n_plates, geo.n_plates);
    }

    #[test]
    fn test_bphx_exchanger_update_ua_from_htc() {
        let geo = test_geometry();
        let mut hx = BphxExchanger::new(geo);

        let h = 5000.0;
        let ua_before = hx.ua();
        hx.update_ua_from_htc(h);
        let ua_after = hx.ua();

        assert!((ua_after - ua_before).abs() > 1.0);
    }

    #[test]
    fn test_bphx_exchanger_validity_warning() {
        let geo = test_geometry().with_exchanger_type(BphxType::Evaporator);
        let hx = BphxExchanger::new(geo).with_correlation(BphxCorrelation::Longo2004);

        assert!(!hx.had_validity_warning());

        hx.compute_htc(
            100.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
        )
        .unwrap();

        assert!(hx.had_validity_warning());
    }

    #[test]
    fn test_bphx_exchanger_invalid_input_returns_error() {
        let hx = BphxExchanger::new(test_geometry());
        let error = hx
            .compute_htc(
                30.0, 1.2, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
            )
            .unwrap_err();

        assert!(matches!(
            error,
            CorrelationSelectionError::InvalidInput(
                super::super::correlation_registry::DomainInputError::InvalidQuality { .. }
            )
        ));
        assert_eq!(hx.last_htc(), 0.0);
        assert!(hx.last_selection_report().is_none());
    }

    #[test]
    fn failed_htc_evaluation_clears_previous_success_state() {
        let hx = BphxExchanger::new(test_geometry()).with_refrigerant("R134a");
        hx.compute_htc(
            30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
        )
        .unwrap();
        assert!(hx.last_htc() > 0.0);
        assert!(hx.last_selection_report().is_some());

        hx.compute_htc(
            30.0, 1.2, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
        )
        .unwrap_err();

        assert_eq!(hx.last_htc(), 0.0);
        assert!(hx.last_htc_result().is_none());
        assert!(hx.last_selection_report().is_none());
        assert!(!hx.had_validity_warning());
    }

    #[test]
    fn test_bphx_exchanger_pressure_drop() {
        let geo = test_geometry();
        let hx = BphxExchanger::new(geo.clone());

        let dp = hx.compute_pressure_drop(30.0, 1100.0);
        assert!(dp >= 0.0);

        let mut hx_with_calib = BphxExchanger::new(geo);
        let mut calib = Calib::default();
        calib.z_dp = 0.5;
        hx_with_calib.set_calib(calib);

        let dp_calib = hx_with_calib.compute_pressure_drop(30.0, 1100.0);
        assert!((dp_calib - dp * 0.5).abs() < 1e-6);
    }

    #[test]
    fn test_bphx_pressure_drop_transition_is_c1() {
        // re = mass_flux · dh / 0.0002 = mass_flux · 15 for dh = 0.003.
        // The laminar→turbulent friction blend over [2300, 4000] must keep ΔP
        // and its slope continuous at the band edges so the analytic Jacobian
        // sees no kink.
        let hx = BphxExchanger::new(test_geometry());
        let rho = 1100.0;
        let re_to_g = |re: f64| re / 15.0;
        let d_g = 0.01; // mass-flux step for the finite-difference slope

        for &re_edge in &[2300.0_f64, 4000.0_f64] {
            let g = re_to_g(re_edge);
            let dp_below = hx.compute_pressure_drop(g - d_g, rho);
            let dp_at = hx.compute_pressure_drop(g, rho);
            let dp_above = hx.compute_pressure_drop(g + d_g, rho);

            let rel_jump = (dp_above - dp_below).abs() / dp_at.abs();
            assert!(
                rel_jump < 0.01,
                "ΔP jump {:.4} at Re = {}",
                rel_jump,
                re_edge
            );

            let slope_below = (dp_at - dp_below) / d_g;
            let slope_above = (dp_above - dp_at) / d_g;
            let denom = slope_below.abs().max(1e-9);
            assert!(
                (slope_above - slope_below).abs() / denom < 0.5,
                "ΔP slope discontinuity at Re = {} ({:.4} vs {:.4})",
                re_edge,
                slope_below,
                slope_above
            );
        }
    }

    #[test]
    fn test_bphx_pressure_drop_reverse_flow_and_low_re() {
        // ΔP must be a positive magnitude and symmetric under flow reversal
        // (friction now uses |Re|), and must not exhibit the old slope kink at
        // Re = 1 (the `re.max(1.0)` floor was lowered to a pure div-by-zero
        // guard). It must also stay finite and vanish at exactly zero flow.
        let hx = BphxExchanger::new(test_geometry());
        let rho = 1100.0;

        for &g in &[0.5_f64, 5.0, 50.0] {
            let dp_fwd = hx.compute_pressure_drop(g, rho);
            let dp_rev = hx.compute_pressure_drop(-g, rho);
            assert!(dp_fwd >= 0.0, "ΔP must be non-negative, got {dp_fwd}");
            assert!(
                (dp_fwd - dp_rev).abs() <= 1e-9 * dp_fwd.max(1.0),
                "ΔP not symmetric under flow reversal: {dp_fwd} vs {dp_rev}"
            );
        }

        // Zero flow → finite, ~zero ΔP (no NaN from the div-by-zero guard).
        let dp_zero = hx.compute_pressure_drop(0.0, rho);
        assert!(
            dp_zero.is_finite() && dp_zero.abs() < 1e-6,
            "ΔP(0) = {dp_zero}"
        );

        // No kink near the old Re = 1 transition (dh=0.003 ⇒ re = g·15).
        let g1 = 1.0 / 15.0; // Re = 1
        let d_g = g1 * 0.1;
        let s_below =
            (hx.compute_pressure_drop(g1, rho) - hx.compute_pressure_drop(g1 - d_g, rho)) / d_g;
        let s_above =
            (hx.compute_pressure_drop(g1 + d_g, rho) - hx.compute_pressure_drop(g1, rho)) / d_g;
        let denom = s_below.abs().max(1e-12);
        assert!(
            (s_above - s_below).abs() / denom < 0.5,
            "ΔP slope kink near Re = 1 ({s_below:.3e} vs {s_above:.3e})"
        );
    }

    #[test]
    fn test_bphx_both_sides_pressure_residuals_include_dp() {
        use entropyk_fluids::TestBackend;
        use std::sync::Arc;

        // State layout per port triple (ṁ, P, h):
        // hot_in 0..3, hot_out 3..6, cold_in 6..9, cold_out 9..12
        let mut hx =
            BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
        hx.set_hot_fluid("Water");
        hx.set_cold_fluid("Water");
        hx.set_port_context(&[
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ]);

        let mut state = vec![0.0; 12];
        // Liquid water-ish P-h (TestBackend): ~2 bar, ~80 kJ/kg
        state[0] = 0.25; // m_hot
        state[1] = 200_000.0;
        state[2] = 80_000.0;
        state[3] = 0.25;
        state[4] = 200_000.0; // isobaric guess — residual should show +ΔP
        state[5] = 70_000.0;
        state[6] = 0.20; // m_cold
        state[7] = 200_000.0;
        state[8] = 50_000.0;
        state[9] = 0.20;
        state[10] = 200_000.0;
        state[11] = 60_000.0;

        let n = hx.n_equations();
        assert!(n >= 4, "4-port BPHX should include two pressure rows");
        let mut residuals = vec![0.0; n];
        hx.compute_residuals(&state, &mut residuals).unwrap();

        let row_p = hx.inner.model_n_equations();
        assert!(
            residuals[row_p].abs() > 1.0,
            "hot pressure residual should include ΔP, got {}",
            residuals[row_p]
        );
        assert!(
            residuals[row_p + 1].abs() > 1.0,
            "cold pressure residual should include ΔP, got {}",
            residuals[row_p + 1]
        );

        // z_dp scale: half calib → half residual contribution at same P_out=P_in
        let mut hx_half =
            BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
        hx_half.set_hot_fluid("Water");
        hx_half.set_cold_fluid("Water");
        hx_half.set_port_context(&[
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ]);
        let mut calib = Calib::default();
        calib.z_dp = 0.5;
        hx_half.set_calib(calib);
        let mut r_half = vec![0.0; n];
        hx_half.compute_residuals(&state, &mut r_half).unwrap();
        assert!(
            (r_half[row_p] - 0.5 * residuals[row_p]).abs() < 1e-6 * residuals[row_p].abs().max(1.0),
            "z_dp should scale hot ΔP residual"
        );
        assert!(
            (r_half[row_p + 1] - 0.5 * residuals[row_p + 1]).abs()
                < 1e-6 * residuals[row_p + 1].abs().max(1.0),
            "z_dp should scale cold ΔP residual"
        );
    }

    #[test]
    fn test_bphx_martin_dp_in_residuals_differs() {
        use entropyk_fluids::TestBackend;
        use std::sync::Arc;

        let ports = [
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ];
        let mut state = vec![0.0; 12];
        state[0] = 0.30;
        state[1] = 200_000.0;
        state[2] = 80_000.0;
        state[3] = 0.30;
        state[4] = 200_000.0;
        state[5] = 70_000.0;
        state[6] = 0.30;
        state[7] = 200_000.0;
        state[8] = 50_000.0;
        state[9] = 0.30;
        state[10] = 200_000.0;
        state[11] = 60_000.0;

        let mut simp =
            BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
        simp.set_hot_fluid("Water");
        simp.set_cold_fluid("Water");
        simp.set_port_context(&ports);

        let mut martin = BphxExchanger::new(test_geometry())
            .with_dp_correlation(BphxDpCorrelation::Martin1996)
            .with_fluid_backend(Arc::new(TestBackend::new()));
        martin.set_hot_fluid("Water");
        martin.set_cold_fluid("Water");
        martin.set_port_context(&ports);

        let n = simp.n_equations();
        let mut r_s = vec![0.0; n];
        let mut r_m = vec![0.0; n];
        simp.compute_residuals(&state, &mut r_s).unwrap();
        martin.compute_residuals(&state, &mut r_m).unwrap();
        let row = simp.inner.model_n_equations();
        assert!(
            (r_s[row] - r_m[row]).abs() > 1.0,
            "Martin residual should differ from Simplified"
        );
    }

    #[test]
    fn test_bphx_condenser_energy_uses_phase_change_duty() {
        use entropyk_fluids::TestBackend;
        use std::sync::Arc;

        // Condenser geometry → Shah C*→0 path (not sensible ṁ·cp).
        let geo = test_geometry().with_exchanger_type(BphxType::Condenser);
        let mut hx = BphxExchanger::new(geo).with_fluid_backend(Arc::new(TestBackend::new()));
        hx.set_hot_fluid("R134a");
        hx.set_cold_fluid("Water");
        hx.set_port_context(&[
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ]);

        let mut state = vec![0.0; 12];
        // Refrigerant ~10 bar, superheated-ish enthalpy table values
        state[0] = 0.02;
        state[1] = 1_000_000.0;
        state[2] = 430_000.0;
        state[3] = 0.02;
        state[4] = 1_000_000.0;
        state[5] = 250_000.0;
        // Water secondary ~2 bar, ~30 °C
        state[6] = 0.40;
        state[7] = 200_000.0;
        state[8] = 125_000.0;
        state[9] = 0.40;
        state[10] = 200_000.0;
        state[11] = 140_000.0;

        assert!(hx.uses_phase_change_duty());
        let n = hx.n_equations();
        let mut residuals = vec![0.0; n];
        hx.compute_residuals(&state, &mut residuals).unwrap();

        let t_sat = hx.tsat_k(state[1], "R134a").unwrap();
        let (t_sec, c_sec) = hx.secondary_inlet_capacity(&state).unwrap();
        let q = condenser_duty(hx.ua(), c_sec, t_sat, t_sec);
        let q_hot = state[0] * (state[2] - state[5]);
        let expected_r0 = q_hot - q;
        assert!(
            (residuals[0] - expected_r0).abs() < 1e-4 * expected_r0.abs().max(1.0),
            "energy r0 must match condenser_duty: got {} expected {} (Q={}, Tsat={:.2}K)",
            residuals[0],
            expected_r0,
            q,
            t_sat
        );
        // Approach must use Tsat, not gas temperature — Q grows if Tsat rises.
        let q_higher = condenser_duty(hx.ua(), c_sec, t_sat + 10.0, t_sec);
        assert!(q_higher > q, "higher Tsat must increase condenser duty");
    }

    #[test]
    fn test_bphx_evaporator_energy_uses_phase_change_duty() {
        use entropyk_fluids::TestBackend;
        use std::sync::Arc;

        let geo = test_geometry().with_exchanger_type(BphxType::Evaporator);
        let mut hx = BphxExchanger::new(geo).with_fluid_backend(Arc::new(TestBackend::new()));
        // Same remapping as BphxEvaporator: hot = secondary, cold = refrigerant.
        hx.set_hot_fluid("Water");
        hx.set_cold_fluid("R134a");
        hx.set_port_context(&[
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ]);

        let mut state = vec![0.0; 12];
        // Water (hot) ~12 °C
        state[0] = 0.50;
        state[1] = 300_000.0;
        state[2] = 50_000.0;
        state[3] = 0.50;
        state[4] = 300_000.0;
        state[5] = 40_000.0;
        // Refrigerant (cold) ~3.5 bar
        state[6] = 0.02;
        state[7] = 350_000.0;
        state[8] = 250_000.0;
        state[9] = 0.02;
        state[10] = 350_000.0;
        state[11] = 400_000.0;

        assert!(hx.uses_phase_change_duty());
        let mut residuals = vec![0.0; hx.n_equations()];
        hx.compute_residuals(&state, &mut residuals).unwrap();

        let t_sat = hx.tsat_k(state[7], "R134a").unwrap();
        let (t_sec, c_sec) = hx.secondary_inlet_capacity(&state).unwrap();
        let q = evaporator_duty(hx.ua(), c_sec, t_sat, t_sec);
        let q_cold = state[6] * (state[11] - state[8]);
        let expected_r1 = q_cold - q;
        assert!(
            (residuals[1] - expected_r1).abs() < 1e-4 * expected_r1.abs().max(1.0),
            "energy r1 must match evaporator_duty: got {} expected {}",
            residuals[1],
            expected_r1
        );
    }
}
