//! Hierarchical Subsystems — MacroComponent
//!
//! A `MacroComponent` wraps a finalized [`System`] (topology + components) and
//! exposes it as a single [`Component`], enabling hierarchical composition.
//!
//! ## Architecture
//!
//! ```text
//! ┌─────────────────── MacroComponent ───────────────────┐
//! │  internal System (finalized)                         │
//! │  ┌─────┐  edge_a  ┌─────┐  edge_b  ┌─────┐         │
//! │  │Comp0├──────────►│Comp1├──────────►│Comp2│         │
//! │  └─────┘           └─────┘           └─────┘         │
//! │                                                      │
//! │  external ports ← port_map                           │
//! │    port 0: edge_a.inlet  (in)                        │
//! │    port 1: edge_b.outlet (out)                       │
//! └──────────────────────────────────────────────────────┘
//! ```
//!
//! ## Index Mapping & Coupling Equations
//!
//! The global solver assigns indices to all edges in the parent `System`.
//! Edges *inside* a `MacroComponent` are addressed via `global_state_offset`
//! (set during `System::finalize()` via `set_system_context`).
//!
//! When the `MacroComponent` is connected to external edges in the parent graph,
//! coupling residuals enforce continuity between those external edges and the
//! corresponding exposed internal edges.  With the stride-3 `(ṁ, P, h)` layout
//! introduced in Story CM1.2, P is at `offset + 3 * pos + 1` and h at
//! `offset + 3 * pos + 2` (resolved symbolically via `edge_state_indices()`):
//!
//! ```text
//! r_P = state[p_ext] − state[offset + p_local] = 0
//! r_h = state[h_ext] − state[offset + h_local] = 0
//! ```
//!
//! Note: ṁ continuity at ports is not yet enforced (deferred to Story CM1.3).
//!
//! ## Serialization (AC #4)
//!
//! The full component graph inside a `MacroComponent` cannot be trivially
//! serialized because `Box<dyn Component>` requires `typetag` or a custom
//! registry (deferred). Instead, `MacroComponent` exposes a **state snapshot**
//! ([`MacroComponentSnapshot`]) that captures the internal edge states and port
//! mappings. This is sufficient for persistence / restore of operating-point data.

use crate::system::System;
use entropyk_components::{
    Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use std::collections::HashMap;

// ─────────────────────────────────────────────────────────────────────────────
// Port mapping
// ─────────────────────────────────────────────────────────────────────────────

/// An exposed port on the MacroComponent surface.
///
/// Maps an internal edge (by position) to an external port visible to the
/// parent `System`.
#[derive(Debug, Clone)]
pub struct PortMapping {
    /// Human-readable name for the external port (e.g. "evap_water_in").
    pub name: String,
    /// The internal edge index (position in the internal System's edge iteration
    /// order) whose state this port corresponds to.
    pub internal_edge_pos: usize,
    /// A connected port to present externally (fluid, P, h).
    pub port: ConnectedPort,
}

// ─────────────────────────────────────────────────────────────────────────────
// Serialization snapshot
// ─────────────────────────────────────────────────────────────────────────────

/// A serializable snapshot of a `MacroComponent`'s operating state.
///
/// Captures the internal edge state vector and port metadata so that an
/// operating point can be saved to disk and restored.  The full component
/// topology (graph structure, `Box<dyn Component>` nodes) is **not** included
/// — reconstruction of the topology is the caller's responsibility.
///
/// # Example (JSON)
///
/// ```json
/// {
///   "label": "chiller_1",
///   "internal_edge_states": [0.05, 1.5e5, 4.2e5, 0.05, 8.0e4, 3.8e5],
///   "port_names": ["evap_in", "evap_out"]
/// }
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MacroComponentSnapshot {
    /// Optional human-readable label for the subsystem.
    pub label: Option<String>,
    /// Flat state vector for the internal edges: `[ṁ_e0, P_e0, h_e0, ṁ_e1, P_e1, h_e1, ...]`.
    ///
    /// Per-edge layout is `(ṁ, P, h)` (stride 3, introduced in Story CM1.2).
    pub internal_edge_states: Vec<f64>,
    /// Names of exposed ports, in the same order as `port_mappings`.
    pub port_names: Vec<String>,
}

// ─────────────────────────────────────────────────────────────────────────────
// MacroComponent
// ─────────────────────────────────────────────────────────────────────────────

/// A hierarchical subsystem that wraps a `System` and implements `Component`.
///
/// This enables Modelica-style block composition: a chiller (compressor +
/// condenser + valve + evaporator) can be wrapped in a `MacroComponent` and
/// plugged into a higher-level `System`.
///
/// # Coupling equations
///
/// When `set_system_context` is called by `System::finalize()`, the component
/// stores the state indices of every parent-graph edge incident to its node.
/// `compute_residuals` then appends 2 coupling residuals per exposed port:
///
/// ```text
/// r_P[i] = state[p_ext_i] − state[offset + p_local_i] = 0
/// r_h[i] = state[h_ext_i] − state[offset + h_local_i] = 0
/// ```
/// (where `p_local_i`, `h_local_i` are the internal-local state indices of the
/// mapped internal edge — stride 3 in the `(ṁ, P, h)` layout)
///
/// TEMP (CM1.2): ṁ continuity at ports is not yet coupled (`r_m = ṁ_ext − ṁ_int`
/// is missing). Each side has its own independent mass-flow closure (`ṁ = seed`).
/// Story CM1.3 must add the ṁ coupling residual + Jacobian entries here, and
/// update `n_equations()` from `2 * n_ports` to `3 * n_ports`.
///
/// # Usage
///
/// ```no_run
/// use entropyk_solver::{System, MacroComponent};
/// use entropyk_components::Component;
///
/// // 1. Build and finalize internal system
/// let mut internal = System::new();
/// // ... add components & edges ...
/// internal.finalize().unwrap();
///
/// // 2. Wrap into a MacroComponent
/// let macro_comp = MacroComponent::new(internal);
///
/// // 3. Optionally expose ports
/// // macro_comp.expose_port(0, "inlet", port);
///
/// // 4. Add to a parent System (finalize() automatically wires context)
/// let mut parent = System::new();
/// parent.add_component(Box::new(macro_comp));
/// ```
pub struct MacroComponent {
    /// The enclosed, finalized subsystem.
    internal: System,
    /// External port mappings.  Ordered; index = external port index.
    port_mappings: Vec<PortMapping>,
    /// Cached external ports (mirrors port_mappings order).
    external_ports: Vec<ConnectedPort>,
    /// Maps external-port-index → internal-edge-position for fast lookup.
    ext_to_internal_edge: HashMap<usize, usize>,
    /// The global state vector offset assigned to this MacroComponent's first
    /// internal edge. Set automatically via `set_system_context` during parent
    /// `System::finalize()`.  Defaults to 0.
    global_state_offset: usize,
    /// State indices `(m_idx, p_idx, h_idx)` of every parent-graph edge incident to
    /// this node (incoming and outgoing), in traversal order.
    /// Populated by `set_system_context`; empty until finalization.
    external_edge_state_indices: Vec<(usize, usize, usize)>,
}

impl MacroComponent {
    /// Creates a new `MacroComponent` wrapping the given *finalized* system.
    ///
    /// # Panics
    ///
    /// Panics if the internal system has not been finalized.
    pub fn new(internal: System) -> Self {
        Self {
            internal,
            port_mappings: Vec::new(),
            external_ports: Vec::new(),
            ext_to_internal_edge: HashMap::new(),
            global_state_offset: 0,
            external_edge_state_indices: Vec::new(),
        }
    }

    /// Exposes an internal edge as an external port on the MacroComponent.
    ///
    /// # Arguments
    ///
    /// * `internal_edge_pos` — Position of the edge in the internal system's
    ///   edge iteration order (0-based).
    /// * `name` — Human-readable label for this external port.
    /// * `port` — A `ConnectedPort` representing the fluid, pressure and
    ///   enthalpy at this interface.
    ///
    /// # Panics
    ///
    /// Panics if `internal_edge_pos >= internal.edge_count()`.
    pub fn expose_port(
        &mut self,
        internal_edge_pos: usize,
        name: impl Into<String>,
        port: ConnectedPort,
    ) {
        assert!(
            internal_edge_pos < self.internal.edge_count(),
            "internal_edge_pos {} out of range (internal has {} edges)",
            internal_edge_pos,
            self.internal.edge_count()
        );

        let ext_idx = self.port_mappings.len();
        self.port_mappings.push(PortMapping {
            name: name.into(),
            internal_edge_pos,
            port: port.clone(),
        });
        self.external_ports.push(port);
        self.ext_to_internal_edge.insert(ext_idx, internal_edge_pos);
    }

    /// Sets the global state-vector offset for this MacroComponent.
    ///
    /// Prefer letting `System::finalize()` set this automatically via
    /// `set_system_context`.  This setter is kept for backward compatibility
    /// and for manual test scenarios.
    pub fn set_global_state_offset(&mut self, offset: usize) {
        self.global_state_offset = offset;
    }

    /// Returns the global state offset.
    pub fn global_state_offset(&self) -> usize {
        self.global_state_offset
    }

    /// Returns a reference to the internal system.
    pub fn internal_system(&self) -> &System {
        &self.internal
    }

    /// Returns a mutable reference to the internal system.
    pub fn internal_system_mut(&mut self) -> &mut System {
        &mut self.internal
    }

    /// Returns the port mappings.
    pub fn port_mappings(&self) -> &[PortMapping] {
        &self.port_mappings
    }

    /// Number of internal edges (each contributes 3 state variables: ṁ, P, h).
    pub fn internal_edge_count(&self) -> usize {
        self.internal.edge_count()
    }

    /// Total number of internal state variables (3 per edge: ṁ, P, h).
    pub fn internal_state_len(&self) -> usize {
        self.internal.state_vector_len()
    }

    // ─── helpers ──────────────────────────────────────────────────────────────

    /// Number of equations from internal components (excluding coupling eqs).
    fn n_internal_equations(&self) -> usize {
        self.internal
            .traverse_for_jacobian()
            .map(|(_, c, _)| c.n_equations())
            .sum()
    }

    /// Number of internal residuals produced by `internal.compute_residuals`:
    /// component equations plus constraint and coupling rows.
    fn n_internal_residuals(&self) -> usize {
        self.n_internal_equations()
            + self.internal.constraint_residual_count()
            + self.internal.coupling_residual_count()
    }

    /// Internal-local `(p_idx, h_idx)` of the internal edge at graph-order
    /// position `pos`, accounting for the per-edge stride (CM1.2: `(ṁ, P, h)`).
    /// Returns `None` if no such internal edge exists.
    fn internal_edge_ph_local(&self, pos: usize) -> Option<(usize, usize)> {
        self.internal
            .edge_indices()
            .nth(pos)
            .map(|e| self.internal.edge_state_indices(e))
    }

    // ─── snapshot ─────────────────────────────────────────────────────────────

    /// Captures the current internal state as a serializable snapshot.
    ///
    /// The caller must supply the global state vector so that internal edge
    /// states can be extracted.  Returns `None` if the state vector is shorter
    /// than expected.
    pub fn to_snapshot(
        &self,
        global_state: &StateSlice,
        label: Option<String>,
    ) -> Option<MacroComponentSnapshot> {
        let start = self.global_state_offset;
        let end = start + self.internal_state_len();
        if global_state.len() < end {
            return None;
        }
        Some(MacroComponentSnapshot {
            label,
            internal_edge_states: global_state[start..end].to_vec(),
            port_names: self.port_mappings.iter().map(|m| m.name.clone()).collect(),
        })
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Component trait implementation
// ─────────────────────────────────────────────────────────────────────────────

impl Component for MacroComponent {
    /// Called by `System::finalize()` to inject the parent-level state offset
    /// and the external edge state indices for this MacroComponent node.
    ///
    /// `external_edge_state_indices` contains one `(m_idx, p_idx, h_idx)` triple per
    /// parent edge incident to this node (in traversal order: incoming, then
    /// outgoing).  The *i*-th entry is matched to `port_mappings[i]` when
    /// emitting coupling residuals.
    fn set_system_context(
        &mut self,
        state_offset: usize,
        external_edge_state_indices: &[(usize, usize, usize)],
    ) {
        self.global_state_offset = state_offset;
        self.external_edge_state_indices = external_edge_state_indices.to_vec();
    }

    fn internal_state_len(&self) -> usize {
        // Delegates to the inherent method or computes directly
        self.internal.state_vector_len()
    }

    fn compute_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        let n_internal_vars = self.internal_state_len();
        let start = self.global_state_offset;
        let end = start + n_internal_vars;

        if state.len() < end {
            return Err(ComponentError::InvalidStateDimensions {
                expected: end,
                actual: state.len(),
            });
        }

        let n_int_res = self.n_internal_residuals();
        let n_coupling = 2 * self.port_mappings.len();
        let n_total = n_int_res + n_coupling;

        if residuals.len() < n_total {
            return Err(ComponentError::InvalidResidualDimensions {
                expected: n_total,
                actual: residuals.len(),
            });
        }

        // --- 1. Delegate internal residuals ----------------------------------
        let internal_state: Vec<f64> = state[start..end].to_vec();
        let mut internal_residuals = vec![0.0; n_int_res];
        self.internal
            .compute_residuals(&internal_state, &mut internal_residuals)?;
        residuals[..n_int_res].copy_from_slice(&internal_residuals);

        // --- 2. Port-coupling residuals --------------------------------------
        // For each exposed port mapping we append two residuals that enforce
        // continuity between the parent-graph external edge and the
        // corresponding internal edge:
        //
        //   r_P = state[p_ext] − state[offset + p_local]  = 0
        //   r_h = state[h_ext] − state[offset + h_local]  = 0
        for (i, mapping) in self.port_mappings.iter().enumerate() {
            if let Some(&(_, p_ext, h_ext)) = self.external_edge_state_indices.get(i) {
                let (p_local, h_local) = self
                    .internal_edge_ph_local(mapping.internal_edge_pos)
                    .ok_or(ComponentError::InvalidStateDimensions {
                        expected: mapping.internal_edge_pos,
                        actual: self.internal.edge_count(),
                    })?;
                let int_p = self.global_state_offset + p_local;
                let int_h = self.global_state_offset + h_local;

                if state.len() <= int_h || state.len() <= p_ext || state.len() <= h_ext {
                    return Err(ComponentError::InvalidStateDimensions {
                        expected: int_h.max(p_ext).max(h_ext) + 1,
                        actual: state.len(),
                    });
                }

                residuals[n_int_res + 2 * i] = state[p_ext] - state[int_p];
                residuals[n_int_res + 2 * i + 1] = state[h_ext] - state[int_h];
            }
        }

        Ok(())
    }

    fn jacobian_entries(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        let n_internal_vars = self.internal_state_len();
        let start = self.global_state_offset;
        let end = start + n_internal_vars;

        if state.len() < end {
            return Err(ComponentError::InvalidStateDimensions {
                expected: end,
                actual: state.len(),
            });
        }

        let n_int_res = self.n_internal_residuals();

        // --- 1. Internal Jacobian entries ------------------------------------
        let internal_state: Vec<f64> = state[start..end].to_vec();

        let mut internal_jac = JacobianBuilder::new();
        self.internal
            .assemble_jacobian(&internal_state, &mut internal_jac)?;

        // Offset columns by global_state_offset to translate from internal-local
        // to global column indices.
        for &(row, col, val) in internal_jac.entries() {
            jacobian.add_entry(row, col + self.global_state_offset, val);
        }

        // --- 2. Coupling Jacobian entries ------------------------------------
        // For each coupling residual pair (row_p, row_h):
        //
        //   ∂r_P/∂state[p_ext] = +1
        //   ∂r_P/∂state[int_p] = −1
        //   ∂r_h/∂state[h_ext] = +1
        //   ∂r_h/∂state[int_h] = −1
        for (i, mapping) in self.port_mappings.iter().enumerate() {
            if let Some(&(_, p_ext, h_ext)) = self.external_edge_state_indices.get(i) {
                let (p_local, h_local) =
                    match self.internal_edge_ph_local(mapping.internal_edge_pos) {
                        Some(v) => v,
                        None => continue,
                    };
                let int_p = self.global_state_offset + p_local;
                let int_h = self.global_state_offset + h_local;
                let row_p = n_int_res + 2 * i;
                let row_h = row_p + 1;

                jacobian.add_entry(row_p, p_ext, 1.0);
                jacobian.add_entry(row_p, int_p, -1.0);
                jacobian.add_entry(row_h, h_ext, 1.0);
                jacobian.add_entry(row_h, int_h, -1.0);
            }
        }

        Ok(())
    }

    fn n_equations(&self) -> usize {
        // Internal residuals (component eqs + mass-flow closures) + 2 coupling
        // equations per exposed port (P and h only).
        // TEMP (CM1.2): becomes `3 * n_ports` in CM1.3 when ṁ coupling is added.
        self.n_internal_residuals() + 2 * self.port_mappings.len()
    }

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

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

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

    /// Minimal mock component for testing.
    struct MockInternalComponent {
        n_equations: usize,
    }

    impl Component for MockInternalComponent {
        fn compute_residuals(
            &self,
            state: &StateSlice,
            residuals: &mut ResidualVector,
        ) -> Result<(), ComponentError> {
            // Simple identity: residual[i] = state[i] (so zero when state is zero)
            for i in 0..self.n_equations {
                residuals[i] = state.get(i).copied().unwrap_or(0.0);
            }
            Ok(())
        }

        fn jacobian_entries(
            &self,
            _state: &StateSlice,
            jacobian: &mut JacobianBuilder,
        ) -> Result<(), ComponentError> {
            for i in 0..self.n_equations {
                jacobian.add_entry(i, i, 1.0);
            }
            Ok(())
        }

        fn n_equations(&self) -> usize {
            self.n_equations
        }

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

    fn make_mock(n: usize) -> Box<dyn Component> {
        Box::new(MockInternalComponent { n_equations: n })
    }

    fn make_connected_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),
        );
        let (c1, _c2) = p1.connect(p2).unwrap();
        c1
    }

    /// Build a simple linear subsystem: A → B → C (2 edges, 3 components).
    ///
    /// Intentionally not square (6 eqs / 5 unknowns) — used only to exercise
    /// macro residual plumbing, not physical DoF. DoF gate disabled for that reason.
    fn build_simple_internal_system() -> System {
        let mut sys = System::new();
        sys.set_enforce_dof_gate(false);
        let a = sys.add_component(make_mock(2));
        let b = sys.add_component(make_mock(2));
        let c = sys.add_component(make_mock(2));
        sys.add_edge(a, b).unwrap();
        sys.add_edge(b, c).unwrap();
        sys.finalize().unwrap();
        sys
    }

    #[test]
    fn test_macro_component_creation() {
        let sys = build_simple_internal_system();
        let mc = MacroComponent::new(sys);

        // 3 components × 2 equations each = 6 internal residuals (no ports exposed yet).
        assert_eq!(mc.n_equations(), 6);
        // CM1.4: 2-edge linear chain → 1 branch → state_len = 1 + 2×2 = 5
        assert_eq!(mc.internal_state_len(), 5);
        // No ports exposed yet
        assert!(mc.get_ports().is_empty());
    }

    #[test]
    fn test_expose_port_adds_coupling_equations() {
        let sys = build_simple_internal_system();
        let mut mc = MacroComponent::new(sys);

        let port = make_connected_port("R134a", 100_000.0, 400_000.0);
        mc.expose_port(0, "inlet", port.clone());

        // 6 internal residuals + 2 coupling = 8
        assert_eq!(mc.n_equations(), 8);
        assert_eq!(mc.get_ports().len(), 1);
        assert_eq!(mc.port_mappings()[0].name, "inlet");
        assert_eq!(mc.port_mappings()[0].internal_edge_pos, 0);
    }

    #[test]
    fn test_expose_multiple_ports() {
        let sys = build_simple_internal_system();
        let mut mc = MacroComponent::new(sys);

        let port_in = make_connected_port("R134a", 100_000.0, 400_000.0);
        let port_out = make_connected_port("R134a", 500_000.0, 450_000.0);

        mc.expose_port(0, "inlet", port_in);
        mc.expose_port(1, "outlet", port_out);

        // 6 internal residuals + 4 coupling = 10
        assert_eq!(mc.n_equations(), 10);
        assert_eq!(mc.get_ports().len(), 2);
        assert_eq!(mc.port_mappings()[0].name, "inlet");
        assert_eq!(mc.port_mappings()[1].name, "outlet");
    }

    #[test]
    #[should_panic(expected = "internal_edge_pos 5 out of range")]
    fn test_expose_port_out_of_range() {
        let sys = build_simple_internal_system();
        let mut mc = MacroComponent::new(sys);
        let port = make_connected_port("R134a", 100_000.0, 400_000.0);
        mc.expose_port(5, "bad", port);
    }

    #[test]
    fn test_compute_residuals_delegation() {
        let sys = build_simple_internal_system();
        let mc = MacroComponent::new(sys);

        // 6 state variables for 2 internal edges (ṁ,P,h each; no external coupling)
        let state = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
        let mut residuals = vec![0.0; mc.n_equations()];

        mc.compute_residuals(&state, &mut residuals).unwrap();

        // 6 internal residuals (3 components × 2 equations, no coupling ports)
        assert_eq!(residuals.len(), 6);
    }

    #[test]
    fn test_compute_residuals_with_offset() {
        let sys = build_simple_internal_system();
        let mut mc = MacroComponent::new(sys);
        mc.set_global_state_offset(4);

        // State vector: 4 padding + 6 internal = 10 total
        let state = vec![0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
        let mut residuals = vec![0.0; mc.n_equations()];

        mc.compute_residuals(&state, &mut residuals).unwrap();
        assert_eq!(residuals.len(), 6);
    }

    #[test]
    fn test_compute_residuals_state_too_short() {
        let sys = build_simple_internal_system();
        let mut mc = MacroComponent::new(sys);
        mc.set_global_state_offset(4);

        let state = vec![0.0; 5]; // Needs at least 10 (offset 4 + 6 internal vars)
        let mut residuals = vec![0.0; mc.n_equations()];

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

    #[test]
    fn test_jacobian_entries_delegation() {
        let sys = build_simple_internal_system();
        let mc = MacroComponent::new(sys);

        let state = vec![0.0; mc.internal_state_len()];
        let mut jac = JacobianBuilder::new();

        mc.jacobian_entries(&state, &mut jac).unwrap();

        // 6 equations → at least 6 diagonal entries from internal mocks
        assert!(jac.len() >= 6);
    }

    #[test]
    fn test_jacobian_entries_with_offset() {
        let sys = build_simple_internal_system();
        let mut mc = MacroComponent::new(sys);
        mc.set_global_state_offset(10);

        let state = vec![0.0; 10 + mc.internal_state_len()];
        let mut jac = JacobianBuilder::new();

        mc.jacobian_entries(&state, &mut jac).unwrap();

        // Verify internal-delegated columns are offset by 10
        for &(_, col, _) in jac.entries() {
            assert!(col >= 10, "Column {} should be >= 10 (offset)", col);
        }
    }

    #[test]
    fn test_coupling_residuals_and_jacobian() {
        // 2-edge internal system: edge0 = (ṁ0, P0, h0), edge1 = (ṁ1, P1, h1)
        let sys = build_simple_internal_system();
        let mut mc = MacroComponent::new(sys);

        // Expose internal edge 0 as port "inlet"
        let port = make_connected_port("R134a", 100_000.0, 400_000.0);
        mc.expose_port(0, "inlet", port);

        // Concrete global layout with internal block at [4..10] (stride 3):
        //   index 4 = ṁ_int_e0, 5 = P_int_e0, 6 = h_int_e0
        //   index 7 = ṁ_int_e1, 8 = P_int_e1, 9 = h_int_e1
        //   index 10 = ṁ_ext, 11 = P_ext, 12 = h_ext (external edge)
        mc.set_global_state_offset(4);
        mc.set_system_context(4, &[(10, 11, 12)]);

        let mut state = vec![0.0; 13];
        state[5] = 1.5e5; // P_int_e0
        state[6] = 3.9e5; // h_int_e0
        state[11] = 2.0e5; // P_ext
        state[12] = 4.1e5; // h_ext

        let n_eqs = mc.n_equations(); // 6 internal residuals + 2 coupling = 8
        assert_eq!(n_eqs, 8);

        let mut residuals = vec![0.0; n_eqs];
        mc.compute_residuals(&state, &mut residuals).unwrap();

        // Coupling residuals occupy the last 2 rows (indices 6, 7):
        //   r[6] = state[11] - state[5] = 2e5 - 1.5e5 = 0.5e5
        //   r[7] = state[12] - state[6] = 4.1e5 - 3.9e5 = 0.2e5
        assert!(
            (residuals[6] - 0.5e5).abs() < 1.0,
            "r_P mismatch: {}",
            residuals[6]
        );
        assert!(
            (residuals[7] - 0.2e5).abs() < 1.0,
            "r_h mismatch: {}",
            residuals[7]
        );

        // Jacobian coupling entries
        let mut jac = JacobianBuilder::new();
        mc.jacobian_entries(&state, &mut jac).unwrap();

        let entries = jac.entries();
        let find = |row: usize, col: usize| -> Option<f64> {
            entries
                .iter()
                .find(|&&(r, c, _)| r == row && c == col)
                .map(|&(_, _, v)| v)
        };
        assert_eq!(find(6, 11), Some(1.0), "expect ∂r_P/∂p_ext = +1");
        assert_eq!(find(6, 5), Some(-1.0), "expect ∂r_P/∂int_p = -1");
        assert_eq!(find(7, 12), Some(1.0), "expect ∂r_h/∂h_ext = +1");
        assert_eq!(find(7, 6), Some(-1.0), "expect ∂r_h/∂int_h = -1");
    }

    #[test]
    fn test_n_equations_empty_system() {
        let mut sys = System::new();
        sys.set_enforce_dof_gate(false);
        let a = sys.add_component(make_mock(0));
        let b = sys.add_component(make_mock(0));
        sys.add_edge(a, b).unwrap();
        sys.finalize().unwrap();

        let mc = MacroComponent::new(sys);
        // 2 components × 0 equations = 0 (no mass-flow closures since CM1.3).
        assert_eq!(mc.n_equations(), 0);
    }

    #[test]
    fn test_macro_component_as_trait_object() {
        let sys = build_simple_internal_system();
        let mc = MacroComponent::new(sys);

        // Verify it can be used as Box<dyn Component>
        let component: Box<dyn Component> = Box::new(mc);
        assert_eq!(component.n_equations(), 6);
    }

    #[test]
    fn test_macro_component_in_parent_system() {
        // Build an internal subsystem
        let internal = build_simple_internal_system();
        let mc = MacroComponent::new(internal);

        // Place it in a parent system alongside another component
        let mut parent = System::new();
        parent.set_enforce_dof_gate(false);
        let mc_node = parent.add_component(Box::new(mc));
        let other = parent.add_component(make_mock(2));
        parent.add_edge(mc_node, other).unwrap();
        parent.finalize().unwrap();

        // Parent should have 2 nodes and 1 edge
        assert_eq!(parent.node_count(), 2);
        assert_eq!(parent.edge_count(), 1);
    }

    // ── Serialization snapshot ─────────────────────────────────────────────────

    #[test]
    fn test_snapshot_round_trip() {
        let sys = build_simple_internal_system();
        let mut mc = MacroComponent::new(sys);
        mc.set_global_state_offset(0);

        let port = make_connected_port("R134a", 1e5, 4e5);
        mc.expose_port(0, "inlet", port);

        // CM1.4: internal block has 5 slots (1 ṁ_branch + 2×2 P,h for 2 edges)
        // Layout: [0:ṁ_branch, 1:P_e0, 2:h_e0, 3:P_e1, 4:h_e1]
        let global_state = vec![0.05, 1.5e5, 3.9e5, 8.0e4, 4.2e5];
        let snap = mc
            .to_snapshot(&global_state, Some("chiller_1".into()))
            .unwrap();

        assert_eq!(snap.label.as_deref(), Some("chiller_1"));
        assert_eq!(snap.internal_edge_states.len(), 5);
        assert_eq!(snap.port_names, vec!["inlet"]);

        // Round-trip through JSON
        let json = serde_json::to_string(&snap).unwrap();
        let restored: MacroComponentSnapshot = serde_json::from_str(&json).unwrap();
        assert_eq!(restored.internal_edge_states, snap.internal_edge_states);
    }
}
