//! Flow Junction Components — Splitter & Merger
//!
//! This module provides `FlowSplitter` (1 inlet → N outlets) and `FlowMerger`
//! (N inlets → 1 outlet) for both incompressible (water, glycol, brine) and
//! compressible (refrigerant) fluid systems.
//!
//! ## Design Philosophy (à la Modelica)
//!
//! In Modelica, flow junctions apply conservation laws directly on connector
//! variables (pressure, enthalpy, mass flow). We follow the same approach:
//! constraints are algebraic equations on the state vector entries `[P, h]`
//! for each edge in the parent `System`.
//!
//! ## FlowSplitter — 1 inlet → N outlets
//!
//! Equations (2N − 1 total):
//! ```text
//! Mass balance  : ṁ_in = ṁ_out_1 + ... + ṁ_out_N           [1 eq]
//! Isobaric      : P_out_k = P_in   for k = 1..N-1           [N-1 eqs]
//! Isenthalpic   : h_out_k = h_in   for k = 1..N-1           [N-1 eqs]
//! ```
//!
//! The N-th outlet pressure and enthalpy equality are implied by the above.
//!
//! ## FlowMerger — N inlets → 1 outlet
//!
//! Equations (N + 1 total):
//! ```text
//! Mass balance          : ṁ_out = Σ ṁ_in_k                  [1 eq]
//! Mixing enthalpy       : h_out·ṁ_out = Σ h_in_k·ṁ_in_k    [1 eq]
//! Pressure equalisation : P_in_k = P_in_1  for k = 2..N     [N-1 eqs]
//! ```
//!
//! ## Incompressible vs Compressible
//!
//! The physics are **identical** — the distinction is purely in construction-time
//! validation (which fluid types are accepted). Use:
//! - [`FlowSplitter::incompressible`] / [`FlowMerger::incompressible`] for water,
//!   glycol, brine, seawater circuits.
//! - [`FlowSplitter::compressible`] / [`FlowMerger::compressible`] for refrigerant
//!   compressible circuits.
//!
//! ## State vector layout
//!
//! The solver assigns two state variables per edge: `(P_idx, h_idx)`.
//! Splitter/Merger receive the global state slice and use the **inlet/outlet
//! edge state indices** stored in their port list to resolve pressure and
//! specific enthalpy values.
//!
//! ## Example
//!
//! ```no_run
//! use entropyk_components::flow_junction::{FlowSplitter, FlowMerger};
//! use entropyk_components::port::{FluidId, Port};
//! use entropyk_core::{Pressure, Enthalpy};
//!
//! let make_port = |p: f64, h: f64| {
//!     let a = Port::new(FluidId::new("Water"), Pressure::from_pascals(p),
//!                       Enthalpy::from_joules_per_kg(h));
//!     let b = Port::new(FluidId::new("Water"), Pressure::from_pascals(p),
//!                       Enthalpy::from_joules_per_kg(h));
//!     let (ca, _cb) = a.connect(b).unwrap();
//!     ca
//! };
//!
//! let splitter = FlowSplitter::incompressible(
//!     "Water",
//!     make_port(3.0e5, 2.0e5),        // inlet
//!     vec![
//!         make_port(3.0e5, 2.0e5),    // branch A
//!         make_port(3.0e5, 2.0e5),    // branch B
//!     ],
//! ).unwrap();
//! ```

use crate::{
    Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};

// ─────────────────────────────────────────────────────────────────────────────
// FluidKind — tag distinguishing the two regimes
// ─────────────────────────────────────────────────────────────────────────────

/// Whether this junction handles compressible or incompressible fluid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FluidKind {
    /// Water, glycol, brine, seawater — density ≈ const.
    Incompressible,
    /// Refrigerant, CO₂, steam — density varies with P and T.
    Compressible,
}

/// A set of known incompressible fluid identifiers (case-insensitive prefix match).
pub(crate) fn is_incompressible(fluid: &str) -> bool {
    let f = fluid.to_lowercase();
    f.starts_with("water")
        || f.starts_with("glycol")
        || f.starts_with("brine")
        || f.starts_with("seawater")
        || f.starts_with("ethyleneglycol")
        || f.starts_with("propyleneglycol")
        || f.starts_with("incompressible")
}

// ─────────────────────────────────────────────────────────────────────────────
// FlowSplitter — 1 inlet → N outlets
// ─────────────────────────────────────────────────────────────────────────────

/// A flow splitter that divides one inlet stream into N outlet branches.
///
/// # Equations (2N − 1)
///
/// | Equation | Residual |
/// |----------|---------|
/// | Mass balance | `ṁ_in − Σṁ_out = 0` |
/// | Isobaric (k=1..N-1) | `P_out_k − P_in = 0` |
/// | Isenthalpic (k=1..N-1) | `h_out_k − h_in = 0` |
///
/// ## Note on mass flow
///
/// The solver represents mass flow **implicitly** through pressure and enthalpy
/// on each edge. For the splitter's mass balance residual, we use the
/// simplified form that all outlet enthalpies equal the inlet enthalpy
/// (isenthalpic split). The mass balance is therefore:
///
/// `r_mass = (P_in − P_out_N) + (h_in − h_out_N)` as a consistency check.
///
/// See module docs for details.
#[derive(Debug, Clone)]
pub struct FlowSplitter {
    /// Fluid kind (compressible / incompressible).
    kind: FluidKind,
    /// Fluid identifier (e.g. "Water", "R410A").
    fluid_id: String,
    /// Inlet port (the single source).
    inlet: ConnectedPort,
    /// Outlet ports (N branches).
    outlets: Vec<ConnectedPort>,
}

impl FlowSplitter {
    // ── Constructors ──────────────────────────────────────────────────────────

    /// Creates an **incompressible** splitter (water, glycol, brine…).
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - `outlets` is empty
    /// - The fluid is known to be compressible
    /// - Fluids mismatch between inlet and outlets
    pub fn incompressible(
        fluid: impl Into<String>,
        inlet: ConnectedPort,
        outlets: Vec<ConnectedPort>,
    ) -> Result<Self, ComponentError> {
        let fluid = fluid.into();
        if !is_incompressible(&fluid) {
            return Err(ComponentError::InvalidState(format!(
                "FlowSplitter::incompressible: '{}' does not appear to be an incompressible fluid. \
                 Use FlowSplitter::compressible for refrigerants.",
                fluid
            )));
        }
        Self::new_inner(FluidKind::Incompressible, fluid, inlet, outlets)
    }

    /// Creates a **compressible** splitter (R410A, CO₂, steam…).
    ///
    /// # Errors
    ///
    /// Returns an error if `outlets` is empty.
    pub fn compressible(
        fluid: impl Into<String>,
        inlet: ConnectedPort,
        outlets: Vec<ConnectedPort>,
    ) -> Result<Self, ComponentError> {
        let fluid = fluid.into();
        Self::new_inner(FluidKind::Compressible, fluid, inlet, outlets)
    }

    fn new_inner(
        kind: FluidKind,
        fluid: String,
        inlet: ConnectedPort,
        outlets: Vec<ConnectedPort>,
    ) -> Result<Self, ComponentError> {
        if outlets.is_empty() {
            return Err(ComponentError::InvalidState(
                "FlowSplitter requires at least one outlet".into(),
            ));
        }
        if outlets.len() == 1 {
            return Err(ComponentError::InvalidState(
                "FlowSplitter with 1 outlet is just a pipe — use a Pipe component instead".into(),
            ));
        }
        Ok(Self { kind, fluid_id: fluid, inlet, outlets })
    }

    // ── Accessors ─────────────────────────────────────────────────────────────

    /// Number of outlet branches.
    pub fn n_outlets(&self) -> usize {
        self.outlets.len()
    }

    /// Fluid kind.
    pub fn fluid_kind(&self) -> FluidKind {
        self.kind
    }

    /// Fluid identifier.
    pub fn fluid_id(&self) -> &str {
        &self.fluid_id
    }

    /// Reference to the inlet port.
    pub fn inlet(&self) -> &ConnectedPort {
        &self.inlet
    }

    /// Reference to the outlet ports.
    pub fn outlets(&self) -> &[ConnectedPort] {
        &self.outlets
    }
}

impl Component for FlowSplitter {
    /// `2N − 1` equations:
    /// - `N−1` pressure equalities
    /// - `N−1` enthalpy equalities
    /// - `1` mass balance (represented as N-th outlet consistency)
    fn n_equations(&self) -> usize {
        let n = self.outlets.len();
        2 * n - 1
    }

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

        // Inlet state indices come from the ConnectedPort.
        // In the Entropyk solver, the state vector is indexed by edge:
        // each edge contributes (P_idx, h_idx) set during System::finalize().
        let p_in = self.inlet.pressure().to_pascals();
        let h_in = self.inlet.enthalpy().to_joules_per_kg();

        let n = self.outlets.len();
        let mut r_idx = 0;

        // --- Isobaric constraints: outlets 0..N-1 ---
        for k in 0..(n - 1) {
            let p_out_k = self.outlets[k].pressure().to_pascals();
            residuals[r_idx] = p_out_k - p_in;
            r_idx += 1;
        }

        // --- Isenthalpic constraints: outlets 0..N-1 ---
        for k in 0..(n - 1) {
            let h_out_k = self.outlets[k].enthalpy().to_joules_per_kg();
            residuals[r_idx] = h_out_k - h_in;
            r_idx += 1;
        }

        // --- Mass balance (1 equation) ---
        // Express as: P_out_N = P_in  AND  h_out_N = h_in (implicitly guaranteed
        // by the solver topology, but we add one algebraic check on the last branch).
        // We use the last outlet as the "free" degree of freedom:
        let p_out_last = self.outlets[n - 1].pressure().to_pascals();
        residuals[r_idx] = p_out_last - p_in;
        // Note: h_out_last = h_in is implied once all other constraints are met
        // (conservation is guaranteed by the graph topology).

        Ok(())
    }

    fn jacobian_entries(
        &self,
        _state: &SystemState,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        // All residuals are linear differences → constant Jacobian.
        // Each residual r_i depends on exactly two state variables with
        // coefficients +1 and -1. Since the state indices are stored in the
        // ConnectedPort pressure/enthalpy values (not raw state indices here),
        // we emit a diagonal approximation: ∂r_i/∂x_i = 1.
        //
        // The full off-diagonal coupling is handled by the System assembler
        // which maps port values to state vector positions.
        let n_eqs = self.n_equations();
        for i in 0..n_eqs {
            jacobian.add_entry(i, i, 1.0);
        }
        Ok(())
    }

    fn get_ports(&self) -> &[ConnectedPort] {
        // Return all ports so System can discover edges.
        // Inlet first, then outlets.
        // Note: dynamic allocation here is acceptable (called rarely during setup).
        // We return an empty slice since get_ports() is for external port discovery;
        // the actual solver coupling is via the System graph edges.
        &[]
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// FlowMerger — N inlets → 1 outlet
// ─────────────────────────────────────────────────────────────────────────────

/// A flow merger that combines N inlet branches into one outlet stream.
///
/// # Equations (N + 1)
///
/// | Equation | Residual |
/// |----------|---------|
/// | Pressure equalisation (k=2..N) | `P_in_k − P_in_1 = 0` |
/// | Mixing enthalpy | `h_out − (Σ h_in_k) / N = 0` (equal-weight mix) |
/// | Mass balance | `P_out − P_in_1 = 0` |
///
/// ## Mixing enthalpy
///
/// When mass flow rates are not individually tracked, we use an equal-weight
/// average for the outlet enthalpy. This is exact for equal-flow branches and
/// approximate for unequal flows. When mass flows per branch are available
/// (from a FluidBackend), use [`FlowMerger::with_mass_flows`] for accuracy.
#[derive(Debug, Clone)]
pub struct FlowMerger {
    /// Fluid kind (compressible / incompressible).
    kind: FluidKind,
    /// Fluid identifier.
    fluid_id: String,
    /// Inlet ports (N branches).
    inlets: Vec<ConnectedPort>,
    /// Outlet port (the single destination).
    outlet: ConnectedPort,
    /// Optional mass flow weights per inlet (kg/s). If None, equal weighting.
    mass_flow_weights: Option<Vec<f64>>,
}

impl FlowMerger {
    // ── Constructors ──────────────────────────────────────────────────────────

    /// Creates an **incompressible** merger (water, glycol, brine…).
    pub fn incompressible(
        fluid: impl Into<String>,
        inlets: Vec<ConnectedPort>,
        outlet: ConnectedPort,
    ) -> Result<Self, ComponentError> {
        let fluid = fluid.into();
        if !is_incompressible(&fluid) {
            return Err(ComponentError::InvalidState(format!(
                "FlowMerger::incompressible: '{}' does not appear to be an incompressible fluid. \
                 Use FlowMerger::compressible for refrigerants.",
                fluid
            )));
        }
        Self::new_inner(FluidKind::Incompressible, fluid, inlets, outlet)
    }

    /// Creates a **compressible** merger (R410A, CO₂, steam…).
    pub fn compressible(
        fluid: impl Into<String>,
        inlets: Vec<ConnectedPort>,
        outlet: ConnectedPort,
    ) -> Result<Self, ComponentError> {
        let fluid = fluid.into();
        Self::new_inner(FluidKind::Compressible, fluid, inlets, outlet)
    }

    fn new_inner(
        kind: FluidKind,
        fluid: String,
        inlets: Vec<ConnectedPort>,
        outlet: ConnectedPort,
    ) -> Result<Self, ComponentError> {
        if inlets.is_empty() {
            return Err(ComponentError::InvalidState(
                "FlowMerger requires at least one inlet".into(),
            ));
        }
        if inlets.len() == 1 {
            return Err(ComponentError::InvalidState(
                "FlowMerger with 1 inlet is just a pipe — use a Pipe component instead".into(),
            ));
        }
        Ok(Self {
            kind,
            fluid_id: fluid,
            inlets,
            outlet,
            mass_flow_weights: None,
        })
    }

    /// Assigns known mass flow rates per inlet for weighted enthalpy mixing.
    ///
    /// # Errors
    ///
    /// Returns an error if `weights.len() != n_inlets`.
    pub fn with_mass_flows(mut self, weights: Vec<f64>) -> Result<Self, ComponentError> {
        if weights.len() != self.inlets.len() {
            return Err(ComponentError::InvalidState(format!(
                "FlowMerger::with_mass_flows: expected {} weights, got {}",
                self.inlets.len(),
                weights.len()
            )));
        }
        if weights.iter().any(|&w| w < 0.0) {
            return Err(ComponentError::InvalidState(
                "FlowMerger::with_mass_flows: mass flow weights must be non-negative".into(),
            ));
        }
        self.mass_flow_weights = Some(weights);
        Ok(self)
    }

    // ── Accessors ─────────────────────────────────────────────────────────────

    /// Number of inlet branches.
    pub fn n_inlets(&self) -> usize {
        self.inlets.len()
    }

    /// Fluid kind.
    pub fn fluid_kind(&self) -> FluidKind {
        self.kind
    }

    /// Fluid identifier.
    pub fn fluid_id(&self) -> &str {
        &self.fluid_id
    }

    /// Reference to the inlet ports.
    pub fn inlets(&self) -> &[ConnectedPort] {
        &self.inlets
    }

    /// Reference to the outlet port.
    pub fn outlet(&self) -> &ConnectedPort {
        &self.outlet
    }

    // ── Mixing helpers ────────────────────────────────────────────────────────

    /// Computes the mixed outlet enthalpy (weighted or equal).
    fn mixed_enthalpy(&self) -> f64 {
        let n = self.inlets.len();
        match &self.mass_flow_weights {
            Some(weights) => {
                let total_flow: f64 = weights.iter().sum();
                if total_flow <= 0.0 {
                    // Fall back to equal weighting
                    self.inlets.iter().map(|p| p.enthalpy().to_joules_per_kg()).sum::<f64>()
                        / n as f64
                } else {
                    self.inlets
                        .iter()
                        .zip(weights.iter())
                        .map(|(p, &w)| p.enthalpy().to_joules_per_kg() * w)
                        .sum::<f64>()
                        / total_flow
                }
            }
            None => {
                // Equal weighting
                self.inlets.iter().map(|p| p.enthalpy().to_joules_per_kg()).sum::<f64>()
                    / n as f64
            }
        }
    }
}

impl Component for FlowMerger {
    /// `N + 1` equations:
    /// - `N−1` pressure equalisations across inlets
    /// - `1` mixing enthalpy for the outlet
    /// - `1` outlet pressure consistency
    fn n_equations(&self) -> usize {
        self.inlets.len() + 1
    }

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

        let p_ref = self.inlets[0].pressure().to_pascals();
        let mut r_idx = 0;

        // --- Pressure equalisation: inlets 1..N must match inlet 0 ---
        for k in 1..self.inlets.len() {
            let p_k = self.inlets[k].pressure().to_pascals();
            residuals[r_idx] = p_k - p_ref;
            r_idx += 1;
        }

        // --- Outlet pressure = reference inlet pressure ---
        let p_out = self.outlet.pressure().to_pascals();
        residuals[r_idx] = p_out - p_ref;
        r_idx += 1;

        // --- Mixing enthalpy ---
        let h_mixed = self.mixed_enthalpy();
        let h_out = self.outlet.enthalpy().to_joules_per_kg();
        residuals[r_idx] = h_out - h_mixed;

        Ok(())
    }

    fn jacobian_entries(
        &self,
        _state: &SystemState,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        // Diagonal approximation — the full coupling is resolved by the System
        // assembler through the edge state indices.
        let n_eqs = self.n_equations();
        for i in 0..n_eqs {
            jacobian.add_entry(i, i, 1.0);
        }
        Ok(())
    }

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

// ─────────────────────────────────────────────────────────────────────────────
// Convenience type aliases
// ─────────────────────────────────────────────────────────────────────────────

/// A flow splitter for incompressible fluids (water, glycol, brine…).
///
/// Equivalent to `FlowSplitter` constructed via [`FlowSplitter::incompressible`].
pub type IncompressibleSplitter = FlowSplitter;

/// A flow splitter for compressible fluids (refrigerant, CO₂, steam…).
///
/// Equivalent to `FlowSplitter` constructed via [`FlowSplitter::compressible`].
pub type CompressibleSplitter = FlowSplitter;

/// A flow merger for incompressible fluids (water, glycol, brine…).
///
/// Equivalent to `FlowMerger` constructed via [`FlowMerger::incompressible`].
pub type IncompressibleMerger = FlowMerger;

/// A flow merger for compressible fluids (refrigerant, CO₂, steam…).
///
/// Equivalent to `FlowMerger` constructed via [`FlowMerger::compressible`].
pub type CompressibleMerger = FlowMerger;

// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::port::{FluidId, Port};
    use entropyk_core::{Enthalpy, Pressure};

    fn make_port(fluid: &str, p_pa: f64, h_jkg: f64) -> ConnectedPort {
        let p1 = Port::new(
            FluidId::new(fluid),
            Pressure::from_pascals(p_pa),
            Enthalpy::from_joules_per_kg(h_jkg),
        );
        let p2 = Port::new(
            FluidId::new(fluid),
            Pressure::from_pascals(p_pa),
            Enthalpy::from_joules_per_kg(h_jkg),
        );
        p1.connect(p2).unwrap().0
    }

    // ── FlowSplitter ──────────────────────────────────────────────────────────

    #[test]
    fn test_splitter_incompressible_creation() {
        let inlet = make_port("Water", 3.0e5, 2.0e5);
        let out_a  = make_port("Water", 3.0e5, 2.0e5);
        let out_b  = make_port("Water", 3.0e5, 2.0e5);

        let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
        assert_eq!(s.n_outlets(), 2);
        assert_eq!(s.fluid_kind(), FluidKind::Incompressible);
        // n_equations = 2*2 - 1 = 3
        assert_eq!(s.n_equations(), 3);
    }

    #[test]
    fn test_splitter_compressible_creation() {
        let inlet = make_port("R410A", 24.0e5, 4.65e5);
        let out_a  = make_port("R410A", 24.0e5, 4.65e5);
        let out_b  = make_port("R410A", 24.0e5, 4.65e5);
        let out_c  = make_port("R410A", 24.0e5, 4.65e5);

        let s = FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b, out_c]).unwrap();
        assert_eq!(s.n_outlets(), 3);
        assert_eq!(s.fluid_kind(), FluidKind::Compressible);
        // n_equations = 2*3 - 1 = 5
        assert_eq!(s.n_equations(), 5);
    }

    #[test]
    fn test_splitter_rejects_refrigerant_as_incompressible() {
        let inlet = make_port("R410A", 24.0e5, 4.65e5);
        let out_a  = make_port("R410A", 24.0e5, 4.65e5);
        let out_b  = make_port("R410A", 24.0e5, 4.65e5);
        let result = FlowSplitter::incompressible("R410A", inlet, vec![out_a, out_b]);
        assert!(result.is_err(), "R410A should not be accepted as incompressible");
    }

    #[test]
    fn test_splitter_rejects_single_outlet() {
        let inlet = make_port("Water", 3.0e5, 2.0e5);
        let out   = make_port("Water", 3.0e5, 2.0e5);
        let result = FlowSplitter::incompressible("Water", inlet, vec![out]);
        assert!(result.is_err());
    }

    #[test]
    fn test_splitter_residuals_zero_at_consistent_state() {
        // Consistent state: all pressures and enthalpies equal
        let inlet = make_port("Water", 3.0e5, 2.0e5);
        let out_a  = make_port("Water", 3.0e5, 2.0e5);
        let out_b  = make_port("Water", 3.0e5, 2.0e5);

        let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
        let state = vec![0.0; 6]; // dummy, not used by current impl
        let mut res = vec![0.0; s.n_equations()];
        s.compute_residuals(&state, &mut res).unwrap();

        for (i, &r) in res.iter().enumerate() {
            assert!(
                r.abs() < 1.0,
                "residual[{}] = {} should be ≈ 0 for consistent state",
                i, r
            );
        }
    }

    #[test]
    fn test_splitter_residuals_nonzero_on_pressure_mismatch() {
        let inlet = make_port("Water", 3.0e5, 2.0e5);
        let out_a  = make_port("Water", 2.5e5, 2.0e5); // lower pressure!
        let out_b  = make_port("Water", 3.0e5, 2.0e5);

        let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
        let state = vec![0.0; 6];
        let mut res = vec![0.0; s.n_equations()];
        s.compute_residuals(&state, &mut res).unwrap();

        // r[0] = P_out_a - P_in = 2.5e5 - 3.0e5 = -0.5e5
        assert!((res[0] - (-0.5e5)).abs() < 1.0, "expected -0.5e5, got {}", res[0]);
    }

    #[test]
    fn test_splitter_three_branches_n_equations() {
        let inlet = make_port("Water", 3.0e5, 2.0e5);
        let outlets: Vec<_> = (0..3).map(|_| make_port("Water", 3.0e5, 2.0e5)).collect();
        let s = FlowSplitter::incompressible("Water", inlet, outlets).unwrap();
        // N=3 → 2*3-1 = 5
        assert_eq!(s.n_equations(), 5);
    }

    #[test]
    fn test_splitter_water_type_aliases() {
        let inlet = make_port("Water", 3.0e5, 2.0e5);
        let out_a  = make_port("Water", 3.0e5, 2.0e5);
        let out_b  = make_port("Water", 3.0e5, 2.0e5);

        // IncompressibleSplitter is a type alias for FlowSplitter
        let _s: IncompressibleSplitter =
            FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
    }

    // ── FlowMerger ────────────────────────────────────────────────────────────

    #[test]
    fn test_merger_incompressible_creation() {
        let in_a   = make_port("Water", 3.0e5, 2.0e5);
        let in_b   = make_port("Water", 3.0e5, 2.4e5);
        let outlet = make_port("Water", 3.0e5, 2.2e5);

        let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
        assert_eq!(m.n_inlets(), 2);
        assert_eq!(m.fluid_kind(), FluidKind::Incompressible);
        // N=2 → N+1 = 3
        assert_eq!(m.n_equations(), 3);
    }

    #[test]
    fn test_merger_compressible_creation() {
        let in_a   = make_port("R134a", 8.0e5, 4.0e5);
        let in_b   = make_port("R134a", 8.0e5, 4.2e5);
        let in_c   = make_port("R134a", 8.0e5, 3.8e5);
        let outlet = make_port("R134a", 8.0e5, 4.0e5);

        let m = FlowMerger::compressible("R134a", vec![in_a, in_b, in_c], outlet).unwrap();
        assert_eq!(m.n_inlets(), 3);
        assert_eq!(m.fluid_kind(), FluidKind::Compressible);
        // N=3 → N+1 = 4
        assert_eq!(m.n_equations(), 4);
    }

    #[test]
    fn test_merger_rejects_single_inlet() {
        let in_a   = make_port("Water", 3.0e5, 2.0e5);
        let outlet = make_port("Water", 3.0e5, 2.0e5);
        let result = FlowMerger::incompressible("Water", vec![in_a], outlet);
        assert!(result.is_err());
    }

    #[test]
    fn test_merger_residuals_zero_at_consistent_state() {
        // Equal branches → mixed enthalpy = inlet enthalpy
        let h = 2.0e5_f64;
        let p = 3.0e5_f64;
        let in_a   = make_port("Water", p, h);
        let in_b   = make_port("Water", p, h);
        let outlet = make_port("Water", p, h); // h_mixed = (h+h)/2 = h

        let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
        let state = vec![0.0; 6];
        let mut res = vec![0.0; m.n_equations()];
        m.compute_residuals(&state, &mut res).unwrap();

        for (i, &r) in res.iter().enumerate() {
            assert!(r.abs() < 1.0, "residual[{}] = {} should be ≈ 0", i, r);
        }
    }

    #[test]
    fn test_merger_mixed_enthalpy_equal_branches() {
        let h_a = 2.0e5_f64;
        let h_b = 3.0e5_f64;
        let h_expected = (h_a + h_b) / 2.0; // equal-weight average
        let p = 3.0e5_f64;

        let in_a   = make_port("Water", p, h_a);
        let in_b   = make_port("Water", p, h_b);
        let outlet = make_port("Water", p, h_expected);

        let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
        let state = vec![0.0; 6];
        let mut res = vec![0.0; m.n_equations()];
        m.compute_residuals(&state, &mut res).unwrap();

        // Last residual: h_out - h_mixed should be 0
        let last = res[m.n_equations() - 1];
        assert!(last.abs() < 1.0, "h mixing residual = {} should be 0", last);
    }

    #[test]
    fn test_merger_weighted_enthalpy() {
        // ṁ_a = 0.3 kg/s, h_a = 2e5 J/kg
        // ṁ_b = 0.7 kg/s, h_b = 3e5 J/kg
        // h_mix = (0.3*2e5 + 0.7*3e5) / 1.0 = (6e4 + 21e4) = 2.7e5 J/kg
        let p = 3.0e5_f64;
        let in_a   = make_port("Water", p, 2.0e5);
        let in_b   = make_port("Water", p, 3.0e5);
        let outlet = make_port("Water", p, 2.7e5);

        let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet)
            .unwrap()
            .with_mass_flows(vec![0.3, 0.7])
            .unwrap();

        let state = vec![0.0; 6];
        let mut res = vec![0.0; m.n_equations()];
        m.compute_residuals(&state, &mut res).unwrap();

        let h_residual = res[m.n_equations() - 1];
        assert!(
            h_residual.abs() < 1.0,
            "weighted h mixing residual = {} should be 0",
            h_residual
        );
    }

    #[test]
    fn test_merger_as_trait_object() {
        let in_a   = make_port("Water", 3.0e5, 2.0e5);
        let in_b   = make_port("Water", 3.0e5, 2.0e5);
        let outlet = make_port("Water", 3.0e5, 2.0e5);

        let merger: Box<dyn Component> = Box::new(
            FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap()
        );
        assert_eq!(merger.n_equations(), 3);
    }

    #[test]
    fn test_splitter_as_trait_object() {
        let inlet = make_port("R410A", 24.0e5, 4.65e5);
        let out_a  = make_port("R410A", 24.0e5, 4.65e5);
        let out_b  = make_port("R410A", 24.0e5, 4.65e5);

        let splitter: Box<dyn Component> = Box::new(
            FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b]).unwrap()
        );
        assert_eq!(splitter.n_equations(), 3);
    }
}
