//! 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, StateSlice,
};

// ─────────────────────────────────────────────────────────────────────────────
// 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).
///
/// Recognises the fluid names used by CoolProp's incompressible backend, including:
/// - Plain names: `Water`, `Glycol`, `Brine`, `MEG`, `PEG`
/// - CoolProp mixture prefix: `INCOMP::*`
/// - Systematic glycol names: `EthyleneGlycol`, `PropyleneGlycol`
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")
        || f.starts_with("meg")
        || f.starts_with("peg")
        || f.starts_with("incomp::")
}

// ─────────────────────────────────────────────────────────────────────────────
// 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>,
    /// Captured (P,h) global state indices of the inlet edge (incoming).
    inlet_idx: Option<(usize, usize)>,
    /// Captured (P,h) global state indices of the outlet edges (outgoing).
    outlet_idx: Vec<(usize, usize)>,
}

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,
            inlet_idx: None,
            outlet_idx: Vec::new(),
        })
    }

    // ── 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` equations — the splitter owns its `N` outgoing edges, contributing
    /// 2 equations (pressure + enthalpy) per branch:
    /// - `P_out_k − P_in = 0`   for k = 0..N
    /// - `h_out_k − h_in = 0`   for k = 0..N
    fn n_equations(&self) -> usize {
        2 * self.outlets.len()
    }

    fn set_system_context(
        &mut self,
        _state_offset: usize,
        external_edge_state_indices: &[(usize, usize, usize)],
    ) {
        // Layout: [0] = incoming inlet edge, [1..] = outgoing outlet edges.
        // Triple: (m_idx, p_idx, h_idx) — extract (p, h) pairs for residuals.
        if external_edge_state_indices.is_empty() {
            return;
        }
        self.inlet_idx = Some((
            external_edge_state_indices[0].1,
            external_edge_state_indices[0].2,
        ));
        self.outlet_idx = external_edge_state_indices[1..]
            .iter()
            .map(|&(_, p, h)| (p, h))
            .collect();
    }

    fn compute_residuals(
        &self,
        state: &StateSlice,
        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 (in_p, in_h) = match self.inlet_idx {
            Some(idx) if self.outlet_idx.len() == self.outlets.len() => idx,
            _ => {
                return Err(ComponentError::InvalidState(
                    "FlowSplitter requires one live inlet edge and all live outlet edges"
                        .to_string(),
                ));
            }
        };

        for (k, &(out_p, out_h)) in self.outlet_idx.iter().enumerate() {
            residuals[2 * k] = state[out_p] - state[in_p];
            residuals[2 * k + 1] = state[out_h] - state[in_h];
        }

        Ok(())
    }

    fn jacobian_entries(
        &self,
        _state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        let (in_p, in_h) = match self.inlet_idx {
            Some(idx) if self.outlet_idx.len() == self.outlets.len() => idx,
            _ => {
                return Err(ComponentError::InvalidState(
                    "FlowSplitter Jacobian requires one live inlet edge and all live outlet edges"
                        .to_string(),
                ));
            }
        };

        for (k, &(out_p, out_h)) in self.outlet_idx.iter().enumerate() {
            // r[2k] = P_out_k - P_in
            jacobian.add_entry(2 * k, out_p, 1.0);
            jacobian.add_entry(2 * k, in_p, -1.0);
            // r[2k+1] = h_out_k - h_in
            jacobian.add_entry(2 * k + 1, out_h, 1.0);
            jacobian.add_entry(2 * k + 1, in_h, -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.
        &[]
    }

    fn port_mass_flows(
        &self,
        state: &StateSlice,
    ) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
        // FlowSplitter: 1 inlet → N outlets
        // Mass balance: inlet = sum of outlets
        // State layout: [m_in, m_out_1, m_out_2, ...]
        let n_outlets = self.n_outlets();
        if state.len() < 1 + n_outlets {
            return Err(ComponentError::InvalidStateDimensions {
                expected: 1 + n_outlets,
                actual: state.len(),
            });
        }

        let mut flows = Vec::with_capacity(1 + n_outlets);
        // Inlet (positive = entering)
        flows.push(entropyk_core::MassFlow::from_kg_per_s(state[0]));
        // Outlets (negative = leaving)
        for i in 0..n_outlets {
            flows.push(entropyk_core::MassFlow::from_kg_per_s(-state[1 + i]));
        }
        Ok(flows)
    }

    /// Returns the enthalpies of all ports (inlet first, then outlets).
    ///
    /// For a flow splitter, the enthalpy is conserved across branches:
    /// `h_in = h_out_1 = h_out_2 = ...` (isenthalpic split).
    fn port_enthalpies(
        &self,
        _state: &StateSlice,
    ) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
        let mut enthalpies = Vec::with_capacity(1 + self.outlets.len());

        enthalpies.push(self.inlet.enthalpy());

        for outlet in &self.outlets {
            enthalpies.push(outlet.enthalpy());
        }

        Ok(enthalpies)
    }

    /// Returns the energy transfers for the flow splitter.
    ///
    /// A flow splitter is adiabatic:
    /// - **Heat (Q)**: 0 W (no heat exchange with environment)
    /// - **Work (W)**: 0 W (no mechanical work)
    fn energy_transfers(
        &self,
        _state: &StateSlice,
    ) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
        Some((
            entropyk_core::Power::from_watts(0.0),
            entropyk_core::Power::from_watts(0.0),
        ))
    }

    fn signature(&self) -> String {
        format!(
            "FlowSplitter(fluid={}, outlets={})",
            self.fluid_id,
            self.outlets.len()
        )
    }

    fn to_params(&self) -> crate::ComponentParams {
        crate::ComponentParams::new("FlowSplitter")
            .with_param("fluid", self.fluid_id.as_str())
            .with_param("outletCount", self.outlets.len())
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 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>>,
    /// Captured (P,h) global state indices of the inlet edges (incoming).
    inlet_idx: Vec<(usize, usize)>,
    /// Captured (P,h) global state indices of the outlet edge (outgoing).
    outlet_idx: Option<(usize, usize)>,
}

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,
            inlet_idx: Vec::new(),
            outlet_idx: 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 ────────────────────────────────────────────────────────

    /// Returns the mass-flow weights normalised so they sum to 1.0.
    ///
    /// Falls back to equal weighting when no weights are set or the total is
    /// non-positive.
    fn normalized_weights(&self) -> Vec<f64> {
        let n = self.inlets.len();
        match &self.mass_flow_weights {
            Some(weights) => {
                let total: f64 = weights.iter().sum();
                if total <= 0.0 {
                    vec![1.0 / n as f64; n]
                } else {
                    weights.iter().map(|&w| w / total).collect()
                }
            }
            None => vec![1.0 / n as f64; n],
        }
    }
}

impl Component for FlowMerger {
    /// `2` equations — the merger owns its single outgoing edge:
    /// - `P_out − P_in_0 = 0`   (outlet pressure = reference inlet pressure)
    /// - `h_out − Σ wₖ·h_in_k = 0`   (mass-weighted enthalpy mixing)
    fn n_equations(&self) -> usize {
        2
    }

    fn set_system_context(
        &mut self,
        _state_offset: usize,
        external_edge_state_indices: &[(usize, usize, usize)],
    ) {
        // Layout: [0..N] = incoming inlet edges, [N] = outgoing outlet edge.
        // Triple: (m_idx, p_idx, h_idx) — extract (p, h) pairs for residuals.
        let n = self.inlets.len();
        if external_edge_state_indices.len() >= n {
            self.inlet_idx = external_edge_state_indices[0..n]
                .iter()
                .map(|&(_, p, h)| (p, h))
                .collect();
        }
        if external_edge_state_indices.len() > n {
            self.outlet_idx = Some((
                external_edge_state_indices[n].1,
                external_edge_state_indices[n].2,
            ));
        }
    }

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

        let (out_p, out_h) = match self.outlet_idx {
            Some(idx) if self.inlet_idx.len() == self.inlets.len() => idx,
            _ => {
                return Err(ComponentError::InvalidState(
                    "FlowMerger requires all live inlet edges and one live outlet edge".to_string(),
                ));
            }
        };

        // r0: outlet pressure tracks the (reference) first inlet pressure.
        let p_ref = state[self.inlet_idx[0].0];
        residuals[0] = state[out_p] - p_ref;

        // r1: mass-weighted enthalpy mixing.
        let weights = self.normalized_weights();
        let h_mix: f64 = self
            .inlet_idx
            .iter()
            .zip(weights.iter())
            .map(|(&(_, h_idx), &w)| w * state[h_idx])
            .sum();
        residuals[1] = state[out_h] - h_mix;

        Ok(())
    }

    fn jacobian_entries(
        &self,
        _state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        let (out_p, out_h) = match self.outlet_idx {
            Some(idx) if self.inlet_idx.len() == self.inlets.len() => idx,
            _ => {
                return Err(ComponentError::InvalidState(
                    "FlowMerger Jacobian requires all live inlet edges and one live outlet edge"
                        .to_string(),
                ));
            }
        };

        // r0 = P_out - P_in_0
        jacobian.add_entry(0, out_p, 1.0);
        jacobian.add_entry(0, self.inlet_idx[0].0, -1.0);

        // r1 = h_out - Σ wₖ·h_in_k
        jacobian.add_entry(1, out_h, 1.0);
        let weights = self.normalized_weights();
        for (&(_, h_idx), &w) in self.inlet_idx.iter().zip(weights.iter()) {
            jacobian.add_entry(1, h_idx, -w);
        }
        Ok(())
    }

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

    fn port_mass_flows(
        &self,
        state: &StateSlice,
    ) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
        // FlowMerger: N inlets → 1 outlet
        // Mass balance: sum of inlets = outlet
        // State layout: [m_in_1, m_in_2, ..., m_out]
        let n_inlets = self.n_inlets();
        if state.len() < n_inlets + 1 {
            return Err(ComponentError::InvalidStateDimensions {
                expected: n_inlets + 1,
                actual: state.len(),
            });
        }

        let mut flows = Vec::with_capacity(n_inlets + 1);
        // Inlets (positive = entering)
        for i in 0..n_inlets {
            flows.push(entropyk_core::MassFlow::from_kg_per_s(state[i]));
        }
        // Outlet (negative = leaving)
        flows.push(entropyk_core::MassFlow::from_kg_per_s(-state[n_inlets]));
        Ok(flows)
    }

    /// Returns the enthalpies of all ports (inlets first, then outlet).
    ///
    /// For a flow merger, the outlet enthalpy is determined by
    /// the mixing of inlet streams (mass-weighted average).
    fn port_enthalpies(
        &self,
        _state: &StateSlice,
    ) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
        let mut enthalpies = Vec::with_capacity(self.inlets.len() + 1);

        for inlet in &self.inlets {
            enthalpies.push(inlet.enthalpy());
        }

        enthalpies.push(self.outlet.enthalpy());

        Ok(enthalpies)
    }

    /// Returns the energy transfers for the flow merger.
    ///
    /// A flow merger is adiabatic:
    /// - **Heat (Q)**: 0 W (no heat exchange with environment)
    /// - **Work (W)**: 0 W (no mechanical work)
    fn energy_transfers(
        &self,
        _state: &StateSlice,
    ) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
        Some((
            entropyk_core::Power::from_watts(0.0),
            entropyk_core::Power::from_watts(0.0),
        ))
    }

    fn signature(&self) -> String {
        format!(
            "FlowMerger(fluid={}, inlets={})",
            self.fluid_id,
            self.inlets.len()
        )
    }

    fn to_params(&self) -> crate::ComponentParams {
        crate::ComponentParams::new("FlowMerger")
            .with_param("fluid", self.fluid_id.as_str())
            .with_param("inletCount", self.inlets.len())
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 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*N = 4 (owns 2 outgoing edges, P+h each)
        assert_eq!(s.n_equations(), 4);
    }

    #[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*N = 6
        assert_eq!(s.n_equations(), 6);
    }

    #[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 branch pressures/enthalpies equal the inlet.
        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 mut s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
        // Edges (CM1.3 stride-3): inlet=(ṁ@0,P@1,h@2), out_a=(ṁ@3,P@4,h@5), out_b=(ṁ@6,P@7,h@8)
        s.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);

        // State: ṁ slots are don't-care (splitter ignores them); P and h are consistent.
        let state = vec![0.0, 3.0e5, 2.0e5, 0.0, 3.0e5, 2.0e5, 0.0, 3.0e5, 2.0e5];
        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", 3.0e5, 2.0e5);
        let out_b = make_port("Water", 3.0e5, 2.0e5);

        let mut s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
        s.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);

        // out_a pressure lower than inlet by 0.5e5; ṁ slots are don't-care.
        let state = vec![0.0, 3.0e5, 2.0e5, 0.0, 2.5e5, 2.0e5, 0.0, 3.0e5, 2.0e5];
        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*N = 6
        assert_eq!(s.n_equations(), 6);
    }

    #[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);
        // Merger owns its single outgoing edge → 2 equations (P + h)
        assert_eq!(m.n_equations(), 2);
    }

    #[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);
        // Merger owns its single outgoing edge → 2 equations (P + h)
        assert_eq!(m.n_equations(), 2);
    }

    #[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 mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
        // Edges (CM1.3 stride-3): in_a=(ṁ@0,P@1,h@2), in_b=(ṁ@3,P@4,h@5), outlet=(ṁ@6,P@7,h@8)
        m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);

        let state = vec![0.0, p, h, 0.0, p, h, 0.0, p, h];
        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 mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
        m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);

        let state = vec![0.0, p, h_a, 0.0, p, h_b, 0.0, p, h_expected];
        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 mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet)
            .unwrap()
            .with_mass_flows(vec![0.3, 0.7])
            .unwrap();
        m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);

        let state = vec![0.0, p, 2.0e5, 0.0, p, 3.0e5, 0.0, p, 2.7e5];
        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(), 2);
    }

    #[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(), 4);
    }

    // ── energy_transfers tests ─────────────────────────────────────────────────

    #[test]
    fn test_splitter_energy_transfers_zero() {
        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 splitter = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
        let state = vec![0.0; 6];

        let (heat, work) = splitter.energy_transfers(&state).unwrap();

        assert_eq!(heat.to_watts(), 0.0);
        assert_eq!(work.to_watts(), 0.0);
    }

    #[test]
    fn test_merger_energy_transfers_zero() {
        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 merger = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
        let state = vec![0.0; 6];

        let (heat, work) = merger.energy_transfers(&state).unwrap();

        assert_eq!(heat.to_watts(), 0.0);
        assert_eq!(work.to_watts(), 0.0);
    }

    // ── port_enthalpies tests ──────────────────────────────────────────────────

    #[test]
    fn test_splitter_port_enthalpies_count() {
        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 out_c = make_port("Water", 3.0e5, 2.0e5);

        let splitter =
            FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b, out_c]).unwrap();
        let state = vec![0.0; 8];

        let enthalpies = splitter.port_enthalpies(&state).unwrap();

        // 1 inlet + 3 outlets = 4 enthalpies
        assert_eq!(enthalpies.len(), 4);
    }

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

        let merger = FlowMerger::incompressible("Water", vec![in_a, in_b, in_c], outlet).unwrap();
        let state = vec![0.0; 8];

        let enthalpies = merger.port_enthalpies(&state).unwrap();

        // 3 inlets + 1 outlet = 4 enthalpies
        assert_eq!(enthalpies.len(), 4);
    }

    #[test]
    fn test_splitter_port_enthalpies_values() {
        let h_in = 2.5e5_f64;
        let h_out_a = 2.5e5_f64;
        let h_out_b = 2.5e5_f64;

        let inlet = make_port("Water", 3.0e5, h_in);
        let out_a = make_port("Water", 3.0e5, h_out_a);
        let out_b = make_port("Water", 3.0e5, h_out_b);

        let splitter = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
        let state = vec![0.0; 6];

        let enthalpies = splitter.port_enthalpies(&state).unwrap();

        assert_eq!(enthalpies[0].to_joules_per_kg(), h_in);
        assert_eq!(enthalpies[1].to_joules_per_kg(), h_out_a);
        assert_eq!(enthalpies[2].to_joules_per_kg(), h_out_b);
    }

    #[test]
    fn test_merger_port_enthalpies_values() {
        let h_in_a = 2.0e5_f64;
        let h_in_b = 3.0e5_f64;
        let h_out = 2.5e5_f64;

        let in_a = make_port("Water", 3.0e5, h_in_a);
        let in_b = make_port("Water", 3.0e5, h_in_b);
        let outlet = make_port("Water", 3.0e5, h_out);

        let merger = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
        let state = vec![0.0; 6];

        let enthalpies = merger.port_enthalpies(&state).unwrap();

        assert_eq!(enthalpies[0].to_joules_per_kg(), h_in_a);
        assert_eq!(enthalpies[1].to_joules_per_kg(), h_in_b);
        assert_eq!(enthalpies[2].to_joules_per_kg(), h_out);
    }
}
