//! Generic Heat Exchanger Component
//!
//! A heat exchanger with 4 ports (hot inlet, hot outlet, cold inlet, cold outlet)
//! and a pluggable heat transfer model.
//!
//! ## Fluid Backend Integration (Story 5.1)
//!
//! `compute_residuals` requires live four-port edge state. Inlet-only boundary
//! conditions may be used for property inspection, but they are not enough to
//! synthesize outlet states.

use super::model::{FluidState, HeatTransferModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
    Component, ComponentError, ConnectedPort, DomainViolation, JacobianBuilder, ResidualVector,
    StateSlice,
};
use entropyk_core::{Calib, MassFlow, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, FluidId as FluidsFluidId, Property, ThermoState};
use std::marker::PhantomData;
use std::sync::Arc;

/// Builder for creating a heat exchanger with disconnected ports.
pub struct HeatExchangerBuilder<Model: HeatTransferModel> {
    model: Model,
    name: String,
    circuit_id: CircuitId,
}

impl<Model: HeatTransferModel + 'static> HeatExchangerBuilder<Model> {
    /// Creates a new builder.
    pub fn new(model: Model) -> Self {
        Self {
            model,
            name: String::from("HeatExchanger"),
            circuit_id: CircuitId::default(),
        }
    }

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

    /// Sets the circuit identifier.
    pub fn circuit_id(mut self, circuit_id: CircuitId) -> Self {
        self.circuit_id = circuit_id;
        self
    }

    /// Builds the heat exchanger. Topology is injected later by name/context.
    pub fn build(self) -> HeatExchanger<Model> {
        HeatExchanger::new(self.model, self.name).with_circuit_id(self.circuit_id)
    }
}

/// Generic heat exchanger component with 4 ports.
///
/// Uses the Strategy Pattern for heat transfer calculations via the
/// `HeatTransferModel` trait.
///
/// # Type Parameters
///
/// * `Model` - The heat transfer model (LmtdModel, EpsNtuModel, etc.)
///
/// # Ports
///
/// - `hot_inlet`: Hot fluid inlet
/// - `hot_outlet`: Hot fluid outlet
/// - `cold_inlet`: Cold fluid inlet
/// - `cold_outlet`: Cold fluid outlet
///
/// # Equations
///
/// The heat exchanger contributes 3 residual equations:
/// 1. Hot side energy balance
/// 2. Cold side energy balance
/// 3. Energy conservation (Q_hot = Q_cold)
///
/// # Operational States
///
/// - **On**: Normal heat transfer operation
/// - **Off**: Zero mass flow on both sides, no heat transfer
/// - **Bypass**: Mass flow continues, no heat transfer (adiabatic)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration};
/// use entropyk_components::Component;
///
/// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
/// let hx = HeatExchanger::new(model, "Condenser");
/// assert_eq!(hx.n_equations(), 2);
/// ```
/// Boundary conditions for one side of the heat exchanger.
///
/// Specifies the inlet state for a fluid stream: temperature, pressure, mass flow,
/// and the fluid identity used to query thermodynamic properties from the backend.
#[derive(Debug, Clone)]
pub struct HxSideConditions {
    temperature_k: f64,
    pressure_pa: f64,
    mass_flow_kg_s: f64,
    fluid_id: FluidsFluidId,
}

impl HxSideConditions {
    /// Returns the inlet temperature in Kelvin.
    pub fn temperature_k(&self) -> f64 {
        self.temperature_k
    }
    /// Returns the inlet pressure in Pascals.
    pub fn pressure_pa(&self) -> f64 {
        self.pressure_pa
    }
    /// Returns the mass flow rate in kg/s.
    pub fn mass_flow_kg_s(&self) -> f64 {
        self.mass_flow_kg_s
    }
    /// Returns a reference to the fluid identifier.
    pub fn fluid_id(&self) -> &FluidsFluidId {
        &self.fluid_id
    }
}

impl HxSideConditions {
    /// Creates a new set of boundary conditions.
    pub fn new(
        temperature: Temperature,
        pressure: Pressure,
        mass_flow: MassFlow,
        fluid_id: impl Into<String>,
    ) -> Result<Self, ComponentError> {
        let t = temperature.to_kelvin();
        let p = pressure.to_pascals();
        let m = mass_flow.to_kg_per_s();

        // Basic validation for physically plausible states
        if t <= 0.0 {
            return Err(ComponentError::InvalidState(
                "Temperature must be greater than 0 K".to_string(),
            ));
        }
        if p <= 0.0 {
            return Err(ComponentError::InvalidState(
                "Pressure must be strictly positive".to_string(),
            ));
        }
        if m < 0.0 {
            return Err(ComponentError::InvalidState(
                "Mass flow must be non-negative".to_string(),
            ));
        }

        Ok(Self {
            temperature_k: t,
            pressure_pa: p,
            mass_flow_kg_s: m,
            fluid_id: FluidsFluidId::new(fluid_id),
        })
    }
}

/// Live four-port edge indices: each triple is `(ṁ_idx, P_idx, h_idx)`.
#[derive(Debug, Clone, Copy)]
pub(crate) struct FourPortEdgeIndices {
    pub hot_in: (usize, usize, usize),
    pub hot_out: (usize, usize, usize),
    pub cold_in: (usize, usize, usize),
    pub cold_out: (usize, usize, usize),
}

/// Generic heat exchanger component with 4 ports.
///
/// Uses the Strategy Pattern for heat transfer calculations via the
/// `HeatTransferModel` trait. When a `FluidBackend` is attached via
/// [`with_fluid_backend`](Self::with_fluid_backend), the `compute_residuals`
/// method queries real thermodynamic properties (Cp, h) from the live edge
/// state instead of using hardcoded placeholder values.
pub struct HeatExchanger<Model: HeatTransferModel> {
    model: Model,
    name: String,
    /// Calibration: f_dp for refrigerant-side ΔP when modeled, f_ua for UA scaling
    calib: Calib,
    /// Indices for dynamically extracting calibration factors from the system state
    calib_indices: entropyk_core::CalibIndices,
    operational_state: OperationalState,
    circuit_id: CircuitId,
    /// Optional fluid property backend for real thermodynamic calculations (Story 5.1).
    fluid_backend: Option<Arc<dyn FluidBackend>>,
    /// Boundary conditions for the hot side inlet.
    hot_conditions: Option<HxSideConditions>,
    /// Boundary conditions for the cold side inlet.
    cold_conditions: Option<HxSideConditions>,
    // ── 4-port (Modelica-style) edge-driven mode ───────────────────────────
    /// Hot inlet edge state indices (m, p, h). Wired by `set_port_context` port 0.
    hot_in_idx: Option<(usize, usize, usize)>,
    /// Hot outlet edge state indices. Wired by `set_port_context` port 1.
    hot_out_idx: Option<(usize, usize, usize)>,
    /// Cold inlet edge state indices. Wired by `set_port_context` port 2.
    cold_in_idx: Option<(usize, usize, usize)>,
    /// Cold outlet edge state indices. Wired by `set_port_context` port 3.
    cold_out_idx: Option<(usize, usize, usize)>,
    /// Hot-side fluid identifier ("Water", "Air", "INCOMP::MEG-30"…).
    hot_fluid_id_str: String,
    /// Cold-side fluid identifier.
    cold_fluid_id_str: String,
    /// Humidity ratio for moist-air hot side (0 = dry).
    hot_humidity_ratio: f64,
    /// Humidity ratio for moist-air cold side.
    cold_humidity_ratio: f64,
    _phantom: PhantomData<()>,
}

impl<Model: HeatTransferModel + std::fmt::Debug> std::fmt::Debug for HeatExchanger<Model> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HeatExchanger")
            .field("name", &self.name)
            .field("model", &self.model)
            .field("calib", &self.calib)
            .field("operational_state", &self.operational_state)
            .field("circuit_id", &self.circuit_id)
            .field("has_fluid_backend", &self.fluid_backend.is_some())
            .finish()
    }
}

impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
    /// Creates a new heat exchanger with the given model.
    pub fn new(mut model: Model, name: impl Into<String>) -> Self {
        let calib = Calib::default();
        model.set_ua_scale(calib.z_ua);
        Self {
            model,
            name: name.into(),
            calib,
            calib_indices: entropyk_core::CalibIndices::default(),
            operational_state: OperationalState::default(),
            circuit_id: CircuitId::default(),
            fluid_backend: None,
            hot_conditions: None,
            cold_conditions: None,
            hot_in_idx: None,
            hot_out_idx: None,
            cold_in_idx: None,
            cold_out_idx: None,
            hot_fluid_id_str: String::new(),
            cold_fluid_id_str: String::new(),
            hot_humidity_ratio: 0.0,
            cold_humidity_ratio: 0.0,
            _phantom: PhantomData,
        }
    }

    /// Attaches a `FluidBackend` so `compute_residuals` can query real thermodynamic properties.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration, HxSideConditions};
    /// use entropyk_fluids::{TestBackend, FluidId};
    /// use entropyk_core::{Temperature, Pressure, MassFlow};
    /// use std::sync::Arc;
    ///
    /// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
    /// let hx = HeatExchanger::new(model, "Condenser")
    ///     .with_fluid_backend(Arc::new(TestBackend::new()))
    ///     .with_hot_conditions(HxSideConditions::new(
    ///         Temperature::from_celsius(60.0),
    ///         Pressure::from_bar(25.0),
    ///         MassFlow::from_kg_per_s(0.05),
    ///         "R410A",
    ///     ).unwrap())
    ///     .with_cold_conditions(HxSideConditions::new(
    ///         Temperature::from_celsius(30.0),
    ///         Pressure::from_bar(1.5),
    ///         MassFlow::from_kg_per_s(0.2),
    ///         "Water",
    ///     ).unwrap());
    /// ```
    pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
        self.fluid_backend = Some(backend);
        self
    }

    /// Sets the hot side boundary conditions for fluid property queries.
    pub fn with_hot_conditions(mut self, conditions: HxSideConditions) -> Self {
        self.hot_conditions = Some(conditions);
        self
    }

    /// Sets the cold side boundary conditions for fluid property queries.
    pub fn with_cold_conditions(mut self, conditions: HxSideConditions) -> Self {
        self.cold_conditions = Some(conditions);
        self
    }

    /// Sets the hot side boundary conditions (mutable).
    pub fn set_hot_conditions(&mut self, conditions: HxSideConditions) {
        self.hot_conditions = Some(conditions);
    }

    /// Sets the cold side boundary conditions (mutable).
    pub fn set_cold_conditions(&mut self, conditions: HxSideConditions) {
        self.cold_conditions = Some(conditions);
    }

    /// Attaches a fluid backend (mutable).
    pub fn set_fluid_backend(&mut self, backend: Arc<dyn FluidBackend>) {
        self.fluid_backend = Some(backend);
    }

    /// Returns true if a real `FluidBackend` is attached.
    pub fn has_fluid_backend(&self) -> bool {
        self.fluid_backend.is_some()
    }

    /// Returns the hot side fluid identifier, if set.
    pub fn hot_conditions(&self) -> Option<&HxSideConditions> {
        self.hot_conditions.as_ref()
    }

    /// Documentation pending
    pub fn cold_conditions(&self) -> Option<&HxSideConditions> {
        self.cold_conditions.as_ref()
    }

    /// Documentation pending
    pub fn hot_fluid_id(&self) -> Option<&FluidsFluidId> {
        self.hot_conditions.as_ref().map(|c| c.fluid_id())
    }

    /// Returns the cold side fluid identifier, if set.
    pub fn cold_fluid_id(&self) -> Option<&FluidsFluidId> {
        self.cold_conditions.as_ref().map(|c| c.fluid_id())
    }

    /// Computes the full thermodynamic state at the hot inlet.
    pub fn hot_inlet_state(&self) -> Result<ThermoState, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("No FluidBackend configured".to_string())
        })?;
        let conditions = self.hot_conditions.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("Hot conditions not set".to_string())
        })?;
        let h = self.query_enthalpy(conditions)?;
        backend
            .full_state(
                conditions.fluid_id().clone(),
                Pressure::from_pascals(conditions.pressure_pa()),
                entropyk_core::Enthalpy::from_joules_per_kg(h),
            )
            .map_err(|e| {
                ComponentError::CalculationFailed(format!(
                    "Failed to compute hot inlet state: {}",
                    e
                ))
            })
    }

    /// Computes the full thermodynamic state at the cold inlet.
    pub fn cold_inlet_state(&self) -> Result<ThermoState, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("No FluidBackend configured".to_string())
        })?;
        let conditions = self.cold_conditions.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("Cold conditions not set".to_string())
        })?;
        let h = self.query_enthalpy(conditions)?;
        backend
            .full_state(
                conditions.fluid_id().clone(),
                Pressure::from_pascals(conditions.pressure_pa()),
                entropyk_core::Enthalpy::from_joules_per_kg(h),
            )
            .map_err(|e| {
                ComponentError::CalculationFailed(format!(
                    "Failed to compute cold inlet state: {}",
                    e
                ))
            })
    }

    /// Queries Cp (J/(kg·K)) from the backend for a given side.
    #[allow(dead_code)]
    fn query_cp(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
        if let Some(backend) = &self.fluid_backend {
            let state = entropyk_fluids::FluidState::from_pt(
                Pressure::from_pascals(conditions.pressure_pa()),
                Temperature::from_kelvin(conditions.temperature_k()),
            );
            backend
                .property(conditions.fluid_id().clone(), Property::Cp, state) // Need to clone FluidId because trait signature requires it for now? Actually FluidId can be cloned cheaply depending on its implementation. We'll leave the clone if required by `property()`. Let's assume it is.
                .map_err(|e| {
                    ComponentError::CalculationFailed(format!(
                        "FluidBackend Cp query failed: {}",
                        e
                    ))
                })
        } else {
            Err(ComponentError::CalculationFailed(
                "No FluidBackend configured".to_string(),
            ))
        }
    }

    /// Queries specific enthalpy (J/kg) from the backend for a given side at (P, T).
    fn query_enthalpy(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
        if let Some(backend) = &self.fluid_backend {
            let state = entropyk_fluids::FluidState::from_pt(
                Pressure::from_pascals(conditions.pressure_pa()),
                Temperature::from_kelvin(conditions.temperature_k()),
            );
            backend
                .property(conditions.fluid_id().clone(), Property::Enthalpy, state)
                .map_err(|e| {
                    ComponentError::CalculationFailed(format!(
                        "FluidBackend Enthalpy query failed: {}",
                        e
                    ))
                })
        } else {
            Err(ComponentError::CalculationFailed(
                "No FluidBackend configured".to_string(),
            ))
        }
    }

    /// Sets the circuit identifier and returns self.
    pub fn with_circuit_id(mut self, circuit_id: CircuitId) -> Self {
        self.circuit_id = circuit_id;
        self
    }

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

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

    /// Returns the nominal (base) UA value [W/K] before any scaling.
    pub fn ua_nominal(&self) -> f64 {
        self.model.ua()
    }

    /// Sets the UA scale factor directly (UA_eff = scale × UA_nominal).
    ///
    /// Used by `MchxCondenserCoil` to apply fan-speed and air-density corrections
    /// without rebuilding the component.
    pub fn set_ua_scale(&mut self, scale: f64) {
        self.model.set_ua_scale(scale.max(0.0));
    }

    /// Returns the current operational state.
    pub fn operational_state(&self) -> OperationalState {
        self.operational_state
    }

    /// Sets the operational state.
    pub fn set_operational_state(&mut self, state: OperationalState) {
        self.operational_state = state;
    }

    /// Returns the circuit identifier.
    pub fn circuit_id(&self) -> &CircuitId {
        &self.circuit_id
    }

    /// Sets the circuit identifier.
    pub fn set_circuit_id(&mut self, circuit_id: CircuitId) {
        self.circuit_id = circuit_id;
    }

    /// Returns calibration factors (f_dp for refrigerant-side ΔP when modeled, f_ua for UA).
    pub fn calib(&self) -> &Calib {
        &self.calib
    }

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

    // ── 4-port (Modelica-style) configuration ───────────────────────────────

    /// Declares the hot-side fluid for edge-driven 4-port mode ("Water", "Air",
    /// "INCOMP::MEG-30"…). When hot-side edges are wired (ports 0 and 1), the
    /// HX reads T and cp from the live edge state via the backend.
    pub fn with_hot_fluid(mut self, fluid: impl Into<String>) -> Self {
        self.hot_fluid_id_str = fluid.into();
        self
    }

    /// Declares the cold-side fluid for edge-driven 4-port mode.
    pub fn with_cold_fluid(mut self, fluid: impl Into<String>) -> Self {
        self.cold_fluid_id_str = fluid.into();
        self
    }

    /// Sets the hot-side fluid identifier (see [`with_hot_fluid`]).
    pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
        self.hot_fluid_id_str = fluid.into();
    }

    /// Sets the cold-side fluid identifier.
    pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
        self.cold_fluid_id_str = fluid.into();
    }

    /// Sets the humidity ratio for the hot side (moist air).
    pub fn set_hot_humidity_ratio(&mut self, w: f64) {
        self.hot_humidity_ratio = w.max(0.0);
    }

    /// Sets the humidity ratio for the cold side (moist air).
    pub fn set_cold_humidity_ratio(&mut self, w: f64) {
        self.cold_humidity_ratio = w.max(0.0);
    }

    /// `true` when all 4 edges are wired (Modelica-style 4-port mode).
    pub(crate) fn edges_ready(&self) -> bool {
        self.hot_in_idx.is_some()
            && self.hot_out_idx.is_some()
            && self.cold_in_idx.is_some()
            && self.cold_out_idx.is_some()
            && !self.hot_fluid_id_str.is_empty()
            && !self.cold_fluid_id_str.is_empty()
    }

    /// Live four-port edge index triples `(ṁ, P, h)` when wired.
    pub(crate) fn four_port_edges(&self) -> Option<FourPortEdgeIndices> {
        if !self.edges_ready() {
            return None;
        }
        Some(FourPortEdgeIndices {
            hot_in: self.hot_in_idx?,
            hot_out: self.hot_out_idx?,
            cold_in: self.cold_in_idx?,
            cold_out: self.cold_out_idx?,
        })
    }

    /// Thermal-model equation count (excludes the two pressure-closure rows).
    pub(crate) fn model_n_equations(&self) -> usize {
        self.model.n_equations()
    }

    /// Calibration state indices (e.g. free `z_dp` actuator).
    pub(crate) fn calib_indices_ref(&self) -> &entropyk_core::CalibIndices {
        &self.calib_indices
    }

    /// Hot-side fluid id used for live property queries.
    pub(crate) fn hot_fluid_id_str(&self) -> &str {
        &self.hot_fluid_id_str
    }

    /// Cold-side fluid id used for live property queries.
    pub(crate) fn cold_fluid_id_str(&self) -> &str {
        &self.cold_fluid_id_str
    }

    /// Inlet density [kg/m³] from the attached fluid backend at `(P, h)`.
    pub(crate) fn side_density(
        &self,
        side: &str,
        fluid_id: &str,
        p_pa: f64,
        h_jkg: f64,
    ) -> Result<f64, ComponentError> {
        self.query_live_property(side, fluid_id, Property::Density, p_pa, h_jkg)
            .and_then(|rho| {
                if rho.is_finite() && rho > 1e-10 {
                    Ok(rho)
                } else {
                    Err(ComponentError::DomainViolation(DomainViolation {
                        component: Some(self.name.clone()),
                        detail: format!("{} {}-side density is invalid: {}", self.name, side, rho),
                    }))
                }
            })
    }

    /// Hot-side temperature [K] at `(P, h)` (live backend).
    pub(crate) fn hot_side_temperature(
        &self,
        p_pa: f64,
        h_jkg: f64,
    ) -> Result<f64, ComponentError> {
        self.hot_temperature(p_pa, h_jkg)
    }

    /// Hot-side \(c_p\) [J/(kg·K)] at `(P, h)`.
    pub(crate) fn hot_side_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        self.hot_cp(p_pa, h_jkg)
    }

    /// Cold-side temperature [K] at `(P, h)`.
    pub(crate) fn cold_side_temperature(
        &self,
        p_pa: f64,
        h_jkg: f64,
    ) -> Result<f64, ComponentError> {
        self.cold_temperature(p_pa, h_jkg)
    }

    /// Cold-side \(c_p\) [J/(kg·K)] at `(P, h)`.
    pub(crate) fn cold_side_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        self.cold_cp(p_pa, h_jkg)
    }

    fn live_state_required_error(&self) -> ComponentError {
        ComponentError::InvalidState(format!(
            "{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
            self.name
        ))
    }

    pub(crate) fn live_fluid_states(
        &self,
        state: &StateSlice,
    ) -> Result<(FluidState, FluidState, FluidState, FluidState), ComponentError> {
        if !self.edges_ready() {
            return Err(self.live_state_required_error());
        }

        let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
        let (m_h_out, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
        let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
        let (m_c_out, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
        let max_idx = [
            m_h, p_h_in, h_h_in, m_h_out, p_h_out, h_h_out, m_c, p_c_in, h_c_in, m_c_out, p_c_out,
            h_c_out,
        ]
        .into_iter()
        .max()
        .unwrap_or(0);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }

        let hot_cp_in = self.hot_cp(state[p_h_in], state[h_h_in])?;
        let hot_cp_out = self.hot_cp(state[p_h_out], state[h_h_out])?;
        let cold_cp_in = self.cold_cp(state[p_c_in], state[h_c_in])?;
        let cold_cp_out = self.cold_cp(state[p_c_out], state[h_c_out])?;

        let hot_t_in = self.hot_temperature(state[p_h_in], state[h_h_in])?;
        let hot_t_out = self.hot_temperature(state[p_h_out], state[h_h_out])?;
        let cold_t_in = self.cold_temperature(state[p_c_in], state[h_c_in])?;
        let cold_t_out = self.cold_temperature(state[p_c_out], state[h_c_out])?;

        let m_hot = state[m_h].max(0.0);
        let m_cold = state[m_c].max(0.0);

        Ok((
            Self::create_fluid_state(hot_t_in, state[p_h_in], state[h_h_in], m_hot, hot_cp_in),
            Self::create_fluid_state(hot_t_out, state[p_h_out], state[h_h_out], m_hot, hot_cp_out),
            Self::create_fluid_state(cold_t_in, state[p_c_in], state[h_c_in], m_cold, cold_cp_in),
            Self::create_fluid_state(
                cold_t_out,
                state[p_c_out],
                state[h_c_out],
                m_cold,
                cold_cp_out,
            ),
        ))
    }

    /// `true` when the hot-side fluid follows the moist-air convention.
    fn hot_is_air(&self) -> bool {
        let f = self.hot_fluid_id_str.trim();
        f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
    }

    /// `true` when the cold-side fluid follows the moist-air convention.
    fn cold_is_air(&self) -> bool {
        let f = self.cold_fluid_id_str.trim();
        f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
    }

    /// Hot-side cp [J/(kg·K)] at (P, h). Moist air uses the psychrometric cp;
    /// other fluids query the backend.
    fn hot_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        if self.hot_is_air() {
            return Ok(1006.0 + 1860.0 * self.hot_humidity_ratio);
        }
        self.query_live_property("hot", &self.hot_fluid_id_str, Property::Cp, p_pa, h_jkg)
            .and_then(|cp| {
                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`).
                    Err(ComponentError::DomainViolation(DomainViolation {
                        component: Some(self.name.clone()),
                        detail: format!("{} hot-side Cp is invalid: {}", self.name, cp),
                    }))
                }
            })
    }

    /// Cold-side cp [J/(kg·K)] at (P, h).
    fn cold_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        if self.cold_is_air() {
            return Ok(1006.0 + 1860.0 * self.cold_humidity_ratio);
        }
        self.query_live_property("cold", &self.cold_fluid_id_str, Property::Cp, p_pa, h_jkg)
            .and_then(|cp| {
                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`).
                    Err(ComponentError::DomainViolation(DomainViolation {
                        component: Some(self.name.clone()),
                        detail: format!("{} cold-side Cp is invalid: {}", self.name, cp),
                    }))
                }
            })
    }

    /// Hot-side temperature [K] at (P, h). Moist air uses the linear psychrometric
    /// inversion; other fluids query the backend T(P,h).
    fn hot_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        if self.hot_is_air() {
            let w = self.hot_humidity_ratio;
            let cp = 1006.0 + 1860.0 * w;
            return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
        }
        self.query_live_property(
            "hot",
            &self.hot_fluid_id_str,
            Property::Temperature,
            p_pa,
            h_jkg,
        )
        .and_then(|t| {
            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: Some(self.name.clone()),
                    detail: format!("{} hot-side temperature is invalid: {}", self.name, t),
                }))
            }
        })
    }

    /// Cold-side temperature [K] at (P, h).
    fn cold_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        if self.cold_is_air() {
            let w = self.cold_humidity_ratio;
            let cp = 1006.0 + 1860.0 * w;
            return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
        }
        self.query_live_property(
            "cold",
            &self.cold_fluid_id_str,
            Property::Temperature,
            p_pa,
            h_jkg,
        )
        .and_then(|t| {
            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: Some(self.name.clone()),
                    detail: format!("{} cold-side temperature is invalid: {}", self.name, t),
                }))
            }
        })
    }

    fn query_live_property(
        &self,
        side: &str,
        fluid_id: &str,
        property: Property,
        p_pa: f64,
        h_jkg: f64,
    ) -> Result<f64, ComponentError> {
        if !p_pa.is_finite() || p_pa <= 0.0 {
            return Err(ComponentError::DomainViolation(DomainViolation {
                component: Some(self.name.clone()),
                detail: format!(
                    "{} {} side has invalid pressure: {} Pa",
                    self.name, side, p_pa
                ),
            }));
        }
        if !h_jkg.is_finite() {
            return Err(ComponentError::DomainViolation(DomainViolation {
                component: Some(self.name.clone()),
                detail: format!(
                    "{} {} side has invalid enthalpy: {} J/kg",
                    self.name, side, h_jkg
                ),
            }));
        }
        // Clamp wild Newton trial enthalpies into a broad physical envelope so
        // CoolProp is not queried with absurd (P,h) that yield T=inf. Exact
        // result is preserved for states already inside the clamp.
        const H_MIN_JKG: f64 = -5.0e5;
        const H_MAX_JKG: f64 = 3.0e6;
        let h_query = h_jkg.clamp(H_MIN_JKG, H_MAX_JKG);
        let p_query = p_pa.clamp(1.0e3, 5.0e7);
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::InvalidState(format!(
                "{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed",
                self.name, side, fluid_id
            ))
        })?;
        backend
            .property(
                FluidsFluidId::new(fluid_id),
                property,
                entropyk_fluids::FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_query),
                    entropyk_core::Enthalpy::from_joules_per_kg(h_query),
                ),
            )
            .map_err(|e| {
                // Off-envelope CoolProp failures during Newton are recoverable.
                ComponentError::DomainViolation(DomainViolation {
                    component: Some(self.name.clone()),
                    detail: format!(
                        "{} failed to evaluate {:?} for {} side fluid '{}': {}",
                        self.name, property, side, fluid_id, e
                    ),
                })
            })
    }

    /// Creates a fluid state from temperature, pressure, enthalpy, mass flow, and Cp.
    fn create_fluid_state(
        temperature: f64,
        pressure: f64,
        enthalpy: f64,
        mass_flow: f64,
        cp: f64,
    ) -> FluidState {
        FluidState::new(temperature, pressure, enthalpy, mass_flow, cp)
    }

    /// Documentation pending
    pub fn compute_residuals_with_ua_scale(
        &self,
        _state: &StateSlice,
        residuals: &mut ResidualVector,
        custom_ua_scale: f64,
    ) -> Result<(), ComponentError> {
        self.do_compute_residuals(_state, residuals, Some(custom_ua_scale))
    }

    /// Documentation pending
    pub fn do_compute_residuals(
        &self,
        _state: &StateSlice,
        residuals: &mut ResidualVector,
        custom_ua_scale: Option<f64>,
    ) -> Result<(), ComponentError> {
        if residuals.len() < self.n_equations() {
            return Err(ComponentError::InvalidResidualDimensions {
                expected: self.n_equations(),
                actual: residuals.len(),
            });
        }

        match self.operational_state {
            OperationalState::Off | OperationalState::Bypass => {
                // Q = 0 in both modes (OFF: no flow; BYPASS: adiabatic pass-through).
                // Thermal residuals are trivially satisfied; pressure closures stay
                // active so the P rows never go singular.
                let n_model = self.model.n_equations();
                for r in residuals.iter_mut().take(n_model) {
                    *r = 0.0;
                }
                self.append_pressure_closures(_state, residuals, n_model)?;
                return Ok(());
            }
            OperationalState::On => {
                // Normal operation - continue with heat transfer model
            }
        }

        let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) = self.live_fluid_states(_state)?;

        let dynamic_f_ua =
            custom_ua_scale.or_else(|| self.calib_indices.z_ua.map(|idx| _state[idx]));

        self.model.compute_residuals(
            &hot_inlet,
            &hot_outlet,
            &cold_inlet,
            &cold_outlet,
            residuals,
            dynamic_f_ua,
        );

        self.append_pressure_closures(_state, residuals, self.model.n_equations())?;

        Ok(())
    }

    /// Writes the per-stream isobaric pressure closures in 4-port mode:
    /// `P_hot_out − P_hot_in = 0` and `P_cold_out − P_cold_in = 0`.
    ///
    /// These rows make the Modelica `MassFlowSource_T` (Free P) + `Boundary_pT`
    /// sink pattern square: the sink anchors pressure and the exchanger
    /// propagates it to the source edge (same convention as `Condenser` /
    /// `Evaporator` secondary sides). No-op outside 4-port mode.
    fn append_pressure_closures(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
        row_start: usize,
    ) -> Result<(), ComponentError> {
        if !self.edges_ready() {
            return Ok(());
        }
        let (_, p_h_in, _) = self.hot_in_idx.unwrap();
        let (_, p_h_out, _) = self.hot_out_idx.unwrap();
        let (_, p_c_in, _) = self.cold_in_idx.unwrap();
        let (_, p_c_out, _) = self.cold_out_idx.unwrap();
        let max_idx = [p_h_in, p_h_out, p_c_in, p_c_out]
            .into_iter()
            .max()
            .unwrap_or(0);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }
        if residuals.len() < row_start + 2 {
            return Err(ComponentError::InvalidResidualDimensions {
                expected: row_start + 2,
                actual: residuals.len(),
            });
        }
        residuals[row_start] = state[p_h_out] - state[p_h_in];
        residuals[row_start + 1] = state[p_c_out] - state[p_c_in];
        Ok(())
    }
}

impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
    fn compute_residuals(
        &self,
        _state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        self.do_compute_residuals(_state, residuals, None)
    }

    fn jacobian_entries(
        &self,
        _state: &StateSlice,
        _jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        // 4-port mode: numerical Jacobian via finite differences. Perturb each
        // relevant state variable, recompute residuals, take the difference.
        if self.edges_ready() {
            let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
            let (_, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
            let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
            let (_, p_c_out, h_c_out) = self.cold_out_idx.unwrap();

            let cols = [
                m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
            ];
            let unique_cols: Vec<usize> = {
                let mut s: Vec<usize> =
                    cols.iter().copied().filter(|c| *c < _state.len()).collect();
                s.sort_unstable();
                s.dedup();
                s
            };

            let compute_res = |s: &[f64]| -> [f64; 2] {
                let mut r = vec![0.0_f64; self.n_equations()];
                let _ = self.do_compute_residuals(s, &mut r, None);
                [r[0], r[1]]
            };

            for &col in &unique_cols {
                let h = (_state[col].abs() * 1e-6).max(1e-3);
                let mut sp = _state.to_vec();
                sp[col] += h;
                let rp = compute_res(&sp);
                let mut sm = _state.to_vec();
                sm[col] -= h;
                let rm = compute_res(&sm);
                for row in 0..2 {
                    let fd = (rp[row] - rm[row]) / (2.0 * h);
                    if fd.abs() > 1e-15 {
                        _jacobian.add_entry(row, col, fd);
                    }
                }
            }

            // Analytic entries for the isobaric pressure closures (rows after
            // the thermal model rows): r = P_out − P_in per stream.
            let row0 = self.model.n_equations();
            _jacobian.add_entry(row0, p_h_out, 1.0);
            _jacobian.add_entry(row0, p_h_in, -1.0);
            _jacobian.add_entry(row0 + 1, p_c_out, 1.0);
            _jacobian.add_entry(row0 + 1, p_c_in, -1.0);
            return Ok(());
        }

        Ok(())
    }

    fn n_equations(&self) -> usize {
        // 4-port mode adds the two per-stream isobaric pressure closures so the
        // Modelica Free-P source + Fixed-P sink boundary pattern stays square.
        if self.edges_ready() {
            self.model.n_equations() + 2
        } else {
            self.model.n_equations()
        }
    }

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

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

    fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
        if let Some(Some(triple)) = port_edges.first() {
            self.hot_in_idx = Some(*triple);
        }
        if let Some(Some(triple)) = port_edges.get(1) {
            self.hot_out_idx = Some(*triple);
        }
        if let Some(Some(triple)) = port_edges.get(2) {
            self.cold_in_idx = Some(*triple);
        }
        if let Some(Some(triple)) = port_edges.get(3) {
            self.cold_out_idx = Some(*triple);
        }
    }

    fn port_names(&self) -> Vec<String> {
        vec![
            "hot_inlet".to_string(),
            "hot_outlet".to_string(),
            "cold_inlet".to_string(),
            "cold_outlet".to_string(),
        ]
    }

    fn flow_paths(&self) -> Vec<(usize, usize)> {
        vec![(0, 1), (2, 3)]
    }

    fn port_mass_flows(
        &self,
        state: &StateSlice,
    ) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
        if !self.edges_ready() {
            return Err(self.live_state_required_error());
        }
        let (m_h_in, _, _) = self.hot_in_idx.unwrap();
        let (m_h_out, _, _) = self.hot_out_idx.unwrap();
        let (m_c_in, _, _) = self.cold_in_idx.unwrap();
        let (m_c_out, _, _) = self.cold_out_idx.unwrap();
        let max_idx = [m_h_in, m_h_out, m_c_in, m_c_out]
            .into_iter()
            .max()
            .unwrap_or(0);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }
        Ok(vec![
            entropyk_core::MassFlow::from_kg_per_s(state[m_h_in]),
            entropyk_core::MassFlow::from_kg_per_s(-state[m_h_out]),
            entropyk_core::MassFlow::from_kg_per_s(state[m_c_in]),
            entropyk_core::MassFlow::from_kg_per_s(-state[m_c_out]),
        ])
    }

    fn port_enthalpies(
        &self,
        state: &StateSlice,
    ) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
        if !self.edges_ready() {
            return Err(self.live_state_required_error());
        }
        let (_, _, h_h_in) = self.hot_in_idx.unwrap();
        let (_, _, h_h_out) = self.hot_out_idx.unwrap();
        let (_, _, h_c_in) = self.cold_in_idx.unwrap();
        let (_, _, h_c_out) = self.cold_out_idx.unwrap();
        let max_idx = [h_h_in, h_h_out, h_c_in, h_c_out]
            .into_iter()
            .max()
            .unwrap_or(0);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }
        Ok(vec![
            entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_in]),
            entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_out]),
            entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_in]),
            entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_out]),
        ])
    }

    fn energy_transfers(
        &self,
        _state: &StateSlice,
    ) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
        match self.operational_state {
            OperationalState::Off | OperationalState::Bypass | OperationalState::On => {
                // Internal heat exchange between tracked streams; adiabatic to macro-environment
                Some((
                    entropyk_core::Power::from_watts(0.0),
                    entropyk_core::Power::from_watts(0.0),
                ))
            }
        }
    }

    fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
        match kind {
            crate::MeasuredOutput::Capacity | crate::MeasuredOutput::HeatTransferRate => {
                if !self.edges_ready() {
                    return None;
                }
                let (m_h, _, h_h_in) = self.hot_in_idx?;
                let (_, _, h_h_out) = self.hot_out_idx?;
                let (m_c, _, h_c_in) = self.cold_in_idx?;
                let (_, _, h_c_out) = self.cold_out_idx?;
                let max_idx = [m_h, h_h_in, h_h_out, m_c, h_c_in, h_c_out]
                    .into_iter()
                    .max()?;
                if max_idx >= state.len() {
                    return None;
                }

                let q_hot_w = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
                let q_cold_w = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
                if q_hot_w.is_finite() && q_cold_w.is_finite() {
                    Some(0.5 * (q_hot_w + q_cold_w))
                } else if q_hot_w.is_finite() {
                    Some(q_hot_w)
                } else if q_cold_w.is_finite() {
                    Some(q_cold_w)
                } else {
                    None
                }
            }
            _ => 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!("{}(circuit={})", self.name, self.circuit_id.0)
    }

    fn to_params(&self) -> crate::ComponentParams {
        crate::ComponentParams::new(&self.name)
            .with_param("circuitId", self.circuit_id.0)
            .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 {
        let mut c = self.calib().clone();
        if c.set_factor(factor, value) {
            self.set_calib(c);
            true
        } else {
            false
        }
    }
}

impl<Model: HeatTransferModel + 'static> StateManageable for HeatExchanger<Model> {
    fn state(&self) -> OperationalState {
        self.operational_state
    }

    fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
        if self.operational_state.can_transition_to(state) {
            let from = self.operational_state;
            self.operational_state = state;
            self.on_state_change(from, state);
            Ok(())
        } else {
            Err(ComponentError::InvalidStateTransition {
                from: self.operational_state,
                to: state,
                reason: "Transition not allowed".to_string(),
            })
        }
    }

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

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

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::heat_exchanger::{FlowConfiguration, LmtdModel};
    use crate::state_machine::StateManageable;

    fn live_air_state(t_k: f64) -> f64 {
        1006.0 * (t_k - 273.15)
    }

    #[test]
    fn test_heat_exchanger_creation() {
        let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
        let hx = HeatExchanger::new(model, "TestHX");

        assert_eq!(hx.name(), "TestHX");
        assert_eq!(hx.ua(), 5000.0);
        assert_eq!(hx.operational_state(), OperationalState::On);
    }

    #[test]
    fn test_n_equations() {
        let model = LmtdModel::counter_flow(1000.0);
        let hx = HeatExchanger::new(model, "Test");

        assert_eq!(hx.n_equations(), 2);
    }

    #[test]
    fn test_compute_residuals() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test");

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

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

    #[test]
    fn test_live_four_port_residuals_compute_from_state() {
        let model = LmtdModel::counter_flow(5000.0);
        let mut hx = HeatExchanger::new(model, "Test")
            .with_hot_fluid("Air")
            .with_cold_fluid("Air");
        hx.set_port_context(&[
            Some((0, 1, 2)),
            Some((0, 3, 4)),
            Some((5, 6, 7)),
            Some((5, 8, 9)),
        ]);

        let state = vec![
            0.5,
            101_325.0,
            live_air_state(350.0),
            101_325.0,
            live_air_state(330.0),
            0.8,
            101_325.0,
            live_air_state(290.0),
            101_325.0,
            live_air_state(300.0),
        ];
        let mut residuals = vec![0.0; hx.n_equations()];

        hx.compute_residuals(&state, &mut residuals).unwrap();
        assert!(residuals.iter().all(|r| r.is_finite()));
        assert!(residuals.iter().any(|r| r.abs() > 1e-9));
        assert_eq!(
            hx.port_enthalpies(&state)
                .unwrap()
                .iter()
                .map(|h| h.to_joules_per_kg())
                .collect::<Vec<_>>(),
            vec![
                live_air_state(350.0),
                live_air_state(330.0),
                live_air_state(290.0),
                live_air_state(300.0)
            ]
        );
    }

    #[test]
    fn test_four_port_metadata_is_name_based() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test");

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

    #[test]
    fn test_residual_dimension_error() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test");

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

        let result = hx.compute_residuals(&state, &mut residuals);
        assert!(result.is_err());
    }

    #[test]
    fn test_builder() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchangerBuilder::new(model)
            .name("Condenser")
            .circuit_id(CircuitId::from_number(5))
            .build();

        assert_eq!(hx.name(), "Condenser");
        assert_eq!(hx.circuit_id().as_number(), 5);
    }

    #[test]
    fn test_state_manageable_state() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test");

        assert_eq!(hx.state(), OperationalState::On);
    }

    #[test]
    fn test_state_manageable_set_state() {
        let model = LmtdModel::counter_flow(5000.0);
        let mut hx = HeatExchanger::new(model, "Test");

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

    #[test]
    fn test_state_manageable_can_transition_to() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test");

        assert!(hx.can_transition_to(OperationalState::Off));
        assert!(hx.can_transition_to(OperationalState::Bypass));
    }

    #[test]
    fn test_state_manageable_circuit_id() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test");

        assert_eq!(*hx.circuit_id(), CircuitId::ZERO);
    }

    #[test]
    fn test_state_manageable_set_circuit_id() {
        let model = LmtdModel::counter_flow(5000.0);
        let mut hx = HeatExchanger::new(model, "Test");

        hx.set_circuit_id(CircuitId::from_number(2));
        assert_eq!(hx.circuit_id().as_number(), 2);
    }

    #[test]
    fn test_off_mode_residuals() {
        let model = LmtdModel::counter_flow(5000.0);
        let mut hx = HeatExchanger::new(model, "Test");
        hx.set_operational_state(OperationalState::Off);

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

        let result = hx.compute_residuals(&state, &mut residuals);
        assert!(result.is_ok());

        // In OFF mode, all residuals should be zero
        assert_eq!(residuals[0], 0.0);
        assert_eq!(residuals[1], 0.0);
        assert_eq!(residuals[2], 0.0);
    }

    #[test]
    fn test_bypass_mode_residuals() {
        let model = LmtdModel::counter_flow(5000.0);
        let mut hx = HeatExchanger::new(model, "Test");
        hx.set_operational_state(OperationalState::Bypass);

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

        let result = hx.compute_residuals(&state, &mut residuals);
        assert!(result.is_ok());

        // In BYPASS mode, all residuals should be zero (no heat transfer)
        assert_eq!(residuals[0], 0.0);
        assert_eq!(residuals[1], 0.0);
        assert_eq!(residuals[2], 0.0);
    }

    #[test]
    fn test_circuit_id_via_builder() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchangerBuilder::new(model)
            .circuit_id(CircuitId::from_number(1))
            .build();

        assert_eq!(hx.circuit_id().as_number(), 1);
    }

    #[test]
    fn test_with_circuit_id() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test").with_circuit_id(CircuitId::from_number(3));

        assert_eq!(hx.circuit_id().as_number(), 3);
    }

    // ===== Story 5.1: FluidBackend Integration Tests =====

    #[test]
    fn test_no_fluid_backend_by_default() {
        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test");
        assert!(!hx.has_fluid_backend());
    }

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

        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Test").with_fluid_backend(Arc::new(TestBackend::new()));

        assert!(hx.has_fluid_backend());
    }

    #[test]
    fn test_hx_side_conditions_construction() {
        use entropyk_core::{MassFlow, Pressure, Temperature};

        let conds = HxSideConditions::new(
            Temperature::from_celsius(60.0),
            Pressure::from_bar(25.0),
            MassFlow::from_kg_per_s(0.05),
            "R410A",
        )
        .expect("Valid conditions should not fail");

        assert!((conds.temperature_k() - 333.15).abs() < 0.01);
        assert!((conds.pressure_pa() - 25.0e5).abs() < 1.0);
        assert!((conds.mass_flow_kg_s() - 0.05).abs() < 1e-10);
        assert_eq!(conds.fluid_id().0, "R410A");
    }

    #[test]
    fn test_boundary_conditions_without_outlet_state_error() {
        use entropyk_core::{MassFlow, Pressure, Temperature};
        use entropyk_fluids::TestBackend;
        use std::sync::Arc;

        let model = LmtdModel::counter_flow(5000.0);
        let hx = HeatExchanger::new(model, "Condenser")
            .with_fluid_backend(Arc::new(TestBackend::new()))
            .with_hot_conditions(
                HxSideConditions::new(
                    Temperature::from_celsius(60.0),
                    Pressure::from_bar(20.0),
                    MassFlow::from_kg_per_s(0.05),
                    "R410A",
                )
                .expect("Valid hot conditions"),
            )
            .with_cold_conditions(
                HxSideConditions::new(
                    Temperature::from_celsius(30.0),
                    Pressure::from_pascals(102_000.0),
                    MassFlow::from_kg_per_s(0.2),
                    "Water",
                )
                .expect("Valid cold conditions"),
            );

        let state = vec![0.0f64; 10];
        let mut residuals = vec![0.0f64; 3];
        let result = hx.compute_residuals(&state, &mut residuals);
        assert!(
            matches!(result, Err(ComponentError::InvalidState(_))),
            "inlet-only boundary conditions must not fabricate outlet states"
        );
    }

    #[test]
    fn test_unwired_hx_never_returns_dummy_finite_residuals() {
        use entropyk_core::{MassFlow, Pressure, Temperature};
        use entropyk_fluids::TestBackend;
        use std::sync::Arc;

        let model1 = LmtdModel::counter_flow(5000.0);
        let hx_no_backend = HeatExchanger::new(model1, "HX_nobackend");

        let state = vec![0.0f64; 10];
        let mut residuals_no_backend = vec![0.0f64; 3];
        assert!(matches!(
            hx_no_backend.compute_residuals(&state, &mut residuals_no_backend),
            Err(ComponentError::InvalidState(_))
        ));

        let model2 = LmtdModel::counter_flow(5000.0);
        let hx_with_backend = HeatExchanger::new(model2, "HX_with_backend")
            .with_fluid_backend(Arc::new(TestBackend::new()))
            .with_hot_conditions(
                HxSideConditions::new(
                    Temperature::from_celsius(60.0),
                    Pressure::from_bar(20.0),
                    MassFlow::from_kg_per_s(0.05),
                    "R410A",
                )
                .expect("Valid hot conditions"),
            )
            .with_cold_conditions(
                HxSideConditions::new(
                    Temperature::from_celsius(30.0),
                    Pressure::from_pascals(102_000.0),
                    MassFlow::from_kg_per_s(0.2),
                    "Water",
                )
                .expect("Valid cold conditions"),
            );

        let mut residuals_with_backend = vec![0.0f64; 3];
        assert!(matches!(
            hx_with_backend.compute_residuals(&state, &mut residuals_with_backend),
            Err(ComponentError::InvalidState(_))
        ));
    }

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

        let model = LmtdModel::counter_flow(5000.0);
        let mut hx = HeatExchanger::new(model, "Test");

        assert!(!hx.has_fluid_backend());
        hx.set_fluid_backend(Arc::new(TestBackend::new()));
        assert!(hx.has_fluid_backend());
    }
}
