//! Evaporator Component
//!
//! A heat exchanger configured for refrigerant evaporation.
//! The refrigerant (cold side) evaporates from two-phase mixture to
/// superheated vapor, absorbing heat from the hot side.
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::HeatExchanger;
use super::flow_regularization::{smooth_mass_magnitude, DEFAULT_M_EPS_KG_S};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
    Component, ComponentError, ConnectedPort, DomainViolation, JacobianBuilder, MeasuredOutput,
    ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::sync::Arc;

/// Evaporator heat exchanger.
///
/// Uses the ε-NTU method for heat transfer calculation.
/// The refrigerant evaporates on the cold side, absorbing heat
/// from the hot side (typically water or air).
///
/// # Configuration
///
/// - Hot side: Heat source (water, air, etc.)
/// - Cold side: Refrigerant evaporating (phase change)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Evaporator;
/// use entropyk_components::Component;
///
/// let evaporator = Evaporator::new(8_000.0);  // UA = 8 kW/K
/// assert_eq!(evaporator.n_equations(), 3);
/// ```
/// Evaporator heat exchanger.
///
/// Uses the ε-NTU method for heat transfer calculation.
/// The refrigerant evaporates on the cold side, absorbing heat
/// from the hot side (typically water or air).
pub struct Evaporator {
    inner: HeatExchanger<EpsNtuModel>,
    saturation_temp: f64,
    superheat_target: f64,
    refrigerant_id: String,
    fluid_backend: Option<Arc<dyn FluidBackend>>,
    inlet_p_idx: Option<usize>,
    /// Refrigerant inlet enthalpy state index (needed for the coupled energy balance).
    inlet_h_idx: Option<usize>,
    outlet_p_idx: Option<usize>,
    outlet_h_idx: Option<usize>,
    /// Mass-flow state index of the inlet edge (ṁ unknown, CM1.3).
    inlet_m_idx: Option<usize>,
    /// Mass-flow state index of the outlet edge (ṁ unknown, CM1.3).
    outlet_m_idx: Option<usize>,
    /// True when inlet and outlet share the same ṁ state index (CM1.4).
    same_branch_m: bool,
    /// Secondary (water/brine/air) inlet temperature [K] for the coupled ε-NTU model.
    secondary_inlet_temp_k: Option<f64>,
    /// Secondary heat-capacity rate ṁ·cp [W/K] for the coupled ε-NTU model.
    secondary_capacity_rate: Option<f64>,
    /// When `true`, the evaporating pressure is emergent: an extra outlet-closure
    /// residual pins the refrigerant outlet to the superheat target, so P_evap is
    /// determined by the ε-NTU ↔ secondary balance instead of the expansion valve.
    emergent_pressure: bool,
    /// When `true` (only meaningful together with `emergent_pressure`), the
    /// evaporator no longer *imposes* the outlet superheat via its outlet-closure
    /// residual. Superheat becomes emergent from the ε-NTU energy balance and is
    /// instead *regulated* by an external control loop acting on the expansion
    /// valve opening (p0b). Dropping the closure removes one thermodynamic
    /// residual, which is offset by the control loop's degrees of freedom.
    superheat_regulated: bool,
    /// Lumped quadratic `k` [Pa·s²/kg²] when no tube geometry is set.
    pressure_drop_coeff: Option<f64>,
    /// Tube-side two-phase correlation (MSH / Friedel).
    tube_dp_correlation: Option<crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation>,
    /// Tube channel geometry for frictional + acceleration ΔP.
    tube_dp_geometry: Option<crate::heat_exchanger::two_phase_dp::TubeChannelGeometry>,
    /// Secondary (water/brine/air) **inlet** edge state indices (Modelica-style
    /// 4-port mode). Wired by `set_port_context` from local port 2.
    sec_in_idx: Option<(usize, usize, usize)>,
    /// Secondary (water/brine/air) **outlet** edge state indices (local port 3).
    sec_out_idx: Option<(usize, usize, usize)>,
    /// Secondary fluid identifier for property queries ("Water",
    /// "INCOMP::MEG-30", "Air"…). Air uses the moist-air linear h↔T convention.
    secondary_fluid_id: String,
    /// Humidity ratio W [kg_v/kg_da] for the moist-air convention
    /// `h = 1006·T_c + W·(2 501 000 + 1860·T_c)` (matches `AirSource`).
    secondary_humidity_ratio: f64,
    /// Secondary water/air quadratic ΔP coeff `k` [Pa·s²/kg²] (`None` = isobaric).
    secondary_pressure_drop_coeff: Option<f64>,
    skip_pressure_eq: bool,
}

/// Pre-computed quantities shared between the secondary energy-balance
/// Jacobian rows (4-port mode). All evaluated at the current state.
#[derive(Debug, Clone, Copy)]
struct SecondaryJacCtx {
    /// `g = ε·C_sec` [W/K].
    g: f64,
    /// `g'(C_sec) = d(ε·C)/dC` [-].
    g_prime: f64,
    /// Secondary cp [J/(kg·K)].
    cp_sec: f64,
    /// `dT_sec,in/dh_sec,in = 1/cp` [K·kg/J].
    dt_sec_dh: f64,
    /// Driving temperature difference `T_sec,in − T_evap` [K].
    delta_t: f64,
    /// `dT_evap/dP_ref,in` [K/Pa] (central finite difference).
    dtevap_dp: f64,
    /// Refrigerant inlet-pressure state index.
    ref_p_in_idx: usize,
}

impl std::fmt::Debug for Evaporator {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Evaporator")
            .field("saturation_temp", &self.saturation_temp)
            .field("superheat_target", &self.superheat_target)
            .field("refrigerant_id", &self.refrigerant_id)
            .field("fluid_backend_set", &self.fluid_backend.is_some())
            .field("outlet_p_idx", &self.outlet_p_idx)
            .field("outlet_h_idx", &self.outlet_h_idx)
            .finish()
    }
}

impl Evaporator {
    /// Creates a new evaporator with the given UA value.
    ///
    /// # Arguments
    ///
    /// * `ua` - Overall heat transfer coefficient × Area (W/K)
    ///
    /// # Example
    ///
    /// ```
    /// use entropyk_components::heat_exchanger::Evaporator;
    ///
    /// let evaporator = Evaporator::new(8_000.0);
    /// ```
    pub fn new(ua: f64) -> Self {
        let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
        Self {
            inner: HeatExchanger::new(model, "Evaporator"),
            saturation_temp: 278.15,
            superheat_target: 5.0,
            refrigerant_id: String::new(),
            fluid_backend: None,
            inlet_p_idx: None,
            inlet_h_idx: None,
            outlet_p_idx: None,
            outlet_h_idx: None,
            inlet_m_idx: None,
            outlet_m_idx: None,
            same_branch_m: false,
            secondary_inlet_temp_k: None,
            secondary_capacity_rate: None,
            emergent_pressure: false,
            superheat_regulated: false,
            pressure_drop_coeff: None,
            tube_dp_correlation: None,
            tube_dp_geometry: None,
            sec_in_idx: None,
            sec_out_idx: None,
            secondary_fluid_id: String::new(),
            secondary_humidity_ratio: 0.0,
            secondary_pressure_drop_coeff: None,
            skip_pressure_eq: false,
        }
    }

    /// Secondary-side quadratic `ΔP_sec = k·ṁ·|ṁ|` [Pa]. Zero / unset = isobaric.
    pub fn with_secondary_pressure_drop_coeff(mut self, k_pa_s2_per_kg2: f64) -> Self {
        self.secondary_pressure_drop_coeff = if k_pa_s2_per_kg2 > 0.0 {
            Some(k_pa_s2_per_kg2)
        } else {
            None
        };
        self
    }

    pub fn set_secondary_pressure_drop_coeff(&mut self, k_pa_s2_per_kg2: f64) {
        self.secondary_pressure_drop_coeff = if k_pa_s2_per_kg2 > 0.0 {
            Some(k_pa_s2_per_kg2)
        } else {
            None
        };
    }

    fn secondary_delta_p(&self, m_sec: f64) -> f64 {
        match self.secondary_pressure_drop_coeff {
            Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec),
            _ => 0.0,
        }
    }

    fn secondary_delta_p_dm(&self, m_sec: f64) -> f64 {
        match self.secondary_pressure_drop_coeff {
            Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec),
            _ => 0.0,
        }
    }

    /// Creates an evaporator with specific saturation and superheat.
    pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self {
        let mut evap = Self::new(ua);
        evap.saturation_temp = saturation_temp;
        evap.superheat_target = superheat_target;
        evap
    }

    /// Attaches a refrigerant identifier used for saturation-property queries.
    pub fn with_refrigerant(mut self, refrigerant: &str) -> Self {
        self.refrigerant_id = refrigerant.to_string();
        self
    }

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

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

    /// Returns the UA value (effective: f_ua × UA_nominal).
    pub fn ua(&self) -> f64 {
        self.inner.ua()
    }

    /// Returns calibration factors (f_ua for evaporator).
    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 saturation temperature.
    pub fn saturation_temp(&self) -> f64 {
        self.saturation_temp
    }

    /// Returns the superheat target.
    pub fn superheat_target(&self) -> f64 {
        self.superheat_target
    }

    /// Sets the saturation temperature.
    pub fn set_saturation_temp(&mut self, temp: f64) {
        self.saturation_temp = temp;
    }

    /// Sets the superheat target.
    pub fn set_superheat_target(&mut self, superheat: f64) {
        self.superheat_target = superheat;
    }

    /// Defines the secondary (water/brine/air) boundary stream that drives the
    /// coupled ε-NTU duty.
    ///
    /// * `inlet_temp_k` - secondary fluid inlet temperature (K)
    /// * `capacity_rate_w_per_k` - heat-capacity rate ṁ·cp of the secondary fluid (W/K)
    ///
    /// When set (together with a fluid backend and resolved state indices), the
    /// evaporator computes a genuinely coupled duty
    /// `Q = ε·C_sec·(T_sec,in − T_evap(P))` and closes the refrigerant energy
    /// balance `ṁ_ref·(h_out − h_in) = Q`, so the cooling capacity reacts to the
    /// secondary inlet temperature and flow.
    pub fn with_secondary_stream(mut self, inlet_temp_k: f64, capacity_rate_w_per_k: f64) -> Self {
        self.secondary_inlet_temp_k = Some(inlet_temp_k);
        self.secondary_capacity_rate = Some(capacity_rate_w_per_k.max(0.0));
        self
    }

    /// Sets the secondary boundary stream (see [`with_secondary_stream`]).
    ///
    /// [`with_secondary_stream`]: Self::with_secondary_stream
    pub fn set_secondary_stream(&mut self, inlet_temp_k: f64, capacity_rate_w_per_k: f64) {
        self.secondary_inlet_temp_k = Some(inlet_temp_k);
        self.secondary_capacity_rate = Some(capacity_rate_w_per_k.max(0.0));
    }

    /// Declares the secondary fluid for the Modelica-style 4-port mode
    /// ("Water", "INCOMP::MEG-30", "Air"…). When the secondary inlet/outlet
    /// edges are wired (ports 2 and 3), the coupled duty reads `T_sec,in` and
    /// `C_sec = ṁ_sec·cp` directly from the live edge state instead of fixed
    /// parameters.
    pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
        self.secondary_fluid_id = fluid.into();
        self
    }

    /// Skips the pressure-equality equation (r0: P_out = P_in) in coupled mode.
    /// Use when both inlet and outlet pressures are imposed by boundary
    /// components (RefrigerantSource/Sink), making r0 redundant.
    pub fn with_skip_pressure_eq(mut self) -> Self {
        self.skip_pressure_eq = true;
        self
    }

    /// Sets the secondary fluid identifier (see [`with_secondary_fluid`]).
    ///
    /// [`with_secondary_fluid`]: Self::with_secondary_fluid
    pub fn set_secondary_fluid(&mut self, fluid: impl Into<String>) {
        self.secondary_fluid_id = fluid.into();
    }

    /// Sets the humidity ratio W [kg_v/kg_da] used by the moist-air h↔T
    /// convention on the secondary side (must match the upstream `AirSource`).
    pub fn set_secondary_humidity_ratio(&mut self, w: f64) {
        self.secondary_humidity_ratio = w.max(0.0);
    }

    /// `true` when the secondary fluid follows the moist-air linear convention
    /// (`h = 1006·T_c + W·(2 501 000 + 1860·T_c)`, as produced by `AirSource`).
    fn secondary_is_air(&self) -> bool {
        let f = self.secondary_fluid_id.trim();
        f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
    }

    /// `true` when both secondary edges are wired: the exchanger runs in true
    /// Modelica-style 4-port mode (edge-driven secondary stream).
    fn secondary_edges_ready(&self) -> bool {
        self.sec_in_idx.is_some()
            && self.sec_out_idx.is_some()
            && !self.secondary_fluid_id.is_empty()
    }

    /// `true` when the secondary inlet and outlet edges share the same branch
    /// ṁ unknown (closed pumped loop) — the secondary mass-conservation row is
    /// then trivially zero and must be dropped.
    fn sec_same_branch(&self) -> bool {
        matches!(
            (self.sec_in_idx, self.sec_out_idx),
            (Some((m_in, _, _)), Some((m_out, _, _))) if m_in == m_out
        )
    }

    /// Number of secondary-side residuals in 4-port mode.
    ///
    /// Isobaric `P_out − P_in = 0` plus mass (dropped on shared branch) and
    /// energy — required for Modelica `MassFlowSource_T` (Free P) + sink
    /// `Boundary_pT` loops to close pressure on the source edge.
    fn n_secondary(&self) -> usize {
        if !self.secondary_edges_ready() {
            0
        } else if self.sec_same_branch() {
            2 // P + energy
        } else {
            3 // P + mass + energy
        }
    }

    /// Residual row index of the first secondary equation.
    fn sec_row_start(&self) -> usize {
        self.n_thermo() + usize::from(!self.same_branch_m)
    }

    /// Secondary specific heat cp [J/(kg·K)] at the given edge state.
    fn sec_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        if self.secondary_is_air() {
            return Ok(1006.0 + 1860.0 * self.secondary_humidity_ratio);
        }
        let cp = self.query_secondary_property(Property::Cp, p_pa, h_jkg)?;
        if cp.is_finite() && cp > 0.0 {
            Ok(cp)
        } else {
            // A non-finite Cp means the trial (P, h) state left the fluid
            // model's valid envelope (e.g. two-phase region): a recoverable
            // domain violation (KINSOL `> 0`), not a fatal defect.
            Err(ComponentError::DomainViolation(DomainViolation {
                component: None,
                detail: format!("Evaporator secondary Cp is invalid: {}", cp),
            }))
        }
    }

    /// Secondary temperature [K] at the given edge state (exact linear
    /// inversion for moist air; backend `T(P, h)` otherwise).
    fn sec_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        if self.secondary_is_air() {
            let w = self.secondary_humidity_ratio;
            let t_c = (h_jkg - 2_501_000.0 * w) / (1006.0 + 1860.0 * w);
            return Ok(t_c + 273.15);
        }
        let t = self.query_secondary_property(Property::Temperature, p_pa, h_jkg)?;
        if t.is_finite() && t > 0.0 {
            Ok(t)
        } else {
            // Non-finite/negative T at a trial state = recoverable domain
            // violation (same class as the Cp check), not a fatal defect.
            Err(ComponentError::DomainViolation(DomainViolation {
                component: None,
                detail: format!("Evaporator secondary temperature is invalid: {}", t),
            }))
        }
    }

    fn query_secondary_property(
        &self,
        property: Property,
        p_pa: f64,
        h_jkg: f64,
    ) -> Result<f64, ComponentError> {
        if !p_pa.is_finite() || p_pa <= 0.0 {
            return Err(ComponentError::InvalidState(format!(
                "Evaporator secondary side has invalid pressure: {} Pa",
                p_pa
            )));
        }
        if !h_jkg.is_finite() {
            return Err(ComponentError::InvalidState(format!(
                "Evaporator secondary side has invalid enthalpy: {} J/kg",
                h_jkg
            )));
        }
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::InvalidState(
                "Evaporator secondary side requires a FluidBackend; no simulation fallback is allowed"
                    .to_string(),
            )
        })?;
        backend
            .property(
                FluidId::new(&self.secondary_fluid_id),
                property,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_pa),
                    entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
                ),
            )
            .map_err(|e| {
                ComponentError::CalculationFailed(format!(
                    "Evaporator failed to evaluate secondary property for fluid '{}': {}",
                    self.secondary_fluid_id, e
                ))
            })
    }

    /// Secondary stream `(T_sec,in [K], C_sec [W/K])`.
    ///
    /// Prefers live 4-port edges; falls back to rating scalars when no edges.
    fn live_secondary_stream(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> {
        if self.secondary_edges_ready() {
            let (m_s, p_s, h_s) = self.sec_in_idx.unwrap();
            if m_s < state.len() && p_s < state.len() && h_s < state.len() {
                let cp = self.sec_cp(state[p_s], state[h_s])?;
                let t = self.sec_temperature(state[p_s], state[h_s])?;
                let m_mag = smooth_mass_magnitude(state[m_s], DEFAULT_M_EPS_KG_S);
                return Ok((t, m_mag * cp));
            }
        }
        if self.rating_secondary_ready() {
            return Ok((
                self.secondary_inlet_temp_k.unwrap(),
                self.secondary_capacity_rate.unwrap(),
            ));
        }
        Err(ComponentError::InvalidState(
            "Evaporator requires live secondary edges (system mode) or rating scalars \
             (secondary_inlet_temp_* + capacity rate / mass·cp)"
                .to_string(),
        ))
    }

    /// Appends the secondary-side residuals (4-port mode).
    ///
    /// * secondary momentum: `P_sec,out − P_sec,in + ΔP_sec(ṁ) = 0`
    /// * mass conservation: `ṁ_sec,out − ṁ_sec,in = 0` (dropped on a shared branch)
    /// * energy balance:
    ///   - physical (`q = Some(Q)`): `ṁ_sec·(h_out − h_in) + Q = 0`
    ///     (the secondary stream is *cooled* by the evaporator duty `Q > 0`)
    ///   - seeding (`q = None`): `h_out − h_in = 0` keeps the row non-singular.
    fn secondary_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
        q: Option<f64>,
    ) {
        if !self.secondary_edges_ready() {
            return;
        }
        let (m_in, p_in, h_in) = self.sec_in_idx.unwrap();
        let (m_out, p_out, h_out) = self.sec_out_idx.unwrap();
        let mut row = self.sec_row_start();
        let m_sec = state[m_in];
        residuals[row] = state[p_out] - state[p_in] + self.secondary_delta_p(m_sec);
        row += 1;
        if !self.sec_same_branch() {
            residuals[row] = state[m_out] - state[m_in];
            row += 1;
        }
        residuals[row] = match q {
            // Raw ṁ (not max(0)) for reverse-flow robustness; C_sec uses smooth |ṁ|.
            Some(q) => state[m_in] * (state[h_out] - state[h_in]) + q,
            None => state[h_out] - state[h_in],
        };
    }

    /// Appends the secondary-side Jacobian entries matching
    /// [`secondary_residuals`](Self::secondary_residuals).
    fn secondary_jacobian(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
        ctx: Option<SecondaryJacCtx>,
    ) {
        if !self.secondary_edges_ready() {
            return;
        }
        let (m_in, p_in, h_in) = self.sec_in_idx.unwrap();
        let (m_out, p_out, h_out) = self.sec_out_idx.unwrap();
        let mut row = self.sec_row_start();
        let m_sec_p = state[m_in];
        jacobian.add_entry(row, p_out, 1.0);
        jacobian.add_entry(row, p_in, -1.0);
        let ddp_dm = self.secondary_delta_p_dm(m_sec_p);
        if ddp_dm != 0.0 {
            jacobian.add_entry(row, m_in, ddp_dm);
        }
        row += 1;
        if !self.sec_same_branch() {
            jacobian.add_entry(row, m_out, 1.0);
            jacobian.add_entry(row, m_in, -1.0);
            row += 1;
        }
        match ctx {
            Some(c) => {
                let m_sec = state[m_in];
                // r = ṁ_sec·(h_out − h_in) + Q(P_ref_in, ṁ_sec, h_sec_in),
                // Q = g·(T_sec,in − T_evap(P)).
                jacobian.add_entry(row, h_out, m_sec);
                // ∂r/∂h_in = −ṁ_sec + ∂Q/∂h_in = −ṁ_sec + g·dT/dh.
                jacobian.add_entry(row, h_in, -m_sec + c.g * c.dt_sec_dh);
                // ∂r/∂ṁ_sec = Δh + ∂Q/∂ṁ_sec = Δh + g'(C_sec)·cp·ΔT.
                jacobian.add_entry(
                    row,
                    m_in,
                    (state[h_out] - state[h_in]) + c.g_prime * c.cp_sec * c.delta_t,
                );
                // ∂r/∂P_ref_in = ∂Q/∂P = −g·dT_evap/dP.
                jacobian.add_entry(row, c.ref_p_in_idx, -c.g * c.dtevap_dp);
                // Live z_ua: secondary r = ṁ·Δh + Q ⇒ ∂r/∂z = +∂Q/∂z
                if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
                    let ua = self.live_ua(Some(state));
                    let c_sec = state[m_in].abs() * c.cp_sec;
                    let e = if c_sec > 1e-10 && ua > 0.0 {
                        (-ua / c_sec).exp()
                    } else {
                        0.0
                    };
                    let d_q_dz = self.inner.ua_nominal() * e * c.delta_t;
                    jacobian.add_entry(row, z_ua_idx, d_q_dz);
                }
            }
            None => {
                jacobian.add_entry(row, h_out, 1.0);
                jacobian.add_entry(row, h_in, -1.0);
            }
        }
    }

    /// Enables a lumped two-phase refrigerant pressure drop `ΔP = k·ṁ·|ṁ|`.
    ///
    /// `k` [Pa·s²/kg²] can be calibrated from a rated point with
    /// [`two_phase_dp::calibrate_quadratic_k`]. Only active on the coupled path
    /// when a mass-flow index is resolved; `None`/0 keeps the zero-drop model.
    ///
    /// [`two_phase_dp::calibrate_quadratic_k`]: crate::heat_exchanger::two_phase_dp::calibrate_quadratic_k
    pub fn with_pressure_drop_coeff(mut self, k_pa_s2_per_kg2: f64) -> Self {
        self.pressure_drop_coeff = Some(k_pa_s2_per_kg2.max(0.0));
        self.tube_dp_correlation = None;
        self.tube_dp_geometry = None;
        self
    }

    /// Idealised isobaric refrigerant path (`P_out = P_in`, `ΔP = 0`).
    pub fn with_isobaric(mut self) -> Self {
        self.pressure_drop_coeff = None;
        self.tube_dp_correlation = None;
        self.tube_dp_geometry = None;
        self
    }

    /// Tube-side two-phase ΔP (MSH or Friedel + acceleration).
    pub fn with_tube_pressure_drop(
        mut self,
        correlation: crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation,
        geometry: crate::heat_exchanger::two_phase_dp::TubeChannelGeometry,
    ) -> Self {
        self.tube_dp_correlation = Some(correlation);
        self.tube_dp_geometry = Some(geometry);
        self.pressure_drop_coeff = None;
        self
    }

    /// Sets the lumped quadratic coefficient (clears tube mode).
    pub fn set_pressure_drop_coeff(&mut self, k_pa_s2_per_kg2: f64) {
        self.pressure_drop_coeff = Some(k_pa_s2_per_kg2.max(0.0));
        self.tube_dp_correlation = None;
        self.tube_dp_geometry = None;
    }

    /// Configures tube two-phase ΔP (MSH/Friedel + acceleration).
    pub fn set_tube_pressure_drop(
        &mut self,
        correlation: crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation,
        geometry: crate::heat_exchanger::two_phase_dp::TubeChannelGeometry,
    ) {
        self.tube_dp_correlation = Some(correlation);
        self.tube_dp_geometry = Some(geometry);
        self.pressure_drop_coeff = None;
    }

    #[inline]
    fn resolved_mass_idx(&self) -> Option<usize> {
        self.inlet_m_idx.or(self.outlet_m_idx)
    }

    /// Signed refrigerant ΔP [Pa]: tube MSH/Friedel (+ acceleration) if
    /// configured, else lumped quadratic, else 0.
    ///
    /// The tube arm is **total and C¹** (see [`crate::heat_exchanger::tube_dp`]
    /// and the Story 0.2 phantom-gradient regularization standard): it never
    /// silently switches to the lumped model when a Newton iterate leaves the
    /// saturation domain — the query pressure is C¹-clamped into the detected
    /// domain and the latent heat is smooth-floored, so a hard backend failure
    /// is a recoverable [`ComponentError::DomainViolation`] (Story 1.3), not a
    /// model switch. The only remaining lumped fallback is solve-invariant
    /// (no backend / empty refrigerant id / no detectable saturation domain),
    /// so the active ΔP model cannot change mid-solve.
    fn refrigerant_pressure_drop(
        &self,
        m_ref: f64,
        p_pa: f64,
        h_in: f64,
        h_out: f64,
    ) -> Result<f64, ComponentError> {
        if self.resolved_mass_idx().is_none() {
            return Ok(0.0);
        }
        if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
            if let Some(backend) = self.fluid_backend.as_ref() {
                if let Some(state) = crate::heat_exchanger::tube_dp::sat_dp_value_state(
                    backend,
                    &self.refrigerant_id,
                    p_pa,
                )? {
                    let x_in = state.quality(h_in);
                    let x_out = state.quality(h_out);
                    return Ok(crate::heat_exchanger::two_phase_dp::tube_two_phase_delta_p(
                        corr,
                        &geom,
                        m_ref,
                        x_in,
                        x_out,
                        &state.props,
                    ));
                }
            }
        }
        Ok(match self.pressure_drop_coeff {
            Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref),
            _ => 0.0,
        })
    }

    /// ΔP value + **exact** partials (∂/∂ṁ, ∂/∂P_in, ∂/∂h_in, ∂/∂h_out) for the
    /// momentum-row Jacobian (NFR9 exact-analytic policy).
    ///
    /// The tube ΔP composition is differentiated analytically (ṁ → mass flux →
    /// friction + acceleration; h → unclamped qualities; P → saturated
    /// properties) via [`crate::heat_exchanger::tube_dp`]; only the individual
    /// saturation-property pressure derivatives use narrow domain-safe FDs
    /// (the thin CoolProp FFI exposes no saturation derivatives). The lumped
    /// quadratic arm contributes its analytic `2·k·|ṁ|` only.
    fn refrigerant_pressure_drop_jacobian(
        &self,
        m_ref: f64,
        p_pa: f64,
        h_in: f64,
        h_out: f64,
    ) -> Result<crate::heat_exchanger::tube_dp::TubeDpEvaluation, ComponentError> {
        if self.resolved_mass_idx().is_none() {
            return Ok(crate::heat_exchanger::tube_dp::TubeDpEvaluation::default());
        }
        if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
            if let Some(backend) = self.fluid_backend.as_ref() {
                if let Some(state) = crate::heat_exchanger::tube_dp::sat_dp_full_state(
                    backend,
                    &self.refrigerant_id,
                    p_pa,
                )? {
                    return Ok(crate::heat_exchanger::tube_dp::tube_dp_evaluation(
                        corr, &geom, m_ref, h_in, h_out, &state,
                    ));
                }
            }
        }
        Ok(match self.pressure_drop_coeff {
            Some(k) if k > 0.0 => crate::heat_exchanger::tube_dp::TubeDpEvaluation {
                value: crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref),
                d_dm: crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_ref),
                ..crate::heat_exchanger::tube_dp::TubeDpEvaluation::default()
            },
            _ => crate::heat_exchanger::tube_dp::TubeDpEvaluation::default(),
        })
    }

    /// Enables emergent-pressure mode. An extra outlet-closure residual pins the
    /// refrigerant outlet to the superheat target `h(P, T_evap(P) + superheat)`.
    /// Combined with the ε-NTU energy balance this makes the evaporating pressure
    /// emerge from the secondary conditions rather than being imposed by the
    /// expansion valve. Requires a secondary stream.
    pub fn with_emergent_pressure(mut self) -> Self {
        self.emergent_pressure = true;
        self
    }

    /// Enables *regulated* superheat (p0b): the emergent outlet-closure that pins
    /// the outlet to the superheat target is dropped, so superheat emerges from
    /// the ε-NTU energy balance and is regulated by an external control loop
    /// acting on the expansion-valve opening. Only meaningful together with
    /// [`with_emergent_pressure`]; removes one thermodynamic residual.
    ///
    /// [`with_emergent_pressure`]: Self::with_emergent_pressure
    pub fn with_regulated_superheat(mut self) -> Self {
        self.superheat_regulated = true;
        self
    }

    /// Sets whether superheat is externally regulated (see [`with_regulated_superheat`]).
    ///
    /// [`with_regulated_superheat`]: Self::with_regulated_superheat
    pub fn set_regulated_superheat(&mut self, regulated: bool) {
        self.superheat_regulated = regulated;
    }

    /// Returns `true` when the emergent outlet-closure (superheat-imposition)
    /// residual must be emitted: emergent mode AND superheat is not externally
    /// regulated.
    #[inline]
    fn imposes_superheat(&self) -> bool {
        self.emergent_pressure && !self.superheat_regulated
    }

    /// Number of thermodynamic residuals emitted (2 normally, 3 in emergent mode
    /// unless superheat is externally regulated, which drops the outlet closure).
    fn n_thermo(&self) -> usize {
        let base = if self.skip_pressure_eq {
            1
        } else {
            self.inner.n_equations()
        };
        base + if self.imposes_superheat() { 1 } else { 0 }
    }

    /// Rating scalars present: `T_sec,in` and strictly positive `C_sec`.
    fn rating_secondary_ready(&self) -> bool {
        matches!(
            (self.secondary_inlet_temp_k, self.secondary_capacity_rate),
            (Some(t), Some(c)) if t.is_finite() && c.is_finite() && c > 0.0
        )
    }

    /// Coupled ε-NTU when refrigerant indices are ready and secondary is either
    /// live edges (system) or rating scalars.
    fn coupled_ready(&self) -> bool {
        self.fluid_backend.is_some()
            && !self.refrigerant_id.is_empty()
            && self.inlet_p_idx.is_some()
            && self.inlet_h_idx.is_some()
            && self.outlet_p_idx.is_some()
            && self.outlet_h_idx.is_some()
            && (self.secondary_edges_ready() || self.rating_secondary_ready())
    }

    /// Saturation (evaporating) temperature of the refrigerant at pressure `p_pa` (K).
    fn evap_temperature(&self, p_pa: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("Evaporator: no fluid backend".to_string())
        })?;
        // Clamp into the detected saturation domain so the evaporating
        // temperature — and every residual derived from it — stays defined
        // when a Newton iterate leaves the domain (see sat_domain).
        let p_pa = crate::heat_exchanger::sat_domain::clamp_to_saturation_domain(
            backend,
            &self.refrigerant_id,
            p_pa,
        )
        .unwrap_or(p_pa);
        backend
            .property(
                FluidId::new(&self.refrigerant_id),
                Property::Temperature,
                FluidState::PressureQuality(Pressure::from_pascals(p_pa), Quality(0.5)),
            )
            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))
    }

    /// Live UA = UA_nominal × z_ua (reads `state[calib_indices.z_ua]` when free).
    fn live_ua(&self, state: Option<&StateSlice>) -> f64 {
        let z = state
            .and_then(|st| {
                self.inner
                    .calib_indices_ref()
                    .z_ua
                    .and_then(|idx| st.get(idx).copied())
            })
            .unwrap_or_else(|| self.calib().z_ua);
        self.inner.ua_nominal() * z.max(0.0)
    }

    /// Effectiveness for a phase-changing refrigerant (`C_min = C_sec`, `C_r → 0`):
    /// `ε = 1 − exp(−UA / C_sec)`.
    fn effectiveness(&self, c_sec: f64, ua: f64) -> f64 {
        if c_sec <= 1e-10 || ua <= 0.0 {
            return 0.0;
        }
        1.0 - (-ua / c_sec).exp()
    }

    /// Coupled evaporator duty `Q = ε·C_sec·(T_sec,in − T_evap(P))` in watts.
    ///
    /// Positive `Q` means heat flows from the secondary fluid into the refrigerant.
    #[cfg_attr(not(test), allow(dead_code))]
    fn coupled_duty(&self, p_in_pa: f64) -> Result<f64, ComponentError> {
        let c_sec = self.secondary_capacity_rate.unwrap_or(0.0);
        let t_sec_in = self.secondary_inlet_temp_k.unwrap_or(0.0);
        let t_evap = self.evap_temperature(p_in_pa)?;
        let ua = self.live_ua(None);
        let eps = self.effectiveness(c_sec, ua);
        Ok(eps * c_sec * (t_sec_in - t_evap))
    }

    /// Validates that the outlet quality is >= 0 (fully evaporated or superheated).
    ///
    /// # Arguments
    ///
    /// * `outlet_enthalpy` - Outlet specific enthalpy (J/kg)
    /// * `h_liquid` - Saturated liquid enthalpy at evaporating pressure
    /// * `h_vapor` - Saturated vapor enthalpy at evaporating pressure
    ///
    /// # Returns
    ///
    /// Returns Ok(superheat) if valid, Err otherwise
    pub fn validate_outlet_quality(
        &self,
        outlet_enthalpy: f64,
        h_liquid: f64,
        h_vapor: f64,
        cp_vapor: f64,
    ) -> Result<f64, ComponentError> {
        if h_vapor <= h_liquid {
            return Err(ComponentError::NumericalError(
                "Invalid saturation enthalpies".to_string(),
            ));
        }

        let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);

        if quality >= 1.0 - 1e-6 {
            if outlet_enthalpy >= h_vapor {
                let superheat = (outlet_enthalpy - h_vapor) / cp_vapor;
                Ok(superheat)
            } else {
                Ok(0.0)
            }
        } else {
            Err(ComponentError::InvalidState(format!(
                "Evaporator outlet quality {} < 1 (not fully evaporated)",
                quality
            )))
        }
    }

    /// Calculates the superheat residual for inverse control.
    ///
    /// Returns (actual_superheat - target_superheat)
    pub fn superheat_residual(&self, actual_superheat: f64) -> f64 {
        actual_superheat - self.superheat_target
    }

    /// Returns the target outlet enthalpy (superheated vapor) at a given inlet pressure [J/kg].
    fn h_superheat_at_p(
        &self,
        backend: &dyn FluidBackend,
        fluid: FluidId,
        p_in_pa: f64,
    ) -> Result<f64, ComponentError> {
        let t_sat_k = backend
            .property(
                fluid.clone(),
                Property::Temperature,
                FluidState::PressureQuality(Pressure::from_pascals(p_in_pa), Quality(1.0)),
            )
            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
        backend
            .property(
                fluid,
                Property::Enthalpy,
                FluidState::PressureTemperature(
                    Pressure::from_pascals(p_in_pa),
                    Temperature::from_kelvin(t_sat_k + self.superheat_target),
                ),
            )
            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))
    }

    /// Real outlet superheat [K] from the current state:
    /// `SH = T(P_out, h_out) − T_sat(P_out)`.
    ///
    /// Returns `None` when the backend/refrigerant/state indices are unavailable
    /// or a property query fails / is non-finite. In the two-phase region the
    /// backend returns `T(P,h) = T_sat`, so the result is ≈ 0 (a valid measurement
    /// telling a controller there is no superheat), not `None`.
    fn measured_superheat(&self, state: &StateSlice) -> Option<f64> {
        let backend = self.fluid_backend.as_ref()?;
        if self.refrigerant_id.is_empty() {
            return None;
        }
        let p_idx = self.outlet_p_idx?;
        let h_idx = self.outlet_h_idx?;
        if p_idx >= state.len() || h_idx >= state.len() {
            return None;
        }
        let p_out = state[p_idx];
        let h_out = state[h_idx];
        if !p_out.is_finite() || !h_out.is_finite() || p_out <= 0.0 {
            return None;
        }
        let fluid = FluidId::new(&self.refrigerant_id);
        let t_out = backend
            .property(
                fluid.clone(),
                Property::Temperature,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_out),
                    Enthalpy::from_joules_per_kg(h_out),
                ),
            )
            .ok()?;
        let t_sat = backend
            .property(
                fluid,
                Property::Temperature,
                FluidState::PressureQuality(Pressure::from_pascals(p_out), Quality(1.0)),
            )
            .ok()?;
        let sh = t_out - t_sat;
        sh.is_finite().then_some(sh)
    }

    /// Measured evaporating (saturation) temperature SST `T_sat(P_out)` [K]
    /// from the solved state, for capacity/slide-valve control loops.
    fn measured_saturation_temperature(&self, state: &StateSlice) -> Option<f64> {
        let backend = self.fluid_backend.as_ref()?;
        if self.refrigerant_id.is_empty() {
            return None;
        }
        let p_idx = self.outlet_p_idx.or(self.inlet_p_idx)?;
        if p_idx >= state.len() {
            return None;
        }
        let p = state[p_idx];
        if !p.is_finite() || p <= 0.0 {
            return None;
        }
        let t_sat = backend
            .property(
                FluidId::new(&self.refrigerant_id),
                Property::Temperature,
                FluidState::PressureQuality(Pressure::from_pascals(p), Quality(1.0)),
            )
            .ok()?;
        t_sat.is_finite().then_some(t_sat)
    }

    /// Computes the full thermodynamic state at the hot (secondary fluid) inlet.
    pub fn hot_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
        self.inner.hot_inlet_state()
    }

    /// Computes the full thermodynamic state at the cold inlet.
    pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
        self.inner.cold_inlet_state()
    }

    /// Returns the hot side fluid identifier, if set.
    pub fn hot_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
        self.inner.hot_fluid_id()
    }

    /// Sets the hot side boundary conditions.
    pub fn set_hot_conditions(&mut self, conditions: super::exchanger::HxSideConditions) {
        self.inner.set_hot_conditions(conditions);
    }

    /// Returns the cold side fluid identifier, if set.
    pub fn cold_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
        self.inner.cold_fluid_id()
    }
}

impl Component for Evaporator {
    fn set_system_context(
        &mut self,
        _state_offset: usize,
        external_edge_state_indices: &[(usize, usize, usize)],
    ) {
        // For a typical single-circuit evaporator: 1 incoming (from EXV) + 1 outgoing (to compressor).
        // Triple: (m_idx, p_idx, h_idx)
        if !external_edge_state_indices.is_empty() {
            self.inlet_m_idx = Some(external_edge_state_indices[0].0);
            self.inlet_p_idx = Some(external_edge_state_indices[0].1);
            self.inlet_h_idx = Some(external_edge_state_indices[0].2);
        }
        if external_edge_state_indices.len() >= 2 {
            self.outlet_m_idx = Some(external_edge_state_indices[1].0);
            self.outlet_p_idx = Some(external_edge_state_indices[1].1);
            self.outlet_h_idx = Some(external_edge_state_indices[1].2);
        }
        // CM1.4: detect same-branch topology.
        self.same_branch_m = matches!(
            (self.inlet_m_idx, self.outlet_m_idx),
            (Some(m_in), Some(m_out)) if m_in == m_out
        );
        self.inner
            .set_system_context(_state_offset, external_edge_state_indices);
    }

    fn compute_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        let n_thermo = self.n_thermo(); // 2, or 3 in emergent mode

        // Coupled ε-NTU path: when a secondary (water/brine/air) stream is configured,
        // the duty Q = ε·C_sec·(T_sec,in − T_evap(P_in)) is drawn from that stream and
        // the refrigerant outlet enthalpy follows the energy balance
        // ṁ·(h_out − h_in) = Q. The cooling capacity then reacts to the secondary
        // inlet temperature and flow.
        if self.coupled_ready() {
            if residuals.len() < self.n_equations() {
                return Err(ComponentError::InvalidResidualDimensions {
                    expected: self.n_equations(),
                    actual: residuals.len(),
                });
            }
            let inlet_p_idx = self.inlet_p_idx.unwrap();
            let p_in = state[inlet_p_idx];
            if p_in > 10_000.0 {
                let inlet_h_idx = self.inlet_h_idx.unwrap();
                let outlet_p_idx = self.outlet_p_idx.unwrap();
                let outlet_h_idx = self.outlet_h_idx.unwrap();
                let m_idx = self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
                    ComponentError::InvalidState(
                        "mass-flow state index not resolved (cannot fall back to a pressure index)"
                            .into(),
                    )
                })?;

                let m_ref = state[m_idx];
                let h_in = state[inlet_h_idx];
                let h_out = state[outlet_h_idx];
                // Live secondary stream: edge-driven in 4-port mode (Modelica).
                let (t_sec_in, c_sec) = self.live_secondary_stream(state)?;
                let t_evap = self.evap_temperature(p_in)?;
                let ua = self.live_ua(Some(state));
                let eps = self.effectiveness(c_sec, ua);
                let q = eps * c_sec * (t_sec_in - t_evap);

                // r0: refrigerant pressure drop (tube MSH/Friedel + accel, or
                // lumped quadratic): P_out = P_in − ΔP.
                let dp_drop = self.refrigerant_pressure_drop(m_ref, p_in, h_in, h_out)?;
                let mut row = 0;
                if !self.skip_pressure_eq {
                    residuals[row] = state[outlet_p_idx] - (p_in - dp_drop);
                    row += 1;
                }
                residuals[row] = m_ref * (h_out - h_in) - q;
                row += 1;

                if self.imposes_superheat() {
                    let h_target = self.h_superheat_at_p(
                        self.fluid_backend.as_ref().unwrap().as_ref(),
                        FluidId::new(&self.refrigerant_id),
                        p_in,
                    )?;
                    residuals[row] = state[outlet_h_idx] - h_target;
                }

                if !self.same_branch_m {
                    residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) {
                        (Some(m_in), Some(m_out)) => state[m_out] - state[m_in],
                        _ => 0.0,
                    };
                }
                // 4-port mode: secondary mass conservation + energy balance
                // (the secondary stream is cooled by Q).
                self.secondary_residuals(state, residuals, Some(q));
                return Ok(());
            } else if self.emergent_pressure {
                // Transient seeding (P_in not yet physical): keep the emergent
                // residuals defined so the residual/DoF count stays consistent.
                // When superheat is regulated the outlet-closure (r2) is dropped,
                // so only the two pressure seeds are emitted.
                let backend = self.fluid_backend.as_ref().unwrap();
                let fluid = FluidId::new(&self.refrigerant_id);
                let outlet_p_idx = self.outlet_p_idx.unwrap();
                let outlet_h_idx = self.outlet_h_idx.unwrap();
                let p_evap_sat = backend
                    .saturation_pressure_t(fluid.clone(), self.saturation_temp)
                    .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                residuals[0] = state[outlet_p_idx] - p_evap_sat;
                residuals[1] = state[inlet_p_idx] - p_evap_sat;
                if self.imposes_superheat() {
                    let t_superheat_k = self.saturation_temp + self.superheat_target;
                    let h_superheated = backend
                        .property(
                            fluid,
                            Property::Enthalpy,
                            FluidState::PressureTemperature(
                                entropyk_core::Pressure::from_pascals(p_evap_sat),
                                entropyk_core::Temperature::from_kelvin(t_superheat_k),
                            ),
                        )
                        .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                    residuals[2] = state[outlet_h_idx] - h_superheated;
                }
                if !self.same_branch_m {
                    residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) {
                        (Some(m_in), Some(m_out)) => state[m_out] - state[m_in],
                        _ => 0.0,
                    };
                }
                // 4-port seeding: keep the secondary rows well-posed.
                self.secondary_residuals(state, residuals, None);
                return Ok(());
            }
        }

        if let (Some(backend), Some(outlet_p_idx), Some(outlet_h_idx)) =
            (&self.fluid_backend, self.outlet_p_idx, self.outlet_h_idx)
        {
            if !self.refrigerant_id.is_empty() {
                if residuals.len() < self.n_equations() {
                    return Err(ComponentError::InvalidResidualDimensions {
                        expected: self.n_equations(),
                        actual: residuals.len(),
                    });
                }

                if let Some(inlet_p_idx) = self.inlet_p_idx {
                    let p_in = state[inlet_p_idx];
                    // Only use coupled model when inlet pressure is physically plausible.
                    if p_in > 10_000.0 {
                        let h_target = self.h_superheat_at_p(
                            backend.as_ref(),
                            FluidId::new(&self.refrigerant_id),
                            p_in,
                        )?;
                        residuals[0] = state[outlet_p_idx] - p_in;
                        residuals[1] = state[outlet_h_idx] - h_target;
                    } else {
                        let fluid = FluidId::new(&self.refrigerant_id);
                        let p_evap_sat = backend
                            .saturation_pressure_t(fluid.clone(), self.saturation_temp)
                            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                        let t_superheat_k = self.saturation_temp + self.superheat_target;
                        let h_superheated = backend
                            .property(
                                fluid,
                                Property::Enthalpy,
                                FluidState::PressureTemperature(
                                    Pressure::from_pascals(p_evap_sat),
                                    Temperature::from_kelvin(t_superheat_k),
                                ),
                            )
                            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                        residuals[0] = state[outlet_p_idx] - p_evap_sat;
                        residuals[1] = state[outlet_h_idx] - h_superheated;
                    }
                } else {
                    // Fallback: fixed saturation-temperature model.
                    let fluid = FluidId::new(&self.refrigerant_id);
                    let p_evap_sat = backend
                        .saturation_pressure_t(fluid.clone(), self.saturation_temp)
                        .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                    let t_superheat_k = self.saturation_temp + self.superheat_target;
                    let h_superheated = backend
                        .property(
                            fluid,
                            Property::Enthalpy,
                            FluidState::PressureTemperature(
                                entropyk_core::Pressure::from_pascals(p_evap_sat),
                                entropyk_core::Temperature::from_kelvin(t_superheat_k),
                            ),
                        )
                        .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                    residuals[0] = state[outlet_p_idx] - p_evap_sat;
                    residuals[1] = state[outlet_h_idx] - h_superheated;
                }
                // r[n_thermo] = ṁ_outlet − ṁ_inlet = 0  (mass conservation, CM1.3)
                // CM1.4: skip when same_branch_m — trivially zero.
                if !self.same_branch_m {
                    residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) {
                        (Some(m_in), Some(m_out)) => state[m_out] - state[m_in],
                        _ => 0.0,
                    };
                }
                // 4-port: secondary rows stay defined even on this transient path.
                self.secondary_residuals(state, residuals, None);
                return Ok(());
            }
        }
        Err(ComponentError::InvalidState(
            "Evaporator requires refrigerant inlet/outlet indices with backend/refrigerant, \
             and either live secondary ports (system) or rating scalars \
             (secondary_inlet_temp_* + capacity rate); refusing generic HX fallback"
                .to_string(),
        ))
    }

    fn jacobian_entries(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        let n_thermo = self.n_thermo(); // 2, or 3 in emergent mode

        // Coupled ε-NTU path: analytical Jacobian of the energy balance.
        if self.coupled_ready() {
            let inlet_p_idx = self.inlet_p_idx.unwrap();
            let p_in = state[inlet_p_idx];
            if p_in > 10_000.0 {
                let inlet_h_idx = self.inlet_h_idx.unwrap();
                let outlet_p_idx = self.outlet_p_idx.unwrap();
                let outlet_h_idx = self.outlet_h_idx.unwrap();
                let m_idx = self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
                    ComponentError::InvalidState(
                        "mass-flow state index not resolved (cannot fall back to a pressure index)"
                            .into(),
                    )
                })?;

                let m_ref = state[m_idx];
                let h_in = state[inlet_h_idx];
                let h_out = state[outlet_h_idx];

                let mut row = 0;
                if !self.skip_pressure_eq {
                    jacobian.add_entry(row, outlet_p_idx, 1.0);
                    jacobian.add_entry(row, inlet_p_idx, -1.0);
                    // Momentum-row tube-ΔP partials: exact analytic composition
                    // (∂/∂ṁ, ∂/∂P_in through the saturated properties, ∂/∂h_in
                    // and ∂/∂h_out through the unclamped qualities) — NFR9.
                    let eval = self.refrigerant_pressure_drop_jacobian(m_ref, p_in, h_in, h_out)?;
                    if let Some(m_real) = self.resolved_mass_idx() {
                        if eval.d_dm != 0.0 {
                            jacobian.add_entry(row, m_real, eval.d_dm);
                        }
                    }
                    if eval.d_dp != 0.0 {
                        jacobian.add_entry(row, inlet_p_idx, eval.d_dp);
                    }
                    if eval.d_dh_in != 0.0 {
                        jacobian.add_entry(row, inlet_h_idx, eval.d_dh_in);
                    }
                    if eval.d_dh_out != 0.0 {
                        jacobian.add_entry(row, outlet_h_idx, eval.d_dh_out);
                    }
                    row += 1;
                }

                jacobian.add_entry(row, outlet_h_idx, m_ref);
                jacobian.add_entry(row, inlet_h_idx, -m_ref);
                jacobian.add_entry(row, m_idx, h_out - h_in);

                // ∂r1/∂P_in = −∂Q/∂P_in = ε·C_sec·dT_evap/dP_in (T_sec,in constant),
                // dT_evap/dP via central finite difference.
                let (t_sec_in, c_sec) = self.live_secondary_stream(state)?;
                let ua = self.live_ua(Some(state));
                let eps = self.effectiveness(c_sec, ua);
                let g = eps * c_sec;
                let t_evap = self.evap_temperature(p_in)?;
                let dp = p_in * 1e-4 + 100.0;
                let t_plus = self.evap_temperature(p_in + dp)?;
                let t_minus = self.evap_temperature((p_in - dp).max(1.0))?;
                let dt_dp = (t_plus - t_minus) / (2.0 * dp);
                jacobian.add_entry(row, inlet_p_idx, g * dt_dp);

                // Live z_ua: UA = UA_nom·z_ua, Q = ε·C·ΔT ⇒ ∂r_energy/∂z = −∂Q/∂z
                if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
                    let e = if c_sec > 1e-10 {
                        (-ua / c_sec).exp()
                    } else {
                        0.0
                    };
                    let d_q_dz = self.inner.ua_nominal() * e * (t_sec_in - t_evap);
                    jacobian.add_entry(row, z_ua_idx, -d_q_dz);
                }

                // 4-port cross-derivatives of r1 to the secondary edge state:
                // ∂r1/∂h_sec,in = −∂Q/∂h_sec,in = −g·dT_sec/dh (exact 1/cp),
                // ∂r1/∂ṁ_sec    = −∂Q/∂ṁ_sec    = −g'(C_sec)·cp·(T_sec,in − T_evap).
                let sec_ctx = if self.secondary_edges_ready() {
                    let (m_s, p_s, h_s) = self.sec_in_idx.unwrap();
                    let cp_sec = self.sec_cp(state[p_s], state[h_s])?;
                    let dt_dh = 1.0 / cp_sec;
                    let g_prime = if c_sec <= 1e-10 || ua <= 0.0 {
                        0.0
                    } else {
                        let e = (-ua / c_sec).exp();
                        (1.0 - e) - (ua / c_sec) * e
                    };
                    jacobian.add_entry(row, h_s, -g * dt_dh);
                    jacobian.add_entry(row, m_s, -g_prime * cp_sec * (t_sec_in - t_evap));
                    Some(SecondaryJacCtx {
                        g,
                        g_prime,
                        cp_sec,
                        dt_sec_dh: dt_dh,
                        delta_t: t_sec_in - t_evap,
                        dtevap_dp: dt_dp,
                        ref_p_in_idx: inlet_p_idx,
                    })
                } else {
                    None
                };

                // r2 (emergent) = H_out − h_superheat(P_in): ∂/∂H_out = 1,
                // ∂/∂P_in = −dh_superheat/dP via central finite difference.
                // Dropped when superheat is externally regulated (p0b).
                if self.imposes_superheat() {
                    let sh_row = if self.skip_pressure_eq { 1 } else { 2 };
                    jacobian.add_entry(sh_row, outlet_h_idx, 1.0);
                    let fluid = FluidId::new(&self.refrigerant_id);
                    let backend = self.fluid_backend.as_ref().unwrap();
                    let hp = self.h_superheat_at_p(backend.as_ref(), fluid.clone(), p_in + dp);
                    let hm = self.h_superheat_at_p(backend.as_ref(), fluid, (p_in - dp).max(1.0));
                    if let (Ok(hp), Ok(hm)) = (hp, hm) {
                        jacobian.add_entry(sh_row, inlet_p_idx, -(hp - hm) / (2.0 * dp));
                    }
                }

                if !self.same_branch_m {
                    if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
                        jacobian.add_entry(n_thermo, m_out, 1.0);
                        jacobian.add_entry(n_thermo, m_in, -1.0);
                    }
                }
                // 4-port: secondary mass + energy rows (exact cross terms).
                self.secondary_jacobian(state, jacobian, sec_ctx);
                return Ok(());
            } else if self.emergent_pressure {
                // Transient seeding Jacobian (diagonal): r0=P_out−const,
                // r1=P_in−const, r2=H_out−const (r2 dropped when SH is regulated).
                let outlet_p_idx = self.outlet_p_idx.unwrap();
                let outlet_h_idx = self.outlet_h_idx.unwrap();
                jacobian.add_entry(0, outlet_p_idx, 1.0);
                jacobian.add_entry(1, inlet_p_idx, 1.0);
                if self.imposes_superheat() {
                    jacobian.add_entry(2, outlet_h_idx, 1.0);
                }
                if !self.same_branch_m {
                    if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
                        jacobian.add_entry(n_thermo, m_out, 1.0);
                        jacobian.add_entry(n_thermo, m_in, -1.0);
                    }
                }
                // 4-port seeding: diagonal secondary rows.
                self.secondary_jacobian(state, jacobian, None);
                return Ok(());
            }
        }

        if let (Some(backend), Some(outlet_p_idx), Some(outlet_h_idx)) =
            (&self.fluid_backend, self.outlet_p_idx, self.outlet_h_idx)
        {
            if !self.refrigerant_id.is_empty() {
                if let Some(inlet_p_idx) = self.inlet_p_idx {
                    let p_in = state[inlet_p_idx];
                    if p_in > 10_000.0 {
                        // r0 = P_out - P_in  →  ∂r0/∂P_out = +1, ∂r0/∂P_in = -1
                        jacobian.add_entry(0, outlet_p_idx, 1.0);
                        jacobian.add_entry(0, inlet_p_idx, -1.0);
                        // r1 = H_out - H_sup(P_in)  →  ∂r1/∂H_out = +1, ∂r1/∂P_in = -dH_sup/dP
                        jacobian.add_entry(1, outlet_h_idx, 1.0);
                        let dp = p_in * 1e-4 + 100.0;
                        let fluid = FluidId::new(&self.refrigerant_id);
                        let h_plus =
                            self.h_superheat_at_p(backend.as_ref(), fluid.clone(), p_in + dp);
                        let h_minus = self.h_superheat_at_p(backend.as_ref(), fluid, p_in - dp);
                        if let (Ok(hp), Ok(hm)) = (h_plus, h_minus) {
                            jacobian.add_entry(1, inlet_p_idx, -(hp - hm) / (2.0 * dp));
                        }
                    } else {
                        jacobian.add_entry(0, outlet_p_idx, 1.0);
                        jacobian.add_entry(1, outlet_h_idx, 1.0);
                    }
                } else {
                    jacobian.add_entry(0, outlet_p_idx, 1.0);
                    jacobian.add_entry(1, outlet_h_idx, 1.0);
                }
                // r[n_thermo] = ṁ_outlet − ṁ_inlet  →  exact analytic entries (CM1.3)
                // CM1.4: omit when same_branch_m.
                if !self.same_branch_m {
                    if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
                        jacobian.add_entry(n_thermo, m_out, 1.0);
                        jacobian.add_entry(n_thermo, m_in, -1.0);
                    }
                }
                self.secondary_jacobian(state, jacobian, None);
                return Ok(());
            }
        }
        Ok(())
    }

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

    fn n_equations(&self) -> usize {
        // Thermodynamic residuals: 2 normally, 3 in emergent mode (outlet closure).
        let thermo = self.n_thermo();
        // CM1.4: drop the conservation equation when in the same series branch.
        let core = if self.same_branch_m {
            thermo
        } else {
            thermo + 1 // +1 for mass conservation (CM1.3)
        };
        // + secondary rows in Modelica-style 4-port mode.
        core + self.n_secondary()
    }

    fn equation_roles(&self) -> Vec<crate::EquationRole> {
        let mut roles = Vec::new();
        if !self.skip_pressure_eq {
            roles.push(crate::EquationRole::MomentumOrPressureDrop {
                stream: "refrigerant",
            });
        }
        roles.push(crate::EquationRole::EnergyBalance {
            stream: "refrigerant",
        });
        if self.emergent_pressure && !self.superheat_regulated {
            roles.push(crate::EquationRole::OutletClosure { kind: "superheat" });
        }
        if !self.same_branch_m {
            roles.push(crate::EquationRole::MassConservation {
                stream: "refrigerant",
            });
        }
        if self.secondary_edges_ready() {
            roles.push(crate::EquationRole::MomentumOrPressureDrop {
                stream: "secondary",
            });
            if !self.sec_same_branch() {
                roles.push(crate::EquationRole::MassConservation {
                    stream: "secondary",
                });
            }
            roles.push(crate::EquationRole::EnergyBalance {
                stream: "secondary",
            });
        }
        roles
    }

    fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
        // Deterministic 4-port wiring:
        //   port 0 = refrigerant inlet, port 1 = refrigerant outlet,
        //   port 2 = secondary inlet,   port 3 = secondary outlet.
        if let Some(Some((m, p, h))) = port_edges.first() {
            self.inlet_m_idx = Some(*m);
            self.inlet_p_idx = Some(*p);
            self.inlet_h_idx = Some(*h);
        }
        if let Some(Some((m, p, h))) = port_edges.get(1) {
            self.outlet_m_idx = Some(*m);
            self.outlet_p_idx = Some(*p);
            self.outlet_h_idx = Some(*h);
        }
        if let Some(Some(triple)) = port_edges.get(2) {
            self.sec_in_idx = Some(*triple);
        }
        if let Some(Some(triple)) = port_edges.get(3) {
            self.sec_out_idx = Some(*triple);
        }
        self.same_branch_m = matches!(
            (self.inlet_m_idx, self.outlet_m_idx),
            (Some(m_in), Some(m_out)) if m_in == m_out
        );
    }

    fn port_names(&self) -> Vec<String> {
        vec![
            "inlet".to_string(),
            "outlet".to_string(),
            "secondary_inlet".to_string(),
            "secondary_outlet".to_string(),
        ]
    }

    fn flow_paths(&self) -> Vec<(usize, usize)> {
        // Modelica-style internal series paths: refrigerant 0→1, secondary 2→3.
        vec![(0, 1), (2, 3)]
    }

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

    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<entropyk_core::MassFlow>, ComponentError> {
        self.inner.port_mass_flows(state)
    }

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

    fn energy_transfers(
        &self,
        state: &StateSlice,
    ) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
        // When coupled to an *external* secondary stream, the refrigerant absorbs
        // its duty Q = ṁ·(h_out − h_in) from the environment. Report it as heat
        // entering the component (positive), so the cycle-level performance can
        // recover the true cooling capacity. Without a secondary stream the
        // exchanger is internal to a tracked two-circuit coupling and must stay
        // adiabatic to avoid double counting — delegate to the inner model.
        if self.coupled_ready() {
            if let (Some(m_idx), Some(in_h), Some(out_h)) = (
                self.resolved_mass_idx(),
                self.inlet_h_idx,
                self.outlet_h_idx,
            ) {
                if m_idx < state.len() && in_h < state.len() && out_h < state.len() {
                    let q_abs = state[m_idx] * (state[out_h] - state[in_h]);
                    if q_abs.is_finite() {
                        return Some((
                            entropyk_core::Power::from_watts(q_abs),
                            entropyk_core::Power::from_watts(0.0),
                        ));
                    }
                }
            }
        }
        self.inner.energy_transfers(state)
    }

    fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
        match kind {
            // Real superheat from thermodynamics — used by the inverse-control
            // loop that regulates the expansion-valve opening (p0b).
            MeasuredOutput::Superheat => self.measured_superheat(state),
            // Evaporating saturation temperature (SST) for slide-valve / capacity
            // control loops: T_sat at the refrigerant outlet pressure.
            MeasuredOutput::SaturationTemperature => self.measured_saturation_temperature(state),
            // Preserve the default capacity/duty behaviour (refrigerant-side heat).
            MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => self
                .energy_transfers(state)
                .map(|(heat, _work)| heat.to_watts().abs()),
            _ => None,
        }
    }

    fn signature(&self) -> String {
        self.inner.signature()
    }

    fn to_params(&self) -> crate::ComponentParams {
        self.inner.to_params()
    }

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

impl StateManageable for Evaporator {
    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::*;

    /// Wires secondary Air edges for unit tests that need `coupled_ready()`.
    /// Extends the refrigerant state with secondary air state at the given
    /// temperature [K] and capacity rate [W/K]. Returns the full state vector
    /// and calls `set_port_context` on the evaporator.
    fn wire_secondary(
        evap: &mut Evaporator,
        ref_state: &[f64],
        t_sec_k: f64,
        c_sec: f64,
        ref_in: (usize, usize, usize),
        ref_out: (usize, usize, usize),
    ) -> Vec<f64> {
        let w = 0.010_f64;
        let cp = 1006.0 + 1860.0 * w; // ≈1024.6
        let t_c = t_sec_k - 273.15;
        let h_air = cp * t_c + 2_501_000.0 * w;
        let m_air = c_sec / cp;

        let n = ref_state.len();
        let sec_in = (n, n + 1, n + 2);
        let sec_out = (n + 3, n + 4, n + 5);

        evap.set_secondary_fluid("Air");
        evap.set_secondary_humidity_ratio(w);
        evap.set_port_context(&[Some(ref_in), Some(ref_out), Some(sec_in), Some(sec_out)]);

        let mut state = ref_state.to_vec();
        state.extend_from_slice(&[
            m_air,
            101_325.0,
            h_air,
            m_air,
            101_325.0,
            h_air - 5000.0, // outlet slightly cooler (evaporator cools air)
        ]);
        state
    }

    #[test]
    fn test_evaporator_creation() {
        let evaporator = Evaporator::new(8_000.0);
        assert_eq!(evaporator.ua(), 8_000.0);
        // CM1.3: 2 thermo + 1 mass-flow conservation = 3
        assert_eq!(evaporator.n_equations(), 3);
    }

    #[test]
    fn test_evaporator_with_superheat() {
        let evaporator = Evaporator::with_superheat(8_000.0, 278.15, 10.0);
        assert_eq!(evaporator.saturation_temp(), 278.15);
        assert_eq!(evaporator.superheat_target(), 10.0);
    }

    #[test]
    fn test_evaporator_emergent_outlet_closure() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        // Same-branch: inlet (0,1,2), outlet (0,3,4).
        let edges = [(0usize, 1usize, 2usize), (0usize, 3usize, 4usize)];
        let p_evap = 350_000.0_f64;
        let mut evap = Evaporator::with_superheat(8_000.0, 278.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_emergent_pressure();
        evap.set_system_context(0, &edges);

        // Superheat target enthalpy at p_evap.
        let h_target = evap
            .h_superheat_at_p(
                backend.as_ref(),
                entropyk_fluids::FluidId::new("R134a"),
                p_evap,
            )
            .unwrap();

        let ref_state = vec![0.1, p_evap, 260_000.0, p_evap, h_target];
        let mut state = wire_secondary(&mut evap, &ref_state, 285.0, 3000.0, (0, 1, 2), (0, 3, 4));

        // Emergent adds a 3rd thermo residual; same-branch drops mass conservation.
        // Secondary edges add P + energy (same-branch drops sec mass).
        assert_eq!(evap.n_equations(), 6);

        let mut r = vec![0.0; evap.n_equations()];
        evap.compute_residuals(&state, &mut r).unwrap();
        assert!(
            r[2].abs() < 1.0,
            "outlet closure should vanish at superheat target: {}",
            r[2]
        );

        // Perturbing the outlet enthalpy makes r2 react.
        state[4] = h_target - 15_000.0;
        evap.compute_residuals(&state, &mut r).unwrap();
        assert!(
            (r[2] + 15_000.0).abs() < 1.0,
            "r2 must track outlet enthalpy: {}",
            r[2]
        );
    }

    #[test]
    fn test_evaporator_regulated_superheat_drops_outlet_closure() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        // Same-branch: inlet (0,1,2), outlet (0,3,4).
        let edges = [(0usize, 1usize, 2usize), (0usize, 3usize, 4usize)];
        let p_evap = 350_000.0_f64;

        // Emergent + imposed superheat keeps the outlet-closure residual (3 thermo).
        let mut imposed = Evaporator::with_superheat(8_000.0, 278.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_emergent_pressure();
        imposed.set_secondary_stream(285.0, 3000.0);
        imposed.set_system_context(0, &edges);
        assert_eq!(
            imposed.n_equations(),
            3,
            "imposed emergent mode keeps the superheat outlet closure"
        );

        // Emergent + regulated superheat drops the outlet closure (2 residuals).
        let mut regulated = Evaporator::with_superheat(8_000.0, 278.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_emergent_pressure()
            .with_regulated_superheat()
            .with_isobaric();
        regulated.set_secondary_stream(285.0, 3000.0);
        regulated.set_system_context(0, &edges);
        assert_eq!(
            regulated.n_equations(),
            2,
            "regulated superheat drops the outlet closure (DoF offset by the control loop)"
        );

        // compute_residuals must fill exactly n_equations() entries without panic.
        let mut state = vec![0.0; 5];
        state[0] = 0.1; // ṁ
        state[1] = p_evap; // P_in
        state[2] = 260_000.0; // h_in (two-phase)
        state[3] = p_evap; // P_out
        state[4] = 430_000.0; // superheated outlet
        let mut r = vec![0.0; regulated.n_equations()];
        regulated.compute_residuals(&state, &mut r).unwrap();
        assert!(
            r[0].abs() < 1.0,
            "r0 (pressure closure) should vanish when isobaric: {}",
            r[0]
        );
    }

    #[test]
    fn test_evaporator_measure_superheat() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let edges = [(0usize, 1usize, 2usize), (0usize, 3usize, 4usize)];
        let mut evap = Evaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone());
        evap.set_system_context(0, &edges);

        let mut state = vec![0.0; 5];
        state[3] = 350_000.0; // P_out
        state[4] = 430_000.0; // clearly superheated outlet enthalpy

        let sh = evap
            .measure_output(crate::MeasuredOutput::Superheat, &state)
            .expect("superheat must be measurable for a superheated outlet");
        assert!(
            sh > 0.0 && sh.is_finite(),
            "superheated outlet → positive finite superheat, got {sh}"
        );

        // Without a backend / refrigerant / resolved indices, no measurement.
        let bare = Evaporator::new(8_000.0);
        assert!(
            bare.measure_output(crate::MeasuredOutput::Superheat, &state)
                .is_none(),
            "superheat cannot be measured without a fluid backend"
        );
    }

    #[test]
    fn test_evaporator_measure_superheat_two_phase_is_zero() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let edges = [(0usize, 1usize, 2usize), (0usize, 3usize, 4usize)];
        let mut evap = Evaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone());
        evap.set_system_context(0, &edges);

        let mut state = vec![0.0; 5];
        state[3] = 350_000.0; // P_out
        state[4] = 300_000.0; // two-phase outlet enthalpy (between hf and hg)

        // In the two-phase region the backend returns T(P,h) = T_sat, so superheat is
        // a valid ≈0 reading rather than `None`. This is a deliberate refinement of
        // the "→ None" I/O row: a ≈0 reading is more stable for the control loop than
        // dropping to the synthetic fallback mid-iteration.
        let sh = evap
            .measure_output(crate::MeasuredOutput::Superheat, &state)
            .expect("two-phase superheat is a valid ≈0 reading, not None");
        assert!(
            sh.abs() < 1e-6,
            "two-phase superheat should be ≈0, got {sh}"
        );
    }

    #[test]
    fn test_validate_outlet_quality_superheated() {
        let evaporator = Evaporator::new(8_000.0);

        let h_liquid = 200_000.0;
        let h_vapor = 400_000.0;
        let outlet_h = 420_000.0;
        let cp_vapor = 1000.0;

        let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
        assert!(result.is_ok());

        let superheat = result.unwrap();
        assert!((superheat - 20.0).abs() < 1e-10);
    }

    #[test]
    fn test_validate_outlet_quality_subcooled() {
        let evaporator = Evaporator::new(8_000.0);

        let h_liquid = 200_000.0;
        let h_vapor = 400_000.0;
        let outlet_h = 150_000.0;
        let cp_vapor = 1000.0;

        let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
        assert!(result.is_err());
    }

    #[test]
    fn test_validate_outlet_quality_two_phase() {
        let evaporator = Evaporator::new(8_000.0);

        let h_liquid = 200_000.0;
        let h_vapor = 400_000.0;
        let outlet_h = 300_000.0;
        let cp_vapor = 1000.0;

        let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
        assert!(result.is_err());
    }

    #[test]
    fn test_superheat_residual() {
        let evaporator = Evaporator::with_superheat(8_000.0, 278.15, 5.0);

        let residual = evaporator.superheat_residual(7.0);
        assert!((residual - 2.0).abs() < 1e-10);

        let residual = evaporator.superheat_residual(3.0);
        assert!((residual - (-2.0)).abs() < 1e-10);
    }

    #[test]
    fn test_compute_residuals() {
        let evaporator = Evaporator::new(8_000.0);

        let state = vec![0.0; 10];
        let mut residuals = vec![0.0; 3];

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

    /// Coupled residual path: the evaporator cooling capacity must react to the
    /// secondary (water/brine) inlet temperature and flow. Warmer secondary ⇒ more
    /// duty ⇒ the refrigerant energy-balance residual r1 = ṁ(h_out−h_in) − Q changes.
    #[test]
    fn test_evaporator_coupled_residual_reacts_to_secondary() {
        use std::sync::Arc;

        // Refrigerant edges: inlet (m,p,h)=(0,1,2), outlet (m,p,h)=(3,4,5).
        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let p_evap = 350_000.0_f64; // ~+4 °C evaporating (R134a)
        let mut state = vec![0.0; 6];
        state[0] = 0.1; // ṁ_ref [kg/s]
        state[1] = p_evap; // P_in
        state[2] = 250_000.0; // h_in [J/kg] (two-phase from EXV)
        state[3] = 0.1; // ṁ_out
        state[4] = p_evap; // P_out
        state[5] = 400_000.0; // h_out [J/kg]

        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let make = |t_sec_k: f64, c_sec: f64| -> (Evaporator, Vec<f64>) {
            // Isolate secondary coupling from the default DX ΔP model.
            let mut evap = Evaporator::new(8_000.0)
                .with_refrigerant("R134a")
                .with_fluid_backend(backend.clone())
                .with_secondary_stream(t_sec_k, c_sec)
                .with_isobaric();
            evap.set_system_context(0, &edges);
            let st = wire_secondary(&mut evap, &state, t_sec_k, c_sec, (0, 1, 2), (3, 4, 5));
            (evap, st)
        };

        // Cool secondary water (285 K) vs warm secondary water (295 K).
        let (evap_cool, state_cool) = make(285.0, 2000.0);
        let mut r_cool = vec![0.0; evap_cool.n_equations()];
        evap_cool
            .compute_residuals(&state_cool, &mut r_cool)
            .unwrap();
        let q_cool = evap_cool.coupled_duty(p_evap).unwrap();

        let (evap_warm, state_warm) = make(295.0, 2000.0);
        let mut r_warm = vec![0.0; evap_warm.n_equations()];
        evap_warm
            .compute_residuals(&state_warm, &mut r_warm)
            .unwrap();
        let q_warm = evap_warm.coupled_duty(p_evap).unwrap();

        assert!(
            q_cool > 0.0,
            "duty must be positive (water warmer than T_evap)"
        );
        assert!(
            q_warm > q_cool + 1.0,
            "warmer secondary inlet must increase duty: q_warm={q_warm}, q_cool={q_cool}"
        );
        // r1 = ṁ(h_out−h_in) − Q drops as Q grows.
        assert!(
            r_warm[1] < r_cool[1] - 1.0,
            "energy-balance residual must change with water temp: r_warm={}, r_cool={}",
            r_warm[1],
            r_cool[1]
        );
        assert!(r_cool[0].abs() < 1e-9 && r_warm[0].abs() < 1e-9);

        // More secondary flow ⇒ more duty (C_sec dominates).
        let (evap_highflow, _) = make(285.0, 6000.0);
        let q_highflow = evap_highflow.coupled_duty(p_evap).unwrap();
        assert!(
            q_highflow > q_cool + 1.0,
            "more secondary flow must increase duty: q_highflow={q_highflow}, q_cool={q_cool}"
        );
    }

    /// Opt-in two-phase pressure drop on the evaporator: r0 reflects
    /// P_out = P_in − k·ṁ·|ṁ| and the analytic ∂r0/∂ṁ matches finite difference.
    #[test]
    fn test_evaporator_pressure_drop_residual_and_jacobian() {
        use crate::JacobianBuilder;
        use std::sync::Arc;

        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let p_evap = 350_000.0_f64;
        let mut state = vec![0.0; 6];
        state[0] = 0.1; // ṁ_ref
        state[1] = p_evap; // P_in
        state[2] = 250_000.0; // h_in
        state[3] = 0.1; // ṁ_out
        state[4] = p_evap; // P_out
        state[5] = 400_000.0; // h_out

        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let k = 3.0e6; // ΔP = k·ṁ² = 3e6·0.01 = 30 kPa

        // Baseline (explicit isobaric): r0 ≈ 0.
        let mut evap0 = Evaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_isobaric();
        evap0.set_system_context(0, &edges);
        let state0 = wire_secondary(&mut evap0, &state, 290.0, 2000.0, (0, 1, 2), (3, 4, 5));
        let mut r0v = vec![0.0; evap0.n_equations()];
        evap0.compute_residuals(&state0, &mut r0v).unwrap();
        assert!(r0v[0].abs() < 1e-9, "no-drop r0 must be ~0, got {}", r0v[0]);

        let mut evap = Evaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_pressure_drop_coeff(k);
        evap.set_system_context(0, &edges);
        let state = wire_secondary(&mut evap, &state, 290.0, 2000.0, (0, 1, 2), (3, 4, 5));
        let mut r = vec![0.0; evap.n_equations()];
        evap.compute_residuals(&state, &mut r).unwrap();
        let expected_dp = k * state[0] * state[0];
        assert!(
            (r[0] - expected_dp).abs() < 1.0,
            "r0 must equal ΔP={expected_dp}, got {}",
            r[0]
        );

        let mut jb = JacobianBuilder::new();
        evap.jacobian_entries(&state, &mut jb).unwrap();
        let analytic: f64 = jb
            .entries()
            .iter()
            .filter(|(row, col, _)| *row == 0 && *col == 0)
            .map(|(_, _, v)| *v)
            .sum();
        let eps = 1e-6;
        let mut sp = state.clone();
        let mut sm = state.clone();
        sp[0] += eps;
        sm[0] -= eps;
        let n_eq = evap.n_equations();
        let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]);
        evap.compute_residuals(&sp, &mut rp).unwrap();
        evap.compute_residuals(&sm, &mut rm).unwrap();
        let fd = (rp[0] - rm[0]) / (2.0 * eps);
        assert!(
            (analytic - fd).abs() < 1e-2 * fd.abs().max(1.0),
            "∂r0/∂ṁ analytic={analytic} vs fd={fd}"
        );
    }

    // ---- Modelica-style 4-port mode ----------------------------------------

    /// Builds a 4-port evaporator wired via `set_port_context`:
    /// refrigerant inlet (0,1,2) → outlet (3,4,5),
    /// secondary (chilled water) inlet (6,7,8) → outlet (9,10,11).
    fn make_4port_evaporator() -> Evaporator {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let mut evap = Evaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_secondary_fluid("Air");
        evap.set_secondary_humidity_ratio(0.008);
        evap.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
        evap.set_port_context(&[
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ]);
        evap
    }

    /// Physically-plausible 12-slot state for the 4-port evaporator.
    fn make_4port_evap_state() -> Vec<f64> {
        let w = 0.008;
        let cp_air = 1006.0 + 1860.0 * w;
        let h_air = |t_c: f64| cp_air * t_c + 2_501_000.0 * w;
        vec![
            0.10,        // 0: ṁ_ref,in
            350_000.0,   // 1: P_ref,in (evaporating)
            250_000.0,   // 2: h_ref,in (two-phase after EXV)
            0.10,        // 3: ṁ_ref,out
            350_000.0,   // 4: P_ref,out
            410_000.0,   // 5: h_ref,out (superheated vapour)
            1.8,         // 6: ṁ_air,in
            101_325.0,   // 7: P_air,in
            h_air(27.0), // 8: h_air,in (27 °C return air)
            1.8,         // 9: ṁ_air,out
            101_325.0,   // 10: P_air,out
            h_air(14.0), // 11: h_air,out (cooled supply air)
        ]
    }

    /// 4-port mode adds 3 secondary rows (P + mass + energy) and named ports.
    #[test]
    fn test_evaporator_4port_equations_and_ports() {
        let evap = make_4port_evaporator();
        // 2 thermo + 1 refrigerant mass + 3 secondary = 6.
        assert_eq!(evap.n_equations(), 6);
        assert_eq!(
            evap.port_names(),
            vec!["inlet", "outlet", "secondary_inlet", "secondary_outlet"]
        );
    }

    /// Secondary energy balance: ṁ_air·(h_out − h_in) + Q = 0, with Q the
    /// coupled ε-NTU duty (secondary is COOLED) driven by the live air edges.
    #[test]
    fn test_evaporator_4port_secondary_energy_balance() {
        let evap = make_4port_evaporator();
        let state = make_4port_evap_state();
        let mut r = vec![0.0; evap.n_equations()];
        evap.compute_residuals(&state, &mut r).unwrap();

        // Mass / pressure rows.
        assert!(r[2].abs() < 1e-12, "ref mass row: {}", r[2]);
        assert!(r[3].abs() < 1e-12, "sec P row: {}", r[3]);
        assert!(r[4].abs() < 1e-12, "sec mass row: {}", r[4]);

        // Row 5 = ṁ_air·Δh_air + Q with Q = ε·C_sec·(T_air,in − T_evap) > 0.
        let w = 0.008;
        let cp_air = 1006.0 + 1860.0 * w;
        let c_sec = state[6] * cp_air;
        let t_air_in = (state[8] - 2_501_000.0 * w) / cp_air + 273.15;
        let t_evap = evap.evap_temperature(state[1]).unwrap();
        let eps = evap.effectiveness(c_sec, evap.ua());
        let q = eps * c_sec * (t_air_in - t_evap);
        assert!(q > 0.0, "evaporator must absorb heat: q={q}");
        let expected = state[6] * (state[11] - state[8]) + q;
        assert!(
            (r[5] - expected).abs() < 1e-6 * expected.abs().max(1.0),
            "sec energy row: {} vs expected {}",
            r[5],
            expected
        );

        // Refrigerant energy balance row carries the same duty.
        let expected_r1 = state[0] * (state[5] - state[2]) - q;
        assert!(
            (r[1] - expected_r1).abs() < 1e-6 * expected_r1.abs().max(1.0),
            "ref energy row: {} vs expected {}",
            r[1],
            expected_r1
        );
    }

    /// Full Jacobian of the 4-port evaporator vs central finite differences on
    /// every (row, column) pair — validates the analytic cross-derivatives.
    #[test]
    fn test_evaporator_4port_jacobian_vs_finite_differences() {
        use crate::JacobianBuilder;
        let evap = make_4port_evaporator();
        let state = make_4port_evap_state();
        let n_eq = evap.n_equations();
        let n_state = state.len();

        let mut jb = JacobianBuilder::new();
        evap.jacobian_entries(&state, &mut jb).unwrap();
        let mut analytic = vec![vec![0.0_f64; n_state]; n_eq];
        for &(row, col, v) in jb.entries() {
            if row < n_eq && col < n_state {
                analytic[row][col] += v;
            }
        }

        for col in 0..n_state {
            let eps = (state[col].abs() * 1e-6).max(1e-7);
            let (mut sp, mut sm) = (state.clone(), state.clone());
            sp[col] += eps;
            sm[col] -= eps;
            let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]);
            evap.compute_residuals(&sp, &mut rp).unwrap();
            evap.compute_residuals(&sm, &mut rm).unwrap();
            for row in 0..n_eq {
                let fd = (rp[row] - rm[row]) / (2.0 * eps);
                let a = analytic[row][col];
                let tol = 1e-3 * fd.abs().max(a.abs()).max(1e-6);
                assert!(
                    (a - fd).abs() <= tol.max(1e-6),
                    "J[{row}][{col}]: analytic={a} vs fd={fd}"
                );
            }
        }
    }

    /// Builds a 4-port evaporator with tube two-phase (MSH) pressure drop.
    fn make_4port_evaporator_tube_dp() -> Evaporator {
        use crate::heat_exchanger::two_phase_dp::{TubeChannelGeometry, TwoPhaseDpCorrelation};
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let mut evap = Evaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_secondary_fluid("Air")
            .with_tube_pressure_drop(
                TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
                TubeChannelGeometry::dx_default(),
            );
        evap.set_secondary_humidity_ratio(0.008);
        evap.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
        evap.set_port_context(&[
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ]);
        evap
    }

    /// Momentum-row Jacobian must include the tube-ΔP partials w.r.t. inlet
    /// pressure (saturated transport properties) and both enthalpies (vapor
    /// qualities) — not only ∂ΔP/∂ṁ. Regression test: with tube MSH at high
    /// mass flow, a momentum row limited to ∂/∂ṁ makes the Newton direction
    /// wrong enough that line search fails and the solve diverges.
    #[test]
    fn test_evaporator_4port_tube_dp_momentum_jacobian_vs_finite_differences() {
        use crate::JacobianBuilder;
        let evap = make_4port_evaporator_tube_dp();
        let state = make_4port_evap_state();
        let n_eq = evap.n_equations();
        let n_state = state.len();

        let mut jb = JacobianBuilder::new();
        evap.jacobian_entries(&state, &mut jb).unwrap();
        let mut analytic = vec![vec![0.0_f64; n_state]; n_eq];
        for &(row, col, v) in jb.entries() {
            if row < n_eq && col < n_state {
                analytic[row][col] += v;
            }
        }

        // Row 0: r = P_out − P_in + ΔP(ṁ, P_in, h_in, h_out).
        for col in 0..n_state {
            let eps = (state[col].abs() * 1e-6).max(1e-7);
            let (mut sp, mut sm) = (state.clone(), state.clone());
            sp[col] += eps;
            sm[col] -= eps;
            let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]);
            evap.compute_residuals(&sp, &mut rp).unwrap();
            evap.compute_residuals(&sm, &mut rm).unwrap();
            let fd = (rp[0] - rm[0]) / (2.0 * eps);
            let a = analytic[0][col];
            let tol = (1e-3 * fd.abs().max(a.abs())).max(1e-9);
            assert!(
                (a - fd).abs() <= tol,
                "momentum J[0][{col}]: analytic={a} vs fd={fd}"
            );
        }
    }
}
