//! FloodedEvaporator - Flooded shell-and-tube / recirculation evaporator.
//!
//! ## Degrees of freedom (critical)
//!
//! A **system-mode** solve must keep a square residual system. Rules:
//!
//! - Refrigerant residuals close refrigerant ports (ΔP, energy).
//! - Live secondary edges (ports 2/3) contribute secondary energy (and mass if needed).
//! - Scalar `with_secondary_stream` is **rating mode**: used by `rate()` **and** by the
//!   Newton residual when no live secondary edges are wired (ε-NTU Q from scalars).
//!   Live edges remain the preferred contract for closed water-loop machines.
//! - `with_quality_control(true)` adds an [`EquationRole::OutletClosure`] residual and
//!   **consumes one DoF**. Pair it with a free actuator (EXV opening, level control)
//!   or the system DoF gate will reject the configuration as over-constrained.
//!
//! Default outlet closure is **saturated vapor** (compressor suction after
//! disengagement). `quality_control` is a legacy recirculation alternative.
//!
//! ## Zero flow
//!
//! Zero refrigerant or secondary mass flow is valid (staging / Newton trials).
//! Duty and transport residuals use [`super::flow_regularization`] (C∞ activity)
//! so residuals and Jacobians stay finite at `ṁ = 0` without hard branches.
//!
//! ## Example
//!
//! ```ignore
//! use entropyk_components::heat_exchanger::FloodedEvaporator;
//!
//! // System mode: quality control OFF; secondary live edges via set_port_context.
//! let evap = FloodedEvaporator::new(10_000.0);
//! assert_eq!(evap.n_equations(), 3); // ΔP + energy + saturated-vapor closure
//! ```

use super::correlation_registry::CorrelationId;
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::{HeatExchanger, HxSideConditions};
use super::flow_regularization::{
    blend_transport_partials, blend_transport_residual, effective_duty, flow_activity,
    flow_activity_derivative, smooth_mass_magnitude, smooth_mass_magnitude_derivative,
    DEFAULT_M_EPS_KG_S, DEFAULT_M_SCALE_KG_S,
};
use super::pool_boiling::{flooded_shell_htc, ua_from_two_side_htc, PoolBoilingInput};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
    Component, ComponentError, ConnectedPort, EquationRole, JacobianBuilder, ResidualVector,
    StateSlice,
};
use entropyk_core::{Calib, MassFlow, Power, Pressure};
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::sync::Arc;

/// How the flooded-evaporator UA is obtained.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UaMode {
    /// Fixed UA set at construction (default, rétrocompat).
    Fixed,
    /// Outer-loop UA from Cooper (1984) pool boiling + Palen bundle + oil factor.
    ///
    /// Call [`FloodedEvaporator::update_ua_from_pool_boiling`] between Newton
    /// iterations (Picard / outer loop). The Newton Jacobian treats UA as constant.
    CooperPoolBoiling,
}

/// Geometry / secondary HTC inputs for Cooper-based UA updates.
#[derive(Debug, Clone, Copy)]
pub struct FloodedPoolBoilingConfig {
    /// Refrigerant-side heat-transfer area [m²].
    pub area_ref_m2: f64,
    /// Secondary-side heat-transfer area [m²].
    pub area_sec_m2: f64,
    /// Secondary-side HTC [W/(m²·K)].
    pub h_sec: f64,
    /// Palen bundle factor (typically 0.6–0.9).
    pub bundle_factor: f64,
    /// Oil mass fraction in refrigerant (0–0.2).
    pub oil_mass_fraction: f64,
    /// Surface roughness Ra [μm].
    pub roughness_um: f64,
    /// Pool-boiling correlation (Cooper or Mostinski).
    pub correlation: CorrelationId,
}

impl Default for FloodedPoolBoilingConfig {
    fn default() -> Self {
        Self {
            area_ref_m2: 20.0,
            area_sec_m2: 18.0,
            h_sec: 4000.0,
            bundle_factor: 0.8,
            oil_mass_fraction: 0.0,
            roughness_um: 1.0,
            correlation: CorrelationId::Cooper1984,
        }
    }
}

/// Minimum valid UA value (W/K).
const MIN_UA: f64 = 0.0;

/// Result of qualifying an evaporator at a fixed refrigerant regime against a
/// secondary (water/brine) stream. All fields are solved from the ε-NTU balance.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EvaporatorRating {
    /// Heat duty `Q = ε·C_sec·(T_sec,in − T_evap)` [W].
    pub q_w: f64,
    /// Effectiveness `ε = 1 − exp(−UA / C_sec)` [-].
    pub effectiveness: f64,
    /// Evaporating (saturation) temperature `T_sat(P)` [K].
    pub t_evap_k: f64,
    /// Approach `T_sec,in − T_evap` [K].
    pub approach_k: f64,
    /// Secondary outlet temperature `T_sec,in − Q/C_sec` [K].
    pub secondary_outlet_k: f64,
}

/// FloodedEvaporator - Heat exchanger for flooded (recirculation) systems.
///
/// Uses the epsilon-NTU method for heat transfer calculation.
/// Output quality is typically 0.5-0.8 (two-phase mixture).
pub struct FloodedEvaporator {
    inner: HeatExchanger<EpsNtuModel>,
    refrigerant_id: String,
    secondary_fluid_id: String,
    fluid_backend: Option<Arc<dyn FluidBackend>>,
    target_quality: f64,
    quality_control_enabled: bool,
    last_heat_transfer_w: f64,
    last_outlet_quality: Option<f64>,
    outlet_pressure_idx: Option<usize>,
    outlet_enthalpy_idx: Option<usize>,
    // CM-coupled: solver state indices for the refrigerant edges (m, p, h).
    inlet_m_idx: Option<usize>,
    inlet_p_idx: Option<usize>,
    inlet_h_idx: Option<usize>,
    outlet_m_idx: Option<usize>,
    same_branch_m: bool,
    // Secondary (water/brine) **rating-mode** scalars (see `rate()`).
    // System-mode coupling uses live secondary edges (`sec_in_idx` / `sec_out_idx`).
    secondary_inlet_temp_k: Option<f64>,
    secondary_capacity_rate: Option<f64>,
    /// Live secondary inlet edge `(m, P, h)` — port 2, system mode.
    sec_in_idx: Option<(usize, usize, usize)>,
    /// Live secondary outlet edge `(m, P, h)` — port 3, system mode.
    sec_out_idx: Option<(usize, usize, usize)>,
    /// Secondary water quadratic ΔP coeff `k` [Pa·s²/kg²] (`None` = isobaric).
    secondary_pressure_drop_coeff: Option<f64>,
    /// UA evaluation mode.
    ua_mode: UaMode,
    /// Pool-boiling geometry (used when `ua_mode == CooperPoolBoiling`).
    pool_boiling: FloodedPoolBoilingConfig,
}

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

impl FloodedEvaporator {
    /// Creates a new flooded evaporator with the given UA value.
    ///
    /// # Arguments
    ///
    /// * `ua` - Overall heat transfer coefficient x Area (W/K). Must be >= 0.
    ///
    /// # Panics
    ///
    /// Panics if `ua` is negative.
    pub fn new(ua: f64) -> Self {
        assert!(ua >= MIN_UA, "UA must be non-negative, got {}", ua);
        let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
        Self {
            inner: HeatExchanger::new(model, "FloodedEvaporator"),
            refrigerant_id: String::new(),
            secondary_fluid_id: String::new(),
            fluid_backend: None,
            target_quality: 0.7,
            quality_control_enabled: false,
            last_heat_transfer_w: 0.0,
            last_outlet_quality: None,
            outlet_pressure_idx: None,
            outlet_enthalpy_idx: None,
            inlet_m_idx: None,
            inlet_p_idx: None,
            inlet_h_idx: None,
            outlet_m_idx: None,
            same_branch_m: false,
            secondary_inlet_temp_k: None,
            secondary_capacity_rate: None,
            sec_in_idx: None,
            sec_out_idx: None,
            secondary_pressure_drop_coeff: None,
            ua_mode: UaMode::Fixed,
            pool_boiling: FloodedPoolBoilingConfig::default(),
        }
    }

    /// 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,
        }
    }

    /// Selects UA evaluation mode (default [`UaMode::Fixed`]).
    pub fn with_ua_mode(mut self, mode: UaMode) -> Self {
        self.ua_mode = mode;
        self
    }

    /// Configures pool-boiling geometry for [`UaMode::CooperPoolBoiling`].
    pub fn with_pool_boiling(mut self, config: FloodedPoolBoilingConfig) -> Self {
        self.pool_boiling = config;
        self
    }

    /// Returns the current UA mode.
    pub fn ua_mode(&self) -> UaMode {
        self.ua_mode
    }

    /// Sets UA via scale so that `UA_nominal × scale = ua_value`.
    pub fn set_ua(&mut self, ua_value: f64) {
        let ua_nominal = self.inner.ua_nominal();
        let scale = if ua_nominal > 0.0 {
            ua_value / ua_nominal
        } else {
            1.0
        };
        self.inner.set_ua_scale(scale.max(0.0));
    }

    /// Outer-loop UA update from pool boiling (Picard).
    ///
    /// Computes `h_nb` (Cooper/Mostinski) × bundle × oil, combines with secondary
    /// HTC into UA, and applies it via [`Self::set_ua`]. No-op when mode is Fixed.
    pub fn update_ua_from_pool_boiling(
        &mut self,
        heat_flux_w_m2: f64,
        p_reduced: f64,
        molar_mass_g_mol: f64,
        p_crit_pa: f64,
    ) -> Result<f64, ComponentError> {
        if self.ua_mode != UaMode::CooperPoolBoiling {
            return Ok(self.ua());
        }
        let input = PoolBoilingInput {
            p_reduced,
            heat_flux: heat_flux_w_m2,
            molar_mass_g_mol,
            roughness_um: self.pool_boiling.roughness_um,
            p_crit_pa,
        };
        let h_ref = flooded_shell_htc(
            &input,
            self.pool_boiling.correlation,
            self.pool_boiling.bundle_factor,
            self.pool_boiling.oil_mass_fraction,
        )
        .map_err(|e| ComponentError::InvalidState(e.to_string()))?;
        let ua = ua_from_two_side_htc(
            h_ref,
            self.pool_boiling.area_ref_m2,
            self.pool_boiling.h_sec,
            self.pool_boiling.area_sec_m2,
        );
        self.set_ua(ua);
        Ok(ua)
    }

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

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

    /// Attaches a fluid backend for saturation calculations.
    pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
        self.fluid_backend = Some(backend);
        self
    }

    /// Sets the target outlet quality (0.0 to 1.0).
    ///
    /// Default is 0.7 (70% vapor, typical for flooded evaporators).
    pub fn with_target_quality(mut self, quality: f64) -> Self {
        self.target_quality = quality.clamp(0.0, 1.0);
        self
    }

    /// Enables quality-control residual `q_actual − q_target` (**+1 equation**).
    ///
    /// # Degrees of freedom
    ///
    /// This is an [`EquationRole::OutletClosure`]. Enabling it **without** freeing
    /// another unknown (EXV opening free actuator, level control, compressor speed,
    /// inverse control mapping, …) over-constrains a closed refrigeration cycle.
    /// The system DoF gate (`System::validate_system_dof`) will reject such configs.
    ///
    /// Prefer explicit control architecture over a hard-coded quality target for
    /// production machines.
    pub fn with_quality_control(mut self, enabled: bool) -> Self {
        self.quality_control_enabled = enabled;
        self
    }

    /// Returns `true` when quality-control residual is active (+1 DoF cost).
    pub fn quality_control_enabled(&self) -> bool {
        self.quality_control_enabled
    }

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

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

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

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

    /// Returns the target outlet quality.
    pub fn target_quality(&self) -> f64 {
        self.target_quality
    }

    /// Sets the target outlet quality.
    pub fn set_target_quality(&mut self, quality: f64) {
        self.target_quality = quality.clamp(0.0, 1.0);
    }

    /// Returns the last computed heat transfer rate (W).
    ///
    /// Returns 0.0 if `compute_residuals` has not been called.
    pub fn heat_transfer(&self) -> f64 {
        self.last_heat_transfer_w
    }

    /// Returns the last computed outlet quality.
    ///
    /// Returns `None` if `compute_residuals` has not been called or
    /// quality could not be computed (no FluidBackend).
    pub fn outlet_quality(&self) -> Option<f64> {
        self.last_outlet_quality
    }

    /// Sets the outlet state indices for quality control.
    ///
    /// These indices point to the pressure and enthalpy in the global state vector
    /// that represent the refrigerant outlet conditions.
    pub fn set_outlet_indices(&mut self, p_idx: usize, h_idx: usize) {
        self.outlet_pressure_idx = Some(p_idx);
        self.outlet_enthalpy_idx = Some(h_idx);
    }

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

    /// Rating-mode secondary stream for [`rate`](Self::rate) only.
    ///
    /// * `inlet_temp_k` - secondary fluid inlet temperature (K)
    /// * `capacity_rate_w_per_k` - heat-capacity rate ṁ·cp of the secondary fluid (W/K)
    ///
    /// # System mode vs rating mode
    ///
    /// For a **closed machine**, wire live secondary ports (`secondary_inlet` /
    /// `secondary_outlet`) via `set_port_context` and a BrineSource/Sink loop.
    /// Scalar streams do **not** create secondary state unknowns and therefore
    /// cannot close a water-side First Law residual in the Newton system.
    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 rating-mode secondary 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));
    }

    /// `true` when live secondary edges are wired (ports 2 and 3).
    fn secondary_edges_ready(&self) -> bool {
        self.sec_in_idx.is_some() && self.sec_out_idx.is_some()
    }

    /// Secondary edges share a branch ṁ unknown (series path 2→3).
    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 residuals in system (4-port) mode.
    ///
    /// Always includes isobaric `P_out − P_in = 0` so MassFlowSource_T (Free P)
    /// + Boundary_pT sink loops 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
        }
    }

    /// Secondary cp [J/(kg·K)] at edge state — liquid water/brine via backend.
    fn sec_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::InvalidState(
                "FloodedEvaporator secondary side requires a FluidBackend".into(),
            )
        })?;
        if self.secondary_fluid_id.is_empty() {
            return Err(ComponentError::InvalidState(
                "FloodedEvaporator secondary_fluid_id not set".into(),
            ));
        }
        let cp = backend
            .property(
                FluidId::new(&self.secondary_fluid_id),
                Property::Cp,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_pa),
                    entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
                ),
            )
            .map_err(|e| {
                ComponentError::CalculationFailed(format!("FloodedEvaporator secondary Cp: {e}"))
            })?;
        if cp.is_finite() && cp > 0.0 {
            Ok(cp)
        } else {
            Err(ComponentError::CalculationFailed(format!(
                "FloodedEvaporator secondary Cp invalid: {cp}"
            )))
        }
    }

    /// Secondary temperature [K] at edge state.
    fn sec_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::InvalidState(
                "FloodedEvaporator secondary side requires a FluidBackend".into(),
            )
        })?;
        let t = backend
            .property(
                FluidId::new(&self.secondary_fluid_id),
                Property::Temperature,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_pa),
                    entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
                ),
            )
            .map_err(|e| {
                ComponentError::CalculationFailed(format!("FloodedEvaporator secondary T: {e}"))
            })?;
        if t.is_finite() && t > 0.0 {
            Ok(t)
        } else {
            Err(ComponentError::CalculationFailed(format!(
                "FloodedEvaporator secondary T invalid: {t}"
            )))
        }
    }

    /// Live secondary `(T_sec,in [K], C_sec [W/K])` from edge state (system mode).
    fn live_secondary_stream(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> {
        if !self.secondary_edges_ready() {
            return Err(ComponentError::InvalidState(
                "FloodedEvaporator system mode requires live secondary edges \
                 (ports secondary_inlet / secondary_outlet). Scalar secondary_stream \
                 is rating-mode only — see with_secondary_stream docs."
                    .into(),
            ));
        }
        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() {
            return Err(ComponentError::InvalidState(
                "FloodedEvaporator secondary edge indices out of range".into(),
            ));
        }
        let cp = self.sec_cp(state[p_s], state[h_s])?;
        let t = self.sec_temperature(state[p_s], state[h_s])?;
        // C_sec = |ṁ|_smooth · cp — finite and C¹ at ṁ=0; reverse-flow trial states OK.
        let m_mag = smooth_mass_magnitude(state[m_s], DEFAULT_M_EPS_KG_S);
        Ok((t, m_mag * cp))
    }

    /// Resolves refrigerant mass-flow state index (never falls back to pressure).
    fn resolved_mass_idx(&self) -> Result<usize, ComponentError> {
        self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
            ComponentError::InvalidState(
                "FloodedEvaporator: mass-flow state index not resolved \
                     (cannot fall back to a pressure index)"
                    .into(),
            )
        })
    }

    /// Refrigerant port indices + backend required for ε-NTU coupling.
    fn refrigerant_indices_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_pressure_idx.is_some()
            && self.outlet_enthalpy_idx.is_some()
    }

    /// System mode: live secondary edges with a secondary fluid id.
    fn system_secondary_ready(&self) -> bool {
        self.secondary_edges_ready() && !self.secondary_fluid_id.is_empty()
    }

    /// Rating mode: scalar secondary stream (T_sec,in and C_sec > 0).
    ///
    /// Scalars are **not** Newton unknowns — they only define the ε-NTU duty.
    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 Newton path: system (live edges) **or** rating (scalars).
    fn coupled_ready(&self) -> bool {
        self.refrigerant_indices_ready()
            && (self.system_secondary_ready() || self.rating_secondary_ready())
    }

    /// Resolve `(T_sec,in [K], C_sec [W/K])` from live edges (preferred) or rating scalars.
    fn resolve_secondary_stream(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> {
        if self.system_secondary_ready() {
            return self.live_secondary_stream(state);
        }
        if self.rating_secondary_ready() {
            return Ok((
                self.secondary_inlet_temp_k.unwrap(),
                self.secondary_capacity_rate.unwrap(),
            ));
        }
        Err(ComponentError::InvalidState(
            "FloodedEvaporator requires either live secondary edges \
             (secondary_inlet / secondary_outlet) or rating scalars \
             (secondary_inlet_temp_* + capacity rate / mass·cp)"
                .into(),
        ))
    }

    /// Saturation vapor enthalpy h_g(P) [J/kg] — default suction outlet closure.
    fn saturation_enthalpy_vapor(&self, p_pa: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::InvalidState(
                "FloodedEvaporator saturated-vapor closure requires a FluidBackend".into(),
            )
        })?;
        if self.refrigerant_id.is_empty() {
            return Err(ComponentError::InvalidState(
                "FloodedEvaporator refrigerant_id not set".into(),
            ));
        }
        backend
            .property(
                FluidId::new(&self.refrigerant_id),
                Property::Enthalpy,
                FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(1.0)),
            )
            .map_err(|e| {
                ComponentError::CalculationFailed(format!("FloodedEvaporator h_g(P) failed: {e}"))
            })
    }

    /// 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("FloodedEvaporator: no fluid backend".to_string())
        })?;
        backend
            .property(
                FluidId::new(&self.refrigerant_id),
                Property::Temperature,
                FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(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`,
    /// so `ε = 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.
    fn coupled_duty_with(
        &self,
        p_in_pa: f64,
        t_sec_in: f64,
        c_sec: f64,
        ua: f64,
    ) -> Result<f64, ComponentError> {
        let t_evap = self.evap_temperature(p_in_pa)?;
        let eps = self.effectiveness(c_sec, ua);
        Ok(eps * c_sec * (t_sec_in - t_evap))
    }

    /// Rating-mode duty from scalar secondary stream (tests / `rate()` path).
    fn coupled_duty(&self, p_in_pa: f64) -> Result<f64, ComponentError> {
        let c_sec = self.secondary_capacity_rate.ok_or_else(|| {
            ComponentError::InvalidState(
                "coupled_duty requires rating-mode secondary stream".into(),
            )
        })?;
        let t_sec_in = self.secondary_inlet_temp_k.ok_or_else(|| {
            ComponentError::InvalidState(
                "coupled_duty requires rating-mode secondary inlet temperature".into(),
            )
        })?;
        self.coupled_duty_with(p_in_pa, t_sec_in, c_sec, self.live_ua(None))
    }

    /// Rates the evaporator at a fixed refrigerant regime (constant evaporating
    /// pressure `p_in_pa`) against the configured secondary (water/brine) stream.
    ///
    /// This is the "qualification" entry point: keep the refrigerant regime constant,
    /// vary the secondary inlet temperature and/or flow, and read off the genuine,
    /// physics-based response of the exchanger. Everything below is solved from the
    /// real ε-NTU balance (`C_min = C_sec` for a phase-changing refrigerant), not
    /// imposed:
    ///
    /// - `q_w`            : heat duty `Q = ε·C_sec·(T_sec,in − T_evap)` [W]
    /// - `effectiveness`  : `ε = 1 − exp(−UA / C_sec)` [-]
    /// - `t_evap_k`       : evaporating temperature `T_sat(P)` [K]
    /// - `approach_k`     : `T_sec,in − T_evap` [K]
    /// - `secondary_outlet_k` : secondary outlet temperature `T_sec,in − Q/C_sec` [K]
    ///
    /// Requires a fluid backend, a refrigerant id and a configured secondary stream
    /// (see [`with_secondary_stream`](Self::with_secondary_stream)).
    pub fn rate(&self, p_in_pa: f64) -> Result<EvaporatorRating, ComponentError> {
        let c_sec = self.secondary_capacity_rate.ok_or_else(|| {
            ComponentError::InvalidState(
                "FloodedEvaporator::rate requires a secondary stream (set_secondary_stream)"
                    .to_string(),
            )
        })?;
        let t_sec_in = self.secondary_inlet_temp_k.ok_or_else(|| {
            ComponentError::InvalidState(
                "FloodedEvaporator::rate requires a secondary inlet temperature".to_string(),
            )
        })?;
        if !self.rating_secondary_ready() {
            return Err(ComponentError::InvalidState(
                "FloodedEvaporator::rate requires rating-mode secondary stream \
                 (with_secondary_stream)"
                    .into(),
            ));
        }
        let t_evap = self.evap_temperature(p_in_pa)?;
        let ua = self.live_ua(None);
        let eps = self.effectiveness(c_sec, ua);
        let q = self.coupled_duty(p_in_pa)?;
        let secondary_outlet_k = if c_sec > 1e-10 {
            t_sec_in - q / c_sec
        } else {
            t_sec_in
        };
        Ok(EvaporatorRating {
            q_w: q,
            effectiveness: eps,
            t_evap_k: t_evap,
            approach_k: t_sec_in - t_evap,
            secondary_outlet_k,
        })
    }

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

    /// Thermodynamic quality `x = (h − h_f)/(h_g − h_f)` **without** clamping.
    ///
    /// Values outside `[0, 1]` mean subcooled (`x<0`) or superheated (`x>1`) —
    /// that information must reach the residual/solver, not be silently hidden.
    ///
    /// Returns `None` if backend/fluid/saturation envelope is unavailable.
    fn compute_quality(&self, h_out: f64, p_pa: f64) -> Option<f64> {
        if self.refrigerant_id.is_empty() {
            return None;
        }
        let backend = self.fluid_backend.as_ref()?;
        let fluid = FluidId::new(&self.refrigerant_id);
        let p = Pressure::from_pascals(p_pa);

        let h_sat_l = backend
            .property(
                fluid.clone(),
                Property::Enthalpy,
                FluidState::from_px(p, Quality::new(0.0)),
            )
            .ok()?;
        let h_sat_v = backend
            .property(
                fluid,
                Property::Enthalpy,
                FluidState::from_px(p, Quality::new(1.0)),
            )
            .ok()?;

        if h_sat_v > h_sat_l {
            Some((h_out - h_sat_l) / (h_sat_v - h_sat_l))
        } else {
            // Invalid saturation envelope - return None
            None
        }
    }

    /// Validates that outlet is in two-phase region (x < 1).
    ///
    /// Returns Err if outlet is superheated (should use DX Evaporator instead).
    ///
    /// # Errors
    ///
    /// - `InvalidState`: Quality >= 1.0 (superheated outlet)
    /// - `CalculationFailed`: No FluidBackend configured or empty refrigerant ID
    pub fn validate_outlet_quality(&self, h_out: f64, p_pa: f64) -> Result<f64, ComponentError> {
        if self.refrigerant_id.is_empty() {
            return Err(ComponentError::InvalidState(
                "FloodedEvaporator: refrigerant_id not set".to_string(),
            ));
        }
        if self.fluid_backend.is_none() {
            return Err(ComponentError::CalculationFailed(
                "FloodedEvaporator: FluidBackend not configured".to_string(),
            ));
        }
        match self.compute_quality(h_out, p_pa) {
            Some(q) if q < 1.0 => Ok(q),
            Some(q) => Err(ComponentError::InvalidState(format!(
                "FloodedEvaporator outlet quality {:.2} >= 1.0 (superheated). Use DX Evaporator instead.",
                q
            ))),
            None => Err(ComponentError::CalculationFailed(format!(
                "FloodedEvaporator: Cannot compute quality for {} at P={:.0} Pa",
                self.refrigerant_id, p_pa
            ))),
        }
    }

    /// Deterministic seed residuals when P is non-physical or coupling incomplete.
    /// Keeps `n_equations` stable and never routes through generic HeatExchanger.
    fn seed_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        let mut row = 0;
        match (
            self.inlet_p_idx,
            self.outlet_pressure_idx,
            self.inlet_h_idx,
            self.outlet_enthalpy_idx,
        ) {
            (Some(p_in), Some(p_out), Some(h_in), Some(h_out)) => {
                residuals[row] = state[p_out] - state[p_in];
                row += 1;
                residuals[row] = state[h_out] - state[h_in];
                row += 1;
                let p_pa = state[p_out];
                let h = state[h_out];
                if self.quality_control_enabled {
                    residuals[row] = self
                        .compute_quality(h, p_pa)
                        .map(|q| q - self.target_quality)
                        .unwrap_or(0.0);
                } else if p_pa > 10_000.0
                    && self.fluid_backend.is_some()
                    && !self.refrigerant_id.is_empty()
                {
                    residuals[row] = match self.saturation_enthalpy_vapor(p_pa) {
                        Ok(h_g) => h - h_g,
                        Err(_) => 0.0,
                    };
                } else {
                    residuals[row] = 0.0;
                }
                row += 1;
            }
            _ => {
                for i in 0..self.n_equations().min(residuals.len()) {
                    residuals[i] = 0.0;
                }
                return Ok(());
            }
        }

        if self.secondary_edges_ready() {
            let (m_in, p_in_s, h_in_s) = self.sec_in_idx.unwrap();
            let (m_out, p_out_s, h_out_s) = self.sec_out_idx.unwrap();
            residuals[row] = state[p_out_s] - state[p_in_s] + self.secondary_delta_p(state[m_in]);
            row += 1;
            if !self.sec_same_branch() {
                residuals[row] = state[m_out] - state[m_in];
                row += 1;
            }
            residuals[row] = state[h_out_s] - state[h_in_s];
            row += 1;
        }
        debug_assert_eq!(row, self.n_equations());
        Ok(())
    }

    /// Jacobian of [`seed_residuals`](Self::seed_residuals) — sparse and non-singular.
    fn seed_jacobian(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        let mut row = 0;
        match (
            self.inlet_p_idx,
            self.outlet_pressure_idx,
            self.inlet_h_idx,
            self.outlet_enthalpy_idx,
        ) {
            (Some(p_in), Some(p_out), Some(h_in), Some(h_out)) => {
                jacobian.add_entry(row, p_out, 1.0);
                jacobian.add_entry(row, p_in, -1.0);
                row += 1;
                jacobian.add_entry(row, h_out, 1.0);
                jacobian.add_entry(row, h_in, -1.0);
                row += 1;
                let p_pa = state[p_out];
                if self.quality_control_enabled {
                    let h0 = state[h_out];
                    let dh = (h0.abs() + 1.0) * 1e-6 + 1.0;
                    let dp_q = (p_pa.abs() + 1.0) * 1e-6 + 10.0;
                    if let (Some(qp), Some(qm)) = (
                        self.compute_quality(h0 + dh, p_pa),
                        self.compute_quality(h0 - dh, p_pa),
                    ) {
                        jacobian.add_entry(row, h_out, (qp - qm) / (2.0 * dh));
                    } else {
                        jacobian.add_entry(row, h_out, 1.0);
                    }
                    if let (Some(qp), Some(qm)) = (
                        self.compute_quality(h0, p_pa + dp_q),
                        self.compute_quality(h0, (p_pa - dp_q).max(1.0)),
                    ) {
                        jacobian.add_entry(row, p_out, (qp - qm) / (2.0 * dp_q));
                    }
                } else if p_pa > 10_000.0
                    && self.fluid_backend.is_some()
                    && !self.refrigerant_id.is_empty()
                {
                    jacobian.add_entry(row, h_out, 1.0);
                    let dp = p_pa * 1e-4 + 100.0;
                    if let (Ok(hg_p), Ok(hg_m)) = (
                        self.saturation_enthalpy_vapor(p_pa + dp),
                        self.saturation_enthalpy_vapor((p_pa - dp).max(1.0)),
                    ) {
                        jacobian.add_entry(row, p_out, -(hg_p - hg_m) / (2.0 * dp));
                    }
                } else {
                    jacobian.add_entry(row, h_out, 1.0);
                }
                row += 1;
            }
            _ => return Ok(()),
        }

        if self.secondary_edges_ready() {
            let (m_in, p_in_s, h_in_s) = self.sec_in_idx.unwrap();
            let (m_out, p_out_s, h_out_s) = self.sec_out_idx.unwrap();
            jacobian.add_entry(row, p_out_s, 1.0);
            jacobian.add_entry(row, p_in_s, -1.0);
            let ddp_dm = self.secondary_delta_p_dm(state[m_in]);
            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;
            }
            jacobian.add_entry(row, h_out_s, 1.0);
            jacobian.add_entry(row, h_in_s, -1.0);
            row += 1;
        }
        debug_assert_eq!(row, self.n_equations());
        Ok(())
    }
}

impl Component for FloodedEvaporator {
    fn set_system_context(
        &mut self,
        state_offset: usize,
        external_edge_state_indices: &[(usize, usize, usize)],
    ) {
        // Incident-edge convention (legacy): [0]=ref in, [1]=ref out.
        // Secondary edges are wired via set_port_context (preferred, Modelica-style).
        if !external_edge_state_indices.is_empty() {
            let (m, p, h) = external_edge_state_indices[0];
            self.inlet_m_idx = Some(m);
            self.inlet_p_idx = Some(p);
            self.inlet_h_idx = Some(h);
        }
        if external_edge_state_indices.len() >= 2 {
            let (m, p, h) = external_edge_state_indices[1];
            self.outlet_m_idx = Some(m);
            self.outlet_pressure_idx = Some(p);
            self.outlet_enthalpy_idx = Some(h);
        }
        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 set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
        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_pressure_idx = Some(*p);
            self.outlet_enthalpy_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)> {
        // Refrigerant 0→1, secondary 2→3 — each path shares one ṁ branch unknown.
        vec![(0, 1), (2, 3)]
    }

    fn n_equations(&self) -> usize {
        // r0 ΔP, r1 energy ref, outlet closure (quality OR saturated vapor),
        // optional secondary mass/energy.
        //
        // Outlet closure is required for a square system: energy alone cannot
        // determine both P_evap and h_out. For compressor suction (default),
        // the shell disengages vapor ≈ saturated (x=1). Legacy quality_control
        // replaces that with q_target (recirculation models only).
        3 + self.n_secondary()
    }

    fn equation_roles(&self) -> Vec<EquationRole> {
        let mut roles = vec![
            EquationRole::MomentumOrPressureDrop {
                stream: "refrigerant",
            },
            EquationRole::EnergyBalance {
                stream: "refrigerant",
            },
            if self.quality_control_enabled {
                EquationRole::OutletClosure { kind: "quality" }
            } else {
                // Physical compressor-suction default: disengaged saturated vapor.
                EquationRole::OutletClosure {
                    kind: "saturated_vapor",
                }
            },
        ];
        if self.secondary_edges_ready() {
            roles.push(EquationRole::MomentumOrPressureDrop {
                stream: "secondary",
            });
            if !self.sec_same_branch() {
                roles.push(EquationRole::MassConservation {
                    stream: "secondary",
                });
            }
            roles.push(EquationRole::EnergyBalance {
                stream: "secondary",
            });
        }
        roles
    }

    fn compute_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        if residuals.len() < self.n_equations() {
            return Err(ComponentError::InvalidResidualDimensions {
                expected: self.n_equations(),
                actual: residuals.len(),
            });
        }

        // Coupled ε-NTU path: system (live secondary edges) or rating (scalars).
        // Never fall through to generic HeatExchanger::inner (four-port only).
        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_pressure_idx.unwrap();
                let outlet_h_idx = self.outlet_enthalpy_idx.unwrap();
                let m_idx = self.resolved_mass_idx()?;

                let m_ref = state[m_idx];
                let h_in = state[inlet_h_idx];
                let h_out = state[outlet_h_idx];
                let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?;
                let ua = self.live_ua(Some(state));
                let q = self.coupled_duty_with(p_in, t_sec_in, c_sec, ua)?;

                // System mode: C∞ zero-flow gating on both streams (staging / Newton trials).
                // Rating mode: secondary is a fixed boundary (C_sec > 0). Do NOT multiply Q by
                // α(ṁ_ref) — that creates a trivial root at ṁ=0 (r_energy→0, outlet closure
                // alone cannot pin mass flow). Match Condenser rating residual: full Q.
                let (q_eff, alpha_s, m_sec_opt) = if self.system_secondary_ready() {
                    let (m_in, _, _) = self.sec_in_idx.unwrap();
                    let m_sec = state[m_in];
                    let alpha_r = flow_activity(m_ref, DEFAULT_M_EPS_KG_S);
                    let alpha_s = flow_activity(m_sec, DEFAULT_M_EPS_KG_S);
                    (effective_duty(q, alpha_r, alpha_s), alpha_s, Some(m_sec))
                } else {
                    (q, 1.0, None)
                };

                let mut row = 0;
                // r0: no refrigerant pressure drop (explicit selectable model later).
                residuals[row] = state[outlet_p_idx] - p_in;
                row += 1;
                // r1: ṁ·Δh − Q_eff
                residuals[row] = m_ref * (h_out - h_in) - q_eff;
                row += 1;

                // r2: outlet closure — quality target (legacy) OR saturated vapor (default).
                if self.quality_control_enabled {
                    let actual_quality = self.compute_quality(h_out, state[outlet_p_idx]).ok_or_else(
                        || {
                            ComponentError::CalculationFailed(
                                "FloodedEvaporator quality control requires computable outlet quality \
                                 (backend + refrigerant + valid saturation envelope)"
                                    .into(),
                            )
                        },
                    )?;
                    residuals[row] = actual_quality - self.target_quality;
                } else {
                    let h_sat_v = self.saturation_enthalpy_vapor(p_in)?;
                    residuals[row] = h_out - h_sat_v;
                }
                row += 1;

                // System mode: secondary momentum (isobaric or quadratic ΔP) + mass + energy.
                if self.system_secondary_ready() {
                    let (m_in, p_in_s, h_in_s) = self.sec_in_idx.unwrap();
                    let (m_out, p_out_s, h_out_s) = self.sec_out_idx.unwrap();
                    let m_sec = m_sec_opt.unwrap();
                    residuals[row] =
                        state[p_out_s] - state[p_in_s] + self.secondary_delta_p(state[m_in]);
                    row += 1;
                    if !self.sec_same_branch() {
                        residuals[row] = state[m_out] - state[m_in];
                        row += 1;
                    }
                    let r_sec_active = m_sec * (state[h_out_s] - state[h_in_s]) + q_eff;
                    residuals[row] = blend_transport_residual(
                        alpha_s,
                        r_sec_active,
                        DEFAULT_M_SCALE_KG_S,
                        state[h_out_s],
                        state[h_in_s],
                    );
                    row += 1;
                }
                debug_assert_eq!(row, self.n_equations());
                return Ok(());
            }

            // Transient seed: keep residual dimension constant without calling inner.
            return self.seed_residuals(state, residuals);
        }

        // Uncoupled / incomplete wiring: finite seed residuals (never inner four-port).
        self.seed_residuals(state, residuals)
    }

    fn jacobian_entries(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        if !self.coupled_ready() {
            return self.seed_jacobian(state, jacobian);
        }
        let inlet_p_idx = self.inlet_p_idx.unwrap();
        let p_in = state[inlet_p_idx];
        if p_in <= 10_000.0 {
            return self.seed_jacobian(state, jacobian);
        }

        let inlet_h_idx = self.inlet_h_idx.unwrap();
        let outlet_p_idx = self.outlet_pressure_idx.unwrap();
        let outlet_h_idx = self.outlet_enthalpy_idx.unwrap();
        let m_idx = self.resolved_mass_idx()?;

        let m_ref = state[m_idx];
        let h_in = state[inlet_h_idx];
        let h_out = state[outlet_h_idx];
        let (t_sec_in, c_sec) = self.resolve_secondary_stream(state)?;
        let ua = self.live_ua(Some(state));
        let eps = self.effectiveness(c_sec, ua);
        let q = self.coupled_duty_with(p_in, t_sec_in, c_sec, ua)?;
        let t_evap = self.evap_temperature(p_in)?;

        // System mode exposes secondary edge unknowns; rating mode freezes (T,C).
        let system = self.system_secondary_ready();
        let sec_edge = if system { self.sec_in_idx } else { None };

        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);
        let d_q_dp = -eps * c_sec * dt_dp;

        // System: q_eff = α_r·α_s·q with edge derivatives.
        // Rating: q_eff = q (full boundary duty — no trivial ṁ=0 root).
        let (
            q_eff,
            alpha_s,
            d_alpha_s_dm,
            m_sec,
            d_qeff_dm_ref,
            d_qeff_dm_sec,
            d_qeff_dp,
            d_qeff_dh_sec,
        ) = if let Some((m_in, p_s, h_s)) = sec_edge {
            let m_sec = state[m_in];
            let alpha_r = flow_activity(m_ref, DEFAULT_M_EPS_KG_S);
            let d_alpha_r_dm = flow_activity_derivative(m_ref, DEFAULT_M_EPS_KG_S);
            let alpha_s = flow_activity(m_sec, DEFAULT_M_EPS_KG_S);
            let d_alpha_s_dm = flow_activity_derivative(m_sec, DEFAULT_M_EPS_KG_S);
            let cp = self.sec_cp(state[p_s], state[h_s])?;
            let d_eps_c_dc = if c_sec > 1e-12 && ua > 0.0 {
                let ntu = ua / c_sec;
                let e = (-ntu).exp();
                1.0 - e * (1.0 - ntu)
            } else {
                0.0
            };
            let d_q_d_c = d_eps_c_dc * (t_sec_in - t_evap);
            let d_m_mag_dm = smooth_mass_magnitude_derivative(m_sec, DEFAULT_M_EPS_KG_S);
            let d_q_dm_sec = d_q_d_c * d_m_mag_dm * cp;
            let d_q_dh_sec = if cp > 0.0 { eps * c_sec / cp } else { 0.0 };
            let q_eff = effective_duty(q, alpha_r, alpha_s);
            (
                q_eff,
                alpha_s,
                d_alpha_s_dm,
                m_sec,
                d_alpha_r_dm * alpha_s * q,
                alpha_r * (d_alpha_s_dm * q + alpha_s * d_q_dm_sec),
                alpha_r * alpha_s * d_q_dp,
                alpha_r * alpha_s * d_q_dh_sec,
            )
        } else {
            (q, 1.0, 0.0, 0.0, 0.0, 0.0, d_q_dp, 0.0)
        };

        let mut row = 0;
        // r0 = P_out − P_in
        jacobian.add_entry(row, outlet_p_idx, 1.0);
        jacobian.add_entry(row, inlet_p_idx, -1.0);
        row += 1;

        // r1 = ṁ·(h_out − h_in) − q_eff
        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) - d_qeff_dm_ref);
        jacobian.add_entry(row, inlet_p_idx, -d_qeff_dp);
        if let Some((m_s, _, h_s)) = sec_edge {
            jacobian.add_entry(row, m_s, -d_qeff_dm_sec);
            jacobian.add_entry(row, h_s, -d_qeff_dh_sec);
        }
        // Live z_ua column: UA = UA_nom·z_ua, ε = 1−e^(−UA/C), Q = ε·C·ΔT
        if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
            let d_eps_dua = if c_sec > 1e-12 {
                (-ua / c_sec).exp() / c_sec
            } else {
                0.0
            };
            let d_q_dua = d_eps_dua * c_sec * (t_sec_in - t_evap);
            let alpha_r = if system {
                flow_activity(m_ref, DEFAULT_M_EPS_KG_S)
            } else {
                1.0
            };
            let d_qeff_dz = alpha_r * alpha_s * d_q_dua * self.inner.ua_nominal();
            jacobian.add_entry(row, z_ua_idx, -d_qeff_dz);
        }
        row += 1;

        // r2 outlet closure
        if self.quality_control_enabled {
            let h0 = h_out;
            let p0 = state[outlet_p_idx];
            let dh = (h0.abs() + 1.0) * 1e-6 + 1.0;
            let dp_q = (p0.abs() + 1.0) * 1e-6 + 10.0;
            if let (Some(qp), Some(qm)) = (
                self.compute_quality(h0 + dh, p0),
                self.compute_quality(h0 - dh, p0),
            ) {
                jacobian.add_entry(row, outlet_h_idx, (qp - qm) / (2.0 * dh));
            }
            if let (Some(qp), Some(qm)) = (
                self.compute_quality(h0, p0 + dp_q),
                self.compute_quality(h0, (p0 - dp_q).max(1.0)),
            ) {
                jacobian.add_entry(row, outlet_p_idx, (qp - qm) / (2.0 * dp_q));
            }
        } else {
            jacobian.add_entry(row, outlet_h_idx, 1.0);
            let hg_p = self.saturation_enthalpy_vapor(p_in + dp)?;
            let hg_m = self.saturation_enthalpy_vapor((p_in - dp).max(1.0))?;
            let dhg_dp = (hg_p - hg_m) / (2.0 * dp);
            jacobian.add_entry(row, inlet_p_idx, -dhg_dp);
        }
        row += 1;

        // System secondary residuals only when live edges are present.
        if system {
            let (m_in, p_in_s, h_in_s) = self.sec_in_idx.unwrap();
            let (m_out, p_out_s, h_out_s) = self.sec_out_idx.unwrap();
            jacobian.add_entry(row, p_out_s, 1.0);
            jacobian.add_entry(row, p_in_s, -1.0);
            let ddp_dm = self.secondary_delta_p_dm(state[m_in]);
            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;
            }
            let dh_s = state[h_out_s] - state[h_in_s];
            let r_sec_active = m_sec * dh_s + q_eff;
            let (dr_da, dr_dract, dr_dhout, dr_dhin) = blend_transport_partials(
                alpha_s,
                r_sec_active,
                DEFAULT_M_SCALE_KG_S,
                state[h_out_s],
                state[h_in_s],
            );
            jacobian.add_entry(row, h_out_s, dr_dract * m_sec + dr_dhout);
            jacobian.add_entry(
                row,
                h_in_s,
                dr_dract * (-m_sec) + dr_dhin + dr_dract * d_qeff_dh_sec,
            );
            jacobian.add_entry(
                row,
                m_in,
                dr_da * d_alpha_s_dm + dr_dract * (dh_s + d_qeff_dm_sec),
            );
            jacobian.add_entry(row, inlet_p_idx, dr_dract * d_qeff_dp);
            jacobian.add_entry(row, m_idx, dr_dract * d_qeff_dm_ref);
            row += 1;
        }
        debug_assert_eq!(row, self.n_equations());
        Ok(())
    }

    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<MassFlow>, ComponentError> {
        // Modelica-style ports: [ref_in, ref_out, sec_in, sec_out].
        // Sign convention: positive = into the component.
        let mut flows = vec![MassFlow::from_kg_per_s(0.0); 4];
        if let Some(m) = self.inlet_m_idx {
            if m < state.len() {
                flows[0] = MassFlow::from_kg_per_s(state[m]);
            }
        }
        if let Some(m) = self.outlet_m_idx.or(self.inlet_m_idx) {
            if m < state.len() {
                flows[1] = MassFlow::from_kg_per_s(-state[m]);
            }
        }
        if let Some((m_in, _, _)) = self.sec_in_idx {
            if m_in < state.len() {
                flows[2] = MassFlow::from_kg_per_s(state[m_in]);
            }
        }
        if let Some((m_out, _, _)) = self.sec_out_idx {
            if m_out < state.len() {
                flows[3] = MassFlow::from_kg_per_s(-state[m_out]);
            }
        } else if let Some((m_in, _, _)) = self.sec_in_idx {
            if m_in < state.len() {
                flows[3] = MassFlow::from_kg_per_s(-state[m_in]);
            }
        }
        Ok(flows)
    }

    fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
        // Coupled (system live secondary or rating scalars): Q absorbed by refrigerant.
        if self.coupled_ready() {
            if let (Ok(m_idx), Some(in_h), Some(out_h)) = (
                self.resolved_mass_idx(),
                self.inlet_h_idx,
                self.outlet_enthalpy_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((Power::from_watts(q_abs), Power::from_watts(0.0)));
                    }
                }
            }
        }
        None
    }

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

    fn signature(&self) -> String {
        format!(
            "FloodedEvaporator(UA={:.0},fluid={},target_q={:.2})",
            self.ua(),
            self.refrigerant_id,
            self.target_quality
        )
    }

    fn to_params(&self) -> crate::ComponentParams {
        crate::ComponentParams::new("FloodedEvaporator")
            .with_param("fluid", self.refrigerant_id.as_str())
            .with_param("ua", self.ua())
            .with_param("targetQuality", self.target_quality)
            .with_param(
                "calib",
                serde_json::to_value(self.calib()).unwrap_or(serde_json::Value::Null),
            )
    }

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

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

    #[test]
    fn test_flooded_evaporator_creation() {
        let mut evap = FloodedEvaporator::new(10_000.0);
        assert_eq!(evap.ua(), 10_000.0);
        // Base: ΔP + energy + saturated-vapor (or quality) outlet closure.
        assert_eq!(evap.n_equations(), 3);
        assert_eq!(evap.target_quality(), 0.7);
        assert_eq!(evap.outlet_quality(), None);

        // quality_control replaces sat-vapor with quality residual — same count.
        evap.quality_control_enabled = true;
        assert_eq!(evap.n_equations(), 3);
    }

    #[test]
    #[should_panic(expected = "UA must be non-negative")]
    fn test_flooded_evaporator_negative_ua_panics() {
        let _evap = FloodedEvaporator::new(-100.0);
    }

    #[test]
    fn test_flooded_evaporator_zero_ua_allowed() {
        let evap = FloodedEvaporator::new(0.0);
        assert_eq!(evap.ua(), 0.0);
    }

    #[test]
    fn test_flooded_evaporator_with_quality_control() {
        let evap = FloodedEvaporator::new(10_000.0).with_quality_control(true);
        assert_eq!(evap.n_equations(), 3);
    }

    #[test]
    fn test_flooded_evaporator_with_target_quality() {
        let evap = FloodedEvaporator::new(10_000.0).with_target_quality(0.6);
        assert_eq!(evap.target_quality(), 0.6);
    }

    #[test]
    fn test_flooded_evaporator_clamps_quality() {
        let evap = FloodedEvaporator::new(10_000.0).with_target_quality(1.5);
        assert_eq!(evap.target_quality(), 1.0);

        let evap = FloodedEvaporator::new(10_000.0).with_target_quality(-0.5);
        assert_eq!(evap.target_quality(), 0.0);
    }

    #[test]
    fn test_flooded_evaporator_compute_residuals() {
        let evap = FloodedEvaporator::new(10_000.0);
        let state = vec![0.0; 10];
        let mut residuals = vec![0.0; 3];

        // Unwired: seed path returns finite residuals (never panics / four-port error).
        let result = evap.compute_residuals(&state, &mut residuals);
        assert!(result.is_ok(), "unwired seed must succeed: {result:?}");
        assert!(residuals.iter().all(|r| r.is_finite()));
    }

    /// Rating mode: scalar secondary drives ε-NTU Q without live secondary edges.
    #[test]
    fn test_rating_mode_residuals_use_scalar_secondary_no_four_port_error() {
        use std::sync::Arc;

        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let mut evap = FloodedEvaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_secondary_stream(285.0, 2000.0);

        // Refrigerant 2-port only (no secondary edges).
        let port_edges = [
            Some((0usize, 1usize, 2usize)),
            Some((0usize, 3usize, 4usize)),
            None,
            None,
        ];
        evap.set_port_context(&port_edges);
        assert!(evap.coupled_ready());
        assert!(!evap.system_secondary_ready());
        assert_eq!(evap.n_equations(), 3); // no secondary residual rows

        let p_in = 350_000.0_f64;
        let mut state = vec![0.0; 5];
        state[0] = 0.1; // ṁ
        state[1] = p_in;
        state[2] = 250_000.0; // h_in
        state[3] = p_in;
        state[4] = 400_000.0; // h_out

        let mut residuals = vec![0.0; 3];
        evap.compute_residuals(&state, &mut residuals)
            .expect("rating residuals must not hit four-port error");
        assert!(residuals.iter().all(|r| r.is_finite()));

        // Energy residual must include non-zero Q (ṁ·Δh − Q ≠ ṁ·Δh alone).
        let q = evap.coupled_duty(p_in).unwrap();
        assert!(q > 0.0, "rating duty must be positive");
        let energy_without_q = state[0] * (state[4] - state[2]);
        assert!(
            (residuals[1] - (energy_without_q - q)).abs() < 1e-6 * q.max(1.0),
            "r1 must be ṁ·Δh − Q_rating: got {} expected {}",
            residuals[1],
            energy_without_q - q
        );

        let mut jac = JacobianBuilder::new();
        evap.jacobian_entries(&state, &mut jac)
            .expect("rating jacobian must succeed");
    }

    /// Coupled duty must react to the secondary (water) inlet temperature and flow:
    /// hotter water or more secondary flow ⇒ larger duty ⇒ the refrigerant
    /// energy-balance residual changes accordingly.
    ///
    /// System mode uses **live secondary edges** (ports 2/3), not scalar cheats.
    #[test]
    fn test_coupled_duty_reacts_to_secondary_conditions() {
        use std::sync::Arc;

        // Layout: ref_in (0,1,2), ref_out (0,3,4) shared ṁ; sec_in (5,6,7), sec_out (5,8,9).
        let p_in = 350_000.0_f64;
        let p_sec = 200_000.0_f64;
        let mut state = vec![0.0; 10];
        state[0] = 0.1; // ṁ_ref
        state[1] = p_in;
        state[2] = 250_000.0; // h_ref_in
        state[3] = p_in;
        state[4] = 400_000.0; // h_ref_out
        state[5] = 0.5; // ṁ_sec
        state[6] = p_sec;
        // h_sec set per case from T via TestBackend-friendly values; TestBackend
        // uses simple linear property models — we drive T via h using rating
        // path for Q and live edges for residuals.

        let backend = Arc::new(entropyk_fluids::TestBackend::new());

        // Wire 4-port context: ref in/out + sec in/out.
        let port_edges = [
            Some((0usize, 1usize, 2usize)),
            Some((0usize, 3usize, 4usize)),
            Some((5usize, 6usize, 7usize)),
            Some((5usize, 8usize, 9usize)),
        ];

        let make = |h_sec_in: f64| {
            let mut evap = FloodedEvaporator::new(8_000.0)
                .with_refrigerant("R134a")
                .with_secondary_fluid("Water")
                .with_fluid_backend(backend.clone())
                // Rating scalars still used by coupled_duty() helper in tests.
                .with_secondary_stream(285.0, 2000.0);
            evap.set_port_context(&port_edges);
            let mut st = state.clone();
            st[7] = h_sec_in;
            st[9] = h_sec_in; // seed outlet = inlet until residual solves
            (evap, st)
        };

        // Rating-mode duty response (scalar path) — still valid for qualification.
        let q_cold = {
            let e = FloodedEvaporator::new(8_000.0)
                .with_refrigerant("R134a")
                .with_fluid_backend(backend.clone())
                .with_secondary_stream(285.0, 2000.0);
            e.coupled_duty(p_in).unwrap()
        };
        let q_hot = {
            let e = FloodedEvaporator::new(8_000.0)
                .with_refrigerant("R134a")
                .with_fluid_backend(backend.clone())
                .with_secondary_stream(295.0, 2000.0);
            e.coupled_duty(p_in).unwrap()
        };
        assert!(q_cold > 0.0, "duty must be positive");
        assert!(
            q_hot > q_cold + 1.0,
            "hotter secondary inlet must increase duty: q_hot={q_hot}, q_cold={q_cold}"
        );

        let q_highflow = {
            let e = FloodedEvaporator::new(8_000.0)
                .with_refrigerant("R134a")
                .with_fluid_backend(backend.clone())
                .with_secondary_stream(285.0, 6000.0);
            e.coupled_duty(p_in).unwrap()
        };
        assert!(
            q_highflow > q_cold + 1.0,
            "more secondary flow must increase duty: q_highflow={q_highflow}, q_cold={q_cold}"
        );

        // System-mode: equation roles include secondary energy when 4-port is wired.
        let (evap, _) = make(100_000.0);
        assert_eq!(evap.n_equations(), 5); // ΔP + energy + sat-vapor + P_sec + energy_sec
        let roles = evap.equation_roles();
        assert!(
            roles.iter().any(|r| matches!(
                r,
                crate::EquationRole::EnergyBalance {
                    stream: "secondary"
                }
            )),
            "system mode must declare secondary energy residual"
        );
        assert!(
            roles.iter().any(|r| matches!(
                r,
                crate::EquationRole::OutletClosure {
                    kind: "saturated_vapor"
                }
            )),
            "default suction model uses saturated-vapor outlet closure"
        );
    }

    /// Qualification workflow: hold the refrigerant regime constant (fixed
    /// evaporating pressure) and sweep the water inlet temperature and flow.
    /// Every output is solved from the real ε-NTU balance and must respond
    /// physically — this is what the user needs to qualify a heat exchanger.
    #[test]
    fn test_qualification_sweep_water_conditions() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let p_evap = 350_000.0_f64; // constant refrigerant regime (R134a ~ +4 °C)

        let rate_at = |t_water_k: f64, c_sec: f64| {
            FloodedEvaporator::new(8_000.0)
                .with_refrigerant("R134a")
                .with_fluid_backend(backend.clone())
                .with_secondary_stream(t_water_k, c_sec)
                .rate(p_evap)
                .unwrap()
        };

        // --- Sweep water inlet temperature at fixed flow (C_sec = 2000 W/K) ---
        let mut last_q = f64::NEG_INFINITY;
        for t_c in [7.0, 12.0, 18.0, 25.0] {
            let r = rate_at(t_c + 273.15, 2000.0);
            // Physical sanity: effectiveness in (0,1), positive approach, positive duty,
            // secondary leaves colder than it entered.
            assert!(r.effectiveness > 0.0 && r.effectiveness < 1.0);
            assert!(r.approach_k > 0.0, "water must be warmer than T_evap");
            assert!(r.q_w > 0.0);
            assert!(r.secondary_outlet_k < t_c + 273.15);
            // Warmer water ⇒ more duty (strictly increasing).
            assert!(
                r.q_w > last_q + 1.0,
                "duty must increase with water inlet temp: {} !> {}",
                r.q_w,
                last_q
            );
            last_q = r.q_w;
        }

        // --- Sweep water flow at fixed inlet temp (12 °C) ---
        let t_w = 12.0 + 273.15;
        let r_low = rate_at(t_w, 1000.0);
        let r_high = rate_at(t_w, 4000.0);
        // More water flow ⇒ higher capacity rate ⇒ NTU = UA/C_sec drops ⇒ effectiveness
        // DECREASES, yet the duty INCREASES because C_sec dominates. This is the correct
        // ε-NTU behaviour and exactly the kind of trade-off a qualification must reveal.
        assert!(r_high.effectiveness < r_low.effectiveness);
        assert!(r_high.q_w > r_low.q_w + 1.0);
        // Refrigerant regime unchanged ⇒ evaporating temperature identical across the
        // entire sweep (it depends only on the fixed pressure).
        assert!((r_low.t_evap_k - r_high.t_evap_k).abs() < 1e-9);
    }

    #[test]
    fn test_flooded_evaporator_state_manageable() {
        let evap = FloodedEvaporator::new(10_000.0);
        assert_eq!(evap.state(), OperationalState::On);
        assert!(evap.can_transition_to(OperationalState::Off));
    }

    #[test]
    fn test_flooded_evaporator_set_state() {
        let mut evap = FloodedEvaporator::new(10_000.0);
        assert_eq!(evap.state(), OperationalState::On);

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

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

    #[test]
    fn test_flooded_evaporator_signature() {
        let evap = FloodedEvaporator::new(10_000.0)
            .with_refrigerant("R410A")
            .with_target_quality(0.75);

        let sig = evap.signature();
        assert!(sig.contains("FloodedEvaporator"));
        assert!(sig.contains("R410A"));
        assert!(sig.contains("0.75"));
    }

    #[test]
    fn test_flooded_evaporator_set_target_quality() {
        let mut evap = FloodedEvaporator::new(10_000.0);
        evap.set_target_quality(0.65);
        assert_eq!(evap.target_quality(), 0.65);
    }

    #[test]
    fn test_flooded_evaporator_energy_transfers() {
        let evap = FloodedEvaporator::new(10_000.0);
        let state = vec![0.0; 10];
        // Unwired / uncoupled: no reliable Q — report None (not a fake zero).
        assert!(evap.energy_transfers(&state).is_none());
    }

    /// Zero / near-zero mass flow must keep residuals and Jacobian finite (no NaN/Inf).
    #[test]
    fn test_zero_and_near_zero_flow_residuals_finite() {
        use crate::Component;
        use std::sync::Arc;

        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let p_in = 350_000.0_f64;
        // Water at ~1 atm for TestBackend liquid domain.
        let p_sec = 101_325.0_f64;
        let port_edges = [
            Some((0usize, 1usize, 2usize)),
            Some((0usize, 3usize, 4usize)),
            Some((5usize, 6usize, 7usize)),
            Some((5usize, 8usize, 9usize)),
        ];
        let mut evap = FloodedEvaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_secondary_fluid("Water")
            .with_fluid_backend(backend);
        evap.set_port_context(&port_edges);

        let mut base = vec![0.0; 10];
        base[1] = p_in;
        base[2] = 250_000.0;
        base[3] = p_in;
        base[4] = 400_000.0;
        base[6] = p_sec;
        base[7] = 50_000.0; // water enthalpy ~12°C in TestBackend scale
        base[8] = 50_000.0;
        base[9] = 50_000.0;

        for &m_ref in &[0.0, 1e-8, 1e-4, -1e-5, 0.05] {
            for &m_sec in &[0.0, 1e-8, 1e-4, -1e-5, 0.5] {
                let mut state = base.clone();
                state[0] = m_ref;
                state[5] = m_sec;
                let n = evap.n_equations();
                let mut r = vec![0.0; n];
                let res = evap.compute_residuals(&state, &mut r);
                assert!(
                    res.is_ok(),
                    "residuals failed at m_ref={m_ref} m_sec={m_sec}: {res:?}"
                );
                assert!(
                    r.iter().all(|x| x.is_finite()),
                    "non-finite residual at m_ref={m_ref} m_sec={m_sec}: {r:?}"
                );
                let mut jac = crate::JacobianBuilder::new();
                let jres = evap.jacobian_entries(&state, &mut jac);
                assert!(
                    jres.is_ok(),
                    "jacobian failed at m_ref={m_ref} m_sec={m_sec}: {jres:?}"
                );
                // Entries stored as (row, col, val)
                for &(row, col, val) in jac.entries() {
                    assert!(
                        val.is_finite(),
                        "non-finite J[{row},{col}]={val} at m_ref={m_ref} m_sec={m_sec}"
                    );
                }
            }
        }
    }

    /// At exact zero secondary flow, duty activity extinguishes heat transfer
    /// so the secondary energy residual reduces to a hold on Δh_sec.
    #[test]
    fn test_zero_secondary_flow_holds_enthalpy_transport() {
        use crate::Component;
        use std::sync::Arc;

        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let p_in = 350_000.0;
        let p_sec = 101_325.0;
        let port_edges = [
            Some((0usize, 1usize, 2usize)),
            Some((0usize, 3usize, 4usize)),
            Some((5usize, 6usize, 7usize)),
            Some((5usize, 8usize, 9usize)),
        ];
        let mut evap = FloodedEvaporator::new(8_000.0)
            .with_refrigerant("R134a")
            .with_secondary_fluid("Water")
            .with_fluid_backend(backend);
        evap.set_port_context(&port_edges);

        let mut state = vec![0.0; 10];
        state[0] = 0.1; // refrigerant flowing
        state[1] = p_in;
        state[2] = 250_000.0;
        state[3] = p_in;
        state[4] = 400_000.0;
        state[5] = 0.0; // secondary exactly zero
        state[6] = p_sec;
        state[7] = 40_000.0;
        state[8] = 40_000.0;
        state[9] = 55_000.0; // Δh_sec ≠ 0

        let mut r = vec![0.0; evap.n_equations()];
        evap.compute_residuals(&state, &mut r).unwrap();
        // Last residual is secondary energy (same-branch → 1 sec eq).
        // α_s=0 → r = m_scale * (h_out − h_in) = 0.05 * 15000
        let expected = DEFAULT_M_SCALE_KG_S * 15_000.0;
        let r_sec = r[r.len() - 1];
        assert!(
            (r_sec - expected).abs() < 1.0,
            "zero-sec hold residual: got {r_sec}, expected ~{expected}"
        );
        assert!(r.iter().all(|x| x.is_finite()));
    }

    #[test]
    fn test_flooded_evaporator_with_refrigerant() {
        let evap = FloodedEvaporator::new(10_000.0).with_refrigerant("R134a");
        let sig = evap.signature();
        assert!(sig.contains("R134a"));
    }

    #[test]
    fn test_flooded_evaporator_with_secondary_fluid() {
        let evap = FloodedEvaporator::new(10_000.0).with_secondary_fluid("Water");
        let debug_str = format!("{:?}", evap);
        assert!(debug_str.contains("Water"));
    }

    #[test]
    fn test_validate_outlet_quality_no_refrigerant() {
        let evap = FloodedEvaporator::new(10_000.0);
        let result = evap.validate_outlet_quality(400_000.0, 300_000.0);
        assert!(result.is_err());
        match result {
            Err(ComponentError::InvalidState(msg)) => {
                assert!(msg.contains("refrigerant_id not set"));
            }
            _ => panic!("Expected InvalidState error"),
        }
    }

    #[test]
    fn test_validate_outlet_quality_no_backend() {
        let evap = FloodedEvaporator::new(10_000.0).with_refrigerant("R134a");
        let result = evap.validate_outlet_quality(400_000.0, 300_000.0);
        assert!(result.is_err());
        match result {
            Err(ComponentError::CalculationFailed(msg)) => {
                assert!(msg.contains("FluidBackend not configured"));
            }
            _ => panic!("Expected CalculationFailed error"),
        }
    }

    #[test]
    fn test_set_outlet_indices() {
        let mut evap = FloodedEvaporator::new(10_000.0).with_quality_control(true);
        evap.set_outlet_indices(2, 3);

        // Partial wiring (outlet only): seed path stays finite — never four-port crash.
        let state = vec![0.0, 0.0, 300_000.0, 400_000.0, 0.0, 0.0];
        let mut residuals = vec![0.0; 3];
        let result = evap.compute_residuals(&state, &mut residuals);
        assert!(
            result.is_ok(),
            "partial wire must use seed path: {result:?}"
        );
        assert!(residuals.iter().all(|r| r.is_finite()));
    }

    #[test]
    fn test_heat_transfer_initial_value() {
        let evap = FloodedEvaporator::new(10_000.0);
        assert_eq!(evap.heat_transfer(), 0.0);
    }

    #[test]
    fn test_cooper_ua_outer_loop_updates() {
        let mut evap = FloodedEvaporator::new(10_000.0)
            .with_ua_mode(UaMode::CooperPoolBoiling)
            .with_pool_boiling(FloodedPoolBoilingConfig::default());
        let ua = evap
            .update_ua_from_pool_boiling(20_000.0, 0.12, 102.0, 4.059e6)
            .unwrap();
        assert!(ua.is_finite() && ua > 1000.0);
        assert!((evap.ua() - ua).abs() < 1.0);
    }
}
