use petgraph::visit::EdgeRef;
use std::collections::HashMap;
use std::sync::Arc;
use thiserror::Error;

use entropyk_core::{CircuitId, ThermalConductance};
use entropyk_fluids::FluidBackend;
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId};
use entropyk_solver::{AddEdgeError, ThermalCoupling, TopologyError};

use crate::ThermoError;

/// Error type for system builder operations.
#[derive(Error, Debug, Clone)]
pub enum SystemBuilderError {
    /// A component with the given name already exists in the builder.
    #[error("Component '{0}' already exists")]
    ComponentExists(String),

    /// The specified component name was not found in the builder.
    #[error("Component '{0}' not found")]
    ComponentNotFound(String),

    /// Failed to create an edge between two components.
    #[error("Failed to create edge from '{from}' to '{to}': {reason}")]
    EdgeFailed {
        /// Name of the source component.
        from: String,
        /// Name of the target component.
        to: String,
        /// Reason for the failure.
        reason: String,
    },

    /// Self-loop edge is not allowed: a component cannot connect to itself.
    #[error("Self-loop edge not allowed: component '{component}' cannot connect to itself")]
    SelfLoopEdge {
        /// Name of the component.
        component: String,
    },

    /// Cross-circuit edge is not allowed: flow edges connect only nodes within the same circuit.
    #[error("Cross-circuit edge not allowed: '{from}' and '{to}' are in different circuits")]
    CrossCircuitEdge {
        /// Name of the source component.
        from: String,
        /// Name of the target component.
        to: String,
    },

    /// Too many circuits: circuit id exceeds the allowed range (0..=4).
    #[error("Too many circuits: requested circuit {requested}, maximum is 5 (0..=4)")]
    TooManyCircuits {
        /// The requested circuit ID that exceeded the limit.
        requested: u16,
    },

    /// Port name not found on the specified component.
    #[error("Port '{port_name}' not found on component '{component}'")]
    PortNotFound {
        /// The component name.
        component: String,
        /// The port name that was not found.
        port_name: String,
    },

    /// Port validation failed (fluid, pressure, or enthalpy mismatch).
    #[error("Port validation failed from '{from}' to '{to}': {reason}")]
    PortValidationFailed {
        /// Source component name.
        from: String,
        /// Target component name.
        to: String,
        /// Reason for validation failure.
        reason: String,
    },

    /// The system must be finalized before this operation.
    #[error("System must be finalized before solving")]
    NotFinalized,

    /// Cannot build a system with no components.
    #[error("Cannot build an empty system")]
    EmptySystem,

    /// Constraint or inverse control operation failed (duplicate id, invalid reference, etc.).
    #[error("Constraint/inverse control: {reason}")]
    ConstraintFailed {
        /// Reason from the solver layer.
        reason: String,
    },

    /// Bounded variable operation failed (e.g. invalid bounds min >= max).
    #[error("Bounded variable: {reason}")]
    BoundedVariableFailed {
        /// Reason from the solver layer.
        reason: String,
    },

    /// Inverse control DoF or linking failed (constraint/control not found, already linked, over/under-constrained).
    #[error("Inverse control DoF/link: {reason}")]
    InverseControlDoF {
        /// Reason from the solver layer.
        reason: String,
    },

    /// Failed to add a component to the system (internal topology error).
    #[error("Failed to add component '{name}': {reason}")]
    ComponentAddFailed {
        /// Component name.
        name: String,
        /// Reason from the solver layer.
        reason: String,
    },

    /// Thermal coupling failed (invalid circuit, missing components, or solver rejection).
    #[error("Thermal coupling failed: {reason}")]
    ThermalCouplingFailed {
        /// Reason from the solver layer.
        reason: String,
    },

    /// Attempted to couple a circuit to itself.
    #[error("Cannot couple circuit {circuit} to itself — use into_inner() + add_thermal_coupling() for economizer scenarios")]
    SameCircuitCoupling {
        /// The circuit that was coupled to itself.
        circuit: u16,
    },

    /// Attempted to couple to a circuit with no components.
    #[error("Circuit {circuit} has no components — cannot create thermal coupling")]
    EmptyCircuitCoupling {
        /// The circuit that has no components.
        circuit: u16,
    },

    /// Fluid backend assignment failed (empty circuit or invalid circuit ID).
    #[error("Fluid backend assignment failed: {reason}")]
    FluidBackendAssignmentFailed {
        /// Reason for the failure.
        reason: String,
    },

    /// JSON config serialization or deserialization failed.
    #[error("Config JSON error: {reason}")]
    ConfigJsonError {
        /// Reason for the failure.
        reason: String,
    },
}

/// A builder for creating thermodynamic systems with a fluent API.
///
/// The `SystemBuilder` provides an ergonomic way to construct thermodynamic
/// systems by adding components and edges with human-readable names.
///
/// # Example
///
/// ```
/// use entropyk::SystemBuilder;
///
/// let builder = SystemBuilder::new();
/// assert_eq!(builder.component_count(), 0);
/// ```
///
/// For real components, see the crate-level documentation.
pub struct SystemBuilder {
    system: entropyk_solver::System,
    component_names: HashMap<String, petgraph::graph::NodeIndex>,
    fluid_name: Option<String>,
    thermal_couplings: Vec<ThermalCoupling>,
    default_backend: Option<Arc<dyn FluidBackend>>,
    circuit_backends: HashMap<u16, Arc<dyn FluidBackend>>,
}

impl SystemBuilder {
    /// Creates a new empty system builder.
    pub fn new() -> Self {
        Self {
            system: entropyk_solver::System::new(),
            component_names: HashMap::new(),
            fluid_name: None,
            thermal_couplings: Vec::new(),
            default_backend: None,
            circuit_backends: HashMap::new(),
        }
    }

    /// Sets the default fluid for the system.
    ///
    /// This stores the fluid name for reference. The actual fluid assignment
    /// to components is handled at the component/port level.
    ///
    /// # Arguments
    ///
    /// * `fluid` - The fluid name (e.g., "R134a", "R410A", "CO2")
    #[inline]
    pub fn with_fluid(mut self, fluid: impl Into<String>) -> Self {
        self.fluid_name = Some(fluid.into());
        self
    }

    /// Sets the default fluid backend for all components in the system.
    ///
    /// During [`build()`](Self::build), this backend is propagated to every component
    /// that supports fluid backends and doesn't already have one assigned.
    /// Per-circuit backends (set via [`with_circuit_fluid_backend`](Self::with_circuit_fluid_backend))
    /// take precedence over the default.
    ///
    /// # Arguments
    ///
    /// * `backend` - A shared fluid backend (e.g., `Arc::new(TestBackend::new())`)
    #[inline]
    pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
        self.default_backend = Some(backend);
        self
    }

    /// Assigns a fluid backend to all components in a specific circuit.
    ///
    /// Per-circuit backends override the default backend set via
    /// [`with_fluid_backend`](Self::with_fluid_backend) for components in that circuit.
    /// The circuit must already contain at least one component.
    ///
    /// # Arguments
    ///
    /// * `circuit_id` - The circuit ID (0..=4)
    /// * `backend` - A shared fluid backend for this circuit
    ///
    /// # Errors
    ///
    /// Returns [`SystemBuilderError::FluidBackendAssignmentFailed`] if:
    /// - The circuit ID exceeds 4
    /// - The circuit has no components
    pub fn with_circuit_fluid_backend(
        mut self,
        circuit_id: u16,
        backend: Arc<dyn FluidBackend>,
    ) -> Result<Self, SystemBuilderError> {
        const MAX_CIRCUIT_ID: u16 = 4;
        if circuit_id > MAX_CIRCUIT_ID {
            return Err(SystemBuilderError::FluidBackendAssignmentFailed {
                reason: format!("Circuit ID {circuit_id} exceeds maximum (0..={MAX_CIRCUIT_ID})"),
            });
        }
        if self
            .system
            .circuit_nodes(entropyk_core::CircuitId(circuit_id))
            .next()
            .is_none()
        {
            return Err(SystemBuilderError::FluidBackendAssignmentFailed {
                reason: format!("Circuit {circuit_id} has no components"),
            });
        }
        self.circuit_backends.insert(circuit_id, backend);
        Ok(self)
    }

    /// Adds a named component to the system (circuit 0).
    ///
    /// The name is used for later reference when creating edges.
    /// Returns an error if a component with the same name already exists.
    /// For multi-circuit systems, use [`component_in_circuit`](Self::component_in_circuit).
    ///
    /// # Arguments
    ///
    /// * `name` - A unique identifier for this component
    /// * `component` - The component to add
    #[inline]
    pub fn component(
        self,
        name: &str,
        component: Box<dyn entropyk_components::Component>,
    ) -> Result<Self, SystemBuilderError> {
        self.component_in_circuit(name, component, CircuitId::ZERO)
    }

    /// Adds a named component to a specific circuit.
    ///
    /// The name is used for later reference when creating edges. Circuit id must be in 0..=4 (max 5 circuits).
    /// Returns an error if the name already exists or the circuit id is invalid.
    ///
    /// # Arguments
    ///
    /// * `name` - A unique identifier for this component
    /// * `component` - The component to add
    /// * `circuit_id` - The circuit to add the component to (0..=4)
    ///
    /// # Errors
    ///
    /// Returns `SystemBuilderError::TooManyCircuits` if `circuit_id` is outside 0..=4.
    #[inline]
    pub fn component_in_circuit(
        mut self,
        name: &str,
        component: Box<dyn entropyk_components::Component>,
        circuit_id: CircuitId,
    ) -> Result<Self, SystemBuilderError> {
        if self.component_names.contains_key(name) {
            return Err(SystemBuilderError::ComponentExists(name.to_string()));
        }

        let idx = self
            .system
            .add_component_to_circuit(component, circuit_id)
            .map_err(|e| match e {
                TopologyError::TooManyCircuits { requested } => {
                    SystemBuilderError::TooManyCircuits { requested }
                }
                other => SystemBuilderError::ComponentAddFailed {
                    name: name.to_string(),
                    reason: other.to_string(),
                },
            })?;
        self.component_names.insert(name.to_string(), idx);
        if !self.system.register_component_name(name, idx) {
            self.component_names.remove(name);
            return Err(SystemBuilderError::ComponentExists(format!(
                "duplicate component name '{name}' already registered in solver"
            )));
        }

        Ok(self)
    }

    /// Creates an edge between two named components.
    ///
    /// The edge represents a fluid connection from the source component's
    /// outlet to the target component's inlet.
    ///
    /// # Arguments
    ///
    /// * `from` - Name of the source component
    /// * `to` - Name of the target component
    ///
    /// # Errors
    ///
    /// Returns an error if either component name is not found, if the edge
    /// would connect two different circuits (cross-circuit edges are not allowed),
    /// or if `from` and `to` refer to the same component (self-loops are not allowed).
    #[inline]
    pub fn edge(mut self, from: &str, to: &str) -> Result<Self, SystemBuilderError> {
        let from_idx = self
            .component_names
            .get(from)
            .ok_or_else(|| SystemBuilderError::ComponentNotFound(from.to_string()))?;

        let to_idx = self
            .component_names
            .get(to)
            .ok_or_else(|| SystemBuilderError::ComponentNotFound(to.to_string()))?;

        if from_idx == to_idx {
            return Err(SystemBuilderError::SelfLoopEdge {
                component: from.to_string(),
            });
        }

        let src_circuit = self.system.node_circuit(*from_idx);
        let tgt_circuit = self.system.node_circuit(*to_idx);
        if src_circuit != tgt_circuit {
            return Err(SystemBuilderError::CrossCircuitEdge {
                from: from.to_string(),
                to: to.to_string(),
            });
        }

        self.system
            .add_edge(*from_idx, *to_idx)
            .map_err(|e| SystemBuilderError::EdgeFailed {
                from: from.to_string(),
                to: to.to_string(),
                reason: e.to_string(),
            })?;

        Ok(self)
    }

    /// Creates an edge between two named components with port validation.
    ///
    /// Validates port compatibility (fluid, pressure, enthalpy continuity) using
    /// the [`Component::resolve_port_name`](entropyk_components::Component::resolve_port_name)
    /// method to map port names to indices, supporting both explicit
    /// [`port_names()`](entropyk_components::Component::port_names) overrides and
    /// convention-based fallback.
    ///
    /// # Arguments
    ///
    /// * `from` - Name of the source component
    /// * `from_port` - Port name on source component (e.g., `"discharge"`, `"outlet"`)
    /// * `to` - Name of the target component
    /// * `to_port` - Port name on target component (e.g., `"inlet"`, `"suction"`)
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Either component name is not found
    /// - Port name is not recognized for the component
    /// - Port validation fails (fluid, pressure, or enthalpy mismatch)
    /// - Components are in different circuits
    ///
    /// # Example
    ///
    /// ```ignore
    /// let system = SystemBuilder::new()
    ///     .component("comp", compressor)?
    ///     .component("cond", condenser)?
    ///     .edge_with_ports("comp", "discharge", "cond", "refrigerant_in")?
    ///     .build()?;
    /// ```
    #[inline]
    pub fn edge_with_ports(
        mut self,
        from: &str,
        from_port: &str,
        to: &str,
        to_port: &str,
    ) -> Result<Self, SystemBuilderError> {
        let from_idx = self
            .component_names
            .get(from)
            .ok_or_else(|| SystemBuilderError::ComponentNotFound(from.to_string()))?;

        let to_idx = self
            .component_names
            .get(to)
            .ok_or_else(|| SystemBuilderError::ComponentNotFound(to.to_string()))?;

        if from_idx == to_idx {
            return Err(SystemBuilderError::SelfLoopEdge {
                component: from.to_string(),
            });
        }

        let src_circuit = self.system.node_circuit(*from_idx);
        let tgt_circuit = self.system.node_circuit(*to_idx);
        if src_circuit != tgt_circuit {
            return Err(SystemBuilderError::CrossCircuitEdge {
                from: from.to_string(),
                to: to.to_string(),
            });
        }

        let from_component = self.system.component(*from_idx);
        let from_port_idx = from_component
            .resolve_port_name(from_port)
            .map_err(|reason| SystemBuilderError::PortNotFound {
                component: from.to_string(),
                port_name: format!("{from_port}: {reason}"),
            })?;

        let to_component = self.system.component(*to_idx);
        let to_port_idx = to_component.resolve_port_name(to_port).map_err(|reason| {
            SystemBuilderError::PortNotFound {
                component: to.to_string(),
                port_name: format!("{to_port}: {reason}"),
            }
        })?;

        self.system
            .add_edge_with_ports(*from_idx, from_port_idx, *to_idx, to_port_idx)
            .map_err(|e| match e {
                AddEdgeError::Connection(conn_err) => SystemBuilderError::PortValidationFailed {
                    from: from.to_string(),
                    to: to.to_string(),
                    reason: conn_err.to_string(),
                },
                AddEdgeError::Topology(topo_err) => SystemBuilderError::EdgeFailed {
                    from: from.to_string(),
                    to: to.to_string(),
                    reason: topo_err.to_string(),
                },
            })?;

        Ok(self)
    }

    /// Adds an output constraint for inverse control (e.g. superheat = 5K at a component).
    ///
    /// The constraint's `component_id` (in [`ComponentOutput`]) should match a component name
    /// added via [`component`](Self::component) or [`component_in_circuit`](Self::component_in_circuit).
    /// Call [`link_constraint_to_control`](Self::link_constraint_to_control) to link this constraint
    /// to a bounded control variable. After [`build`](Self::build), call
    /// `system.validate_inverse_control_dof()` before solving to ensure the system is well-posed.
    ///
    /// # Errors
    ///
    /// Returns `SystemBuilderError::ConstraintFailed` if the solver rejects the constraint
    /// (e.g. duplicate constraint id).
    #[inline]
    pub fn with_constraint(mut self, constraint: Constraint) -> Result<Self, SystemBuilderError> {
        let component_id = constraint.output().component_id().to_string();
        self.system.add_constraint(constraint).map_err(|e| {
            let hint = if !self.component_names.contains_key(&component_id) {
                format!("{} — component '{}' has not been added to the builder yet (call `component()` first)", e, component_id)
            } else {
                e.to_string()
            };
            SystemBuilderError::ConstraintFailed { reason: hint }
        })?;
        Ok(self)
    }

    /// Adds a bounded control variable for inverse control (e.g. valve position 0.0–1.0).
    ///
    /// Link this variable to a constraint via [`link_constraint_to_control`](Self::link_constraint_to_control).
    /// After [`build`](Self::build), call `system.validate_inverse_control_dof()` before solving.
    ///
    /// # Errors
    ///
    /// Returns `SystemBuilderError::BoundedVariableFailed` if bounds are invalid (e.g. min >= max).
    #[inline]
    pub fn with_bounded_variable(
        mut self,
        variable: BoundedVariable,
    ) -> Result<Self, SystemBuilderError> {
        self.system.add_bounded_variable(variable).map_err(|e| {
            SystemBuilderError::BoundedVariableFailed {
                reason: e.to_string(),
            }
        })?;
        Ok(self)
    }

    /// Links a constraint to a bounded control variable for one-shot inverse solving.
    ///
    /// Both the constraint and the bounded variable must have been added previously with
    /// [`with_constraint`](Self::with_constraint) and [`with_bounded_variable`](Self::with_bounded_variable).
    /// Each constraint should be linked to exactly one control variable (and vice versa) for
    /// a well-posed system. Call `system.validate_inverse_control_dof()` after [`build`](Self::build)
    /// to check before solving.
    ///
    /// # Errors
    ///
    /// Returns `SystemBuilderError::InverseControlDoF` if the constraint or control is not found,
    /// already linked, or the system is over/under-constrained.
    #[inline]
    pub fn link_constraint_to_control(
        mut self,
        constraint_id: &ConstraintId,
        bounded_variable_id: &BoundedVariableId,
    ) -> Result<Self, SystemBuilderError> {
        self.system
            .link_constraint_to_control(constraint_id, bounded_variable_id)
            .map_err(|e| SystemBuilderError::InverseControlDoF {
                reason: e.to_string(),
            })?;
        Ok(self)
    }

    /// Adds a thermal coupling between two circuits (e.g. condenser–evaporator HX link).
    ///
    /// Both circuits must already contain at least one component (added via
    /// [`component_in_circuit`](Self::component_in_circuit)). The UA value is
    /// specified in kW/K and automatically converted to W/K internally.
    ///
    /// The coupling is stored in the builder and applied to the system during
    /// [`build`](Self::build).
    ///
    /// # Arguments
    ///
    /// * `circuit_a` - First circuit ID (hot side)
    /// * `circuit_b` - Second circuit ID (cold side)
    /// * `ua_kw_per_k` - Thermal conductance in kW/K (must be positive)
    ///
    /// # Errors
    ///
    /// - `SystemBuilderError::SameCircuitCoupling` if `circuit_a == circuit_b`.
    /// - `SystemBuilderError::EmptyCircuitCoupling` if either circuit has no components.
    /// - `SystemBuilderError::ThermalCouplingFailed` if UA is not positive.
    pub fn thermal_coupling(
        mut self,
        circuit_a: u16,
        circuit_b: u16,
        ua_kw_per_k: f64,
    ) -> Result<Self, SystemBuilderError> {
        // AC3: Reject same-circuit coupling
        if circuit_a == circuit_b {
            return Err(SystemBuilderError::SameCircuitCoupling { circuit: circuit_a });
        }

        // AC5: Validate UA > 0
        if ua_kw_per_k <= 0.0 {
            return Err(SystemBuilderError::ThermalCouplingFailed {
                reason: "UA must be positive".to_string(),
            });
        }

        // AC2: Validate both circuits have components
        if self
            .system
            .circuit_nodes(CircuitId(circuit_a))
            .next()
            .is_none()
        {
            return Err(SystemBuilderError::EmptyCircuitCoupling { circuit: circuit_a });
        }
        if self
            .system
            .circuit_nodes(CircuitId(circuit_b))
            .next()
            .is_none()
        {
            return Err(SystemBuilderError::EmptyCircuitCoupling { circuit: circuit_b });
        }

        // AC1 & AC5: Create coupling with kW→W conversion
        let ua = ThermalConductance::from_kilowatts_per_kelvin(ua_kw_per_k);
        let coupling = ThermalCoupling::new(CircuitId(circuit_a), CircuitId(circuit_b), ua);

        // Defer to build time
        self.thermal_couplings.push(coupling);
        Ok(self)
    }

    /// Gets the underlying system without finalizing.
    ///
    /// This is useful when you need to perform additional operations
    /// on the system before finalizing.
    pub fn into_inner(self) -> entropyk_solver::System {
        self.system
    }

    /// Gets a reference to the component name to index mapping.
    pub fn component_names(&self) -> &HashMap<String, petgraph::graph::NodeIndex> {
        &self.component_names
    }

    /// Returns the number of components added so far.
    pub fn component_count(&self) -> usize {
        self.component_names.len()
    }

    /// Returns the number of edges created so far.
    pub fn edge_count(&self) -> usize {
        self.system.edge_count()
    }

    /// Serializes the builder configuration to a JSON string.
    ///
    /// Produces a complete JSON representation of the builder state:
    /// component parameters, topology (edges with port names), circuit assignments,
    /// thermal couplings, fluid name, constraints, and bounded variables.
    ///
    /// Fluid backends (`Arc<dyn FluidBackend>`) are NOT serialized — they are runtime
    /// objects. After deserialization, call `with_fluid_backend()` to reassign them.
    pub fn to_config_json(&self) -> Result<String, SystemBuilderError> {
        use entropyk_solver::snapshot::{
            BoundedVariableSnapshot, ConstraintSnapshot, EdgeSnapshot, FluidBackendInfo,
            SolverConfigSnapshot, SystemSnapshot, TopologySnapshot,
        };

        let reverse_names: HashMap<petgraph::graph::NodeIndex, &String> =
            self.component_names.iter().map(|(n, &i)| (i, n)).collect();

        let mut edges = Vec::new();
        for edge in self.system.graph().edge_indices() {
            let (source, target) = self.system.graph().edge_endpoints(edge).unwrap();
            let source_node = self.system.graph().node_weight(source).unwrap();
            let target_node = self.system.graph().node_weight(target).unwrap();

            let source_ports = source_node.port_names();
            let target_ports = target_node.port_names();

            let target_incoming: Vec<_> = self
                .system
                .graph()
                .edges_directed(target, petgraph::Direction::Incoming)
                .collect();
            let target_port_idx = target_incoming
                .iter()
                .position(|e| e.id() == edge)
                .unwrap_or(0);

            let source_outgoing: Vec<_> = self
                .system
                .graph()
                .edges_directed(source, petgraph::Direction::Outgoing)
                .collect();
            let source_port_idx = source_outgoing
                .iter()
                .position(|e| e.id() == edge)
                .unwrap_or(0);

            let source_port_name = source_ports
                .get(source_port_idx)
                .cloned()
                .unwrap_or_else(|| format!("port_{}", source_port_idx));
            let target_port_name = target_ports
                .get(target_port_idx)
                .cloned()
                .unwrap_or_else(|| format!("port_{}", target_port_idx));

            let circuit_id = self.system.edge_circuit(edge).0;

            edges.push(EdgeSnapshot {
                source: reverse_names
                    .get(&source)
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| source_node.signature()),
                source_port: source_port_name,
                target: reverse_names
                    .get(&target)
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| target_node.signature()),
                target_port: target_port_name,
                circuit_id,
            });
        }

        let mut parameters = HashMap::new();
        for node in self.system.graph().node_indices() {
            if let Some(component) = self.system.graph().node_weight(node) {
                let params = component.to_params();
                let key = reverse_names
                    .get(&node)
                    .map(|s| (*s).clone())
                    .unwrap_or_else(|| component.signature());
                parameters.insert(key, params);
            }
        }

        let component_names: HashMap<String, String> = self
            .component_names
            .iter()
            .map(|(name, &node_idx)| {
                let type_name = self
                    .system
                    .graph()
                    .node_weight(node_idx)
                    .map(|c| c.to_params().component_type.clone())
                    .unwrap_or_else(|| "Unknown".to_string());
                (name.clone(), type_name)
            })
            .collect();

        let circuit_assignments: HashMap<String, u16> = self
            .component_names
            .iter()
            .map(|(name, &node_idx)| {
                let cid = self
                    .system
                    .node_to_circuit()
                    .get(&node_idx)
                    .map(|c| c.0)
                    .unwrap_or(0);
                (name.clone(), cid)
            })
            .collect();

        let constraints = self
            .system
            .constraints_map()
            .iter()
            .map(|(id, c)| ConstraintSnapshot {
                id: id.as_str().to_string(),
                component: c.output().component_id().to_string(),
                output_type: c.output().constraint_type_name().to_string(),
                target: c.target_value(),
            })
            .collect();

        let bounded_variables = self
            .system
            .bounded_variables_map()
            .iter()
            .map(|(id, v)| BoundedVariableSnapshot {
                id: id.as_str().to_string(),
                component: v.component_id().unwrap_or("").to_string(),
                variable_name: id.as_str().to_string(),
                lower_bound: v.min(),
                upper_bound: v.max(),
                initial_value: v.value(),
            })
            .collect();

        let mut metadata = HashMap::new();
        if let Some(ref fluid) = self.fluid_name {
            metadata.insert(
                "fluidName".to_string(),
                serde_json::Value::String(fluid.clone()),
            );
        }

        let snapshot = SystemSnapshot {
            version: "1.0".to_string(),
            topology: TopologySnapshot {
                edges,
                thermal_couplings: self.thermal_couplings.clone(),
            },
            parameters,
            fluid_state: None,
            fluid_backend: FluidBackendInfo {
                name: "RuntimeProvided".to_string(),
                version: env!("CARGO_PKG_VERSION").to_string(),
                hash: None,
            },
            solver_config: Some(SolverConfigSnapshot::default()),
            component_names,
            circuit_assignments,
            constraints,
            bounded_variables,
            metadata,
        };

        serde_json::to_string_pretty(&snapshot).map_err(|e| SystemBuilderError::ConfigJsonError {
            reason: format!("JSON serialization failed: {}", e),
        })
    }

    /// Deserializes a builder configuration from a JSON string.
    ///
    /// Reconstructs a `SystemBuilder` from JSON produced by [`to_config_json`](Self::to_config_json).
    /// Components are reconstructed via the component registry — no `ParamsPlaceholder` fallback.
    ///
    /// Fluid backends must be re-assigned after deserialization using
    /// [`with_fluid_backend`](Self::with_fluid_backend) or
    /// [`with_circuit_fluid_backend`](Self::with_circuit_fluid_backend).
    pub fn from_config_json(json: &str) -> Result<Self, SystemBuilderError> {
        use entropyk_components::create_component;
        use entropyk_solver::inverse::{
            BoundedVariable, BoundedVariableId, Constraint, ConstraintId,
        };
        use entropyk_solver::snapshot::SystemSnapshot;

        let snapshot: SystemSnapshot =
            serde_json::from_str(json).map_err(|e| SystemBuilderError::ConfigJsonError {
                reason: format!("Invalid JSON: {}", e),
            })?;

        if snapshot.version != "1.0" {
            return Err(SystemBuilderError::ConfigJsonError {
                reason: format!("Unsupported version '{}', expected '1.0'", snapshot.version),
            });
        }

        let mut builder = SystemBuilder::new();

        // Restore fluid name from metadata
        if let Some(serde_json::Value::String(fluid)) = snapshot.metadata.get("fluidName") {
            builder = builder.with_fluid(fluid.clone());
        }

        // Reconstruct components from parameters, ordered by circuit_assignments for deterministic ordering
        let mut ordered_names: Vec<String> = snapshot.circuit_assignments.keys().cloned().collect();
        ordered_names.sort_by(|a, b| {
            let ca = snapshot.circuit_assignments.get(a).copied().unwrap_or(0);
            let cb = snapshot.circuit_assignments.get(b).copied().unwrap_or(0);
            ca.cmp(&cb).then_with(|| a.cmp(b))
        });

        for name in &ordered_names {
            let params = snapshot.parameters.get(name).ok_or_else(|| {
                SystemBuilderError::ConfigJsonError {
                    reason: format!("Missing parameters for component '{}'", name),
                }
            })?;

            let component =
                create_component(params).map_err(|e| SystemBuilderError::ConfigJsonError {
                    reason: format!(
                        "Failed to reconstruct component '{}' (type '{}'): {}",
                        name, params.component_type, e
                    ),
                })?;

            let circuit_id = snapshot.circuit_assignments.get(name).copied().unwrap_or(0);

            builder = builder
                .component_in_circuit(name, component, entropyk_core::CircuitId(circuit_id))
                .map_err(|e| SystemBuilderError::ConfigJsonError {
                    reason: format!("Failed to add component '{}': {}", name, e),
                })?;
        }

        // Reconstruct edges — use edge_with_ports only for real named ports,
        // fall back to edge() for synthetic port names (port_0, port_1, etc.)
        for edge in &snapshot.topology.edges {
            let has_real_ports = !edge.source_port.is_empty()
                && !edge.target_port.is_empty()
                && !edge.source_port.starts_with("port_")
                && !edge.target_port.starts_with("port_");

            if has_real_ports {
                builder = builder
                    .edge_with_ports(
                        &edge.source,
                        &edge.source_port,
                        &edge.target,
                        &edge.target_port,
                    )
                    .map_err(|e| SystemBuilderError::ConfigJsonError {
                        reason: format!(
                            "Failed to create edge '{}' -> '{}': {}",
                            edge.source, edge.target, e
                        ),
                    })?;
            } else {
                builder = builder.edge(&edge.source, &edge.target).map_err(|e| {
                    SystemBuilderError::ConfigJsonError {
                        reason: format!(
                            "Failed to create edge '{}' -> '{}': {}",
                            edge.source, edge.target, e
                        ),
                    }
                })?;
            }
        }

        // Store thermal couplings for deferred application during build()
        builder.thermal_couplings = snapshot.topology.thermal_couplings.clone();

        // Reconstruct constraints
        for cs in &snapshot.constraints {
            let output = parse_component_output(&cs.output_type, &cs.component);
            let constraint = Constraint::new(ConstraintId::new(&cs.id), output, cs.target);
            builder = builder.with_constraint(constraint).map_err(|e| {
                SystemBuilderError::ConfigJsonError {
                    reason: format!("Failed to restore constraint '{}': {}", cs.id, e),
                }
            })?;
        }

        // Reconstruct bounded variables
        for bvs in &snapshot.bounded_variables {
            let var = BoundedVariable::new(
                BoundedVariableId::new(&bvs.id),
                bvs.initial_value,
                bvs.lower_bound,
                bvs.upper_bound,
            )
            .map_err(|e| SystemBuilderError::ConfigJsonError {
                reason: format!("Failed to restore bounded variable '{}': {}", bvs.id, e),
            })?;
            builder = builder.with_bounded_variable(var).map_err(|e| {
                SystemBuilderError::ConfigJsonError {
                    reason: format!("Failed to add bounded variable '{}': {}", bvs.id, e),
                }
            })?;
        }

        Ok(builder)
    }

    /// Saves the builder configuration to a JSON file.
    pub fn save_config_json(&self, path: &std::path::Path) -> Result<(), SystemBuilderError> {
        let json = self.to_config_json()?;
        std::fs::write(path, json).map_err(|e| SystemBuilderError::ConfigJsonError {
            reason: format!("Failed to write file '{}': {}", path.display(), e),
        })
    }

    /// Loads a builder configuration from a JSON file.
    pub fn load_config_json(path: &std::path::Path) -> Result<Self, SystemBuilderError> {
        let json =
            std::fs::read_to_string(path).map_err(|e| SystemBuilderError::ConfigJsonError {
                reason: format!("Failed to read file '{}': {}", path.display(), e),
            })?;
        Self::from_config_json(&json)
    }

    /// Builds and finalizes the system.
    ///
    /// This method consumes the builder and returns a finalized [`entropyk_solver::System`]
    /// ready for solving.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The system is empty (no components)
    /// - Finalization fails (e.g., invalid topology)
    /// - Constraint/control DoF validation fails — call
    ///   [`validate_inverse_control_dof()`](entropyk_solver::System::validate_inverse_control_dof)
    ///   after building to check that constraint and control degrees of freedom are balanced.
    pub fn build(self) -> Result<entropyk_solver::System, ThermoError> {
        if self.component_names.is_empty() {
            return Err(ThermoError::Builder(SystemBuilderError::EmptySystem));
        }

        let mut system = self.system;
        system.finalize()?;

        // Apply deferred thermal couplings
        for coupling in self.thermal_couplings {
            system.add_thermal_coupling(coupling).map_err(|e| {
                ThermoError::Builder(SystemBuilderError::ThermalCouplingFailed {
                    reason: e.to_string(),
                })
            })?;
        }

        // Propagate fluid backends to components
        for idx in self.component_names.values() {
            let circuit = system.node_circuit(*idx).0;
            let backend = self
                .circuit_backends
                .get(&circuit)
                .or(self.default_backend.as_ref());
            if let Some(backend) = backend {
                if let Some(comp) = system.component_mut(*idx) {
                    comp.set_fluid_backend_from_builder(Arc::clone(backend));
                }
            }
        }

        Ok(system)
    }

    /// Builds the system without finalizing.
    ///
    /// Use this when you need to perform additional operations
    /// that require an unfinalized system.
    pub fn build_unfinalized(self) -> Result<entropyk_solver::System, SystemBuilderError> {
        if self.component_names.is_empty() {
            return Err(SystemBuilderError::EmptySystem);
        }

        Ok(self.system)
    }
}

fn parse_component_output(
    type_name: &str,
    component_id: &str,
) -> entropyk_solver::inverse::ComponentOutput {
    use entropyk_solver::inverse::ComponentOutput;
    match type_name {
        "saturationTemperature" => ComponentOutput::SaturationTemperature {
            component_id: component_id.to_string(),
        },
        "superheat" => ComponentOutput::Superheat {
            component_id: component_id.to_string(),
        },
        "subcooling" => ComponentOutput::Subcooling {
            component_id: component_id.to_string(),
        },
        "heatTransferRate" => ComponentOutput::HeatTransferRate {
            component_id: component_id.to_string(),
        },
        "capacity" => ComponentOutput::Capacity {
            component_id: component_id.to_string(),
        },
        "massFlowRate" => ComponentOutput::MassFlowRate {
            component_id: component_id.to_string(),
        },
        "pressure" => ComponentOutput::Pressure {
            component_id: component_id.to_string(),
        },
        "temperature" => ComponentOutput::Temperature {
            component_id: component_id.to_string(),
        },
        _ => ComponentOutput::Superheat {
            component_id: component_id.to_string(),
        },
    }
}

impl Default for SystemBuilder {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use entropyk_components::ComponentError;

    struct MockComponent {
        n_eqs: usize,
    }

    impl entropyk_components::Component for MockComponent {
        fn compute_residuals(
            &self,
            _state: &[f64],
            _residuals: &mut entropyk_components::ResidualVector,
        ) -> Result<(), ComponentError> {
            Ok(())
        }

        fn jacobian_entries(
            &self,
            _state: &[f64],
            _jacobian: &mut entropyk_components::JacobianBuilder,
        ) -> Result<(), ComponentError> {
            Ok(())
        }

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

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

    #[test]
    fn test_builder_creates_system() {
        let builder = SystemBuilder::new();
        assert_eq!(builder.component_count(), 0);
        assert_eq!(builder.edge_count(), 0);
    }

    #[test]
    fn test_add_component() {
        let builder = SystemBuilder::new()
            .component("comp1", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap();

        assert_eq!(builder.component_count(), 1);
    }

    #[test]
    fn test_duplicate_component_error() {
        let result = SystemBuilder::new()
            .component("comp", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .component("comp", Box::new(MockComponent { n_eqs: 1 }));

        assert!(result.is_err());
        if let Err(SystemBuilderError::ComponentExists(name)) = result {
            assert_eq!(name, "comp");
        } else {
            panic!("Expected ComponentExists error");
        }
    }

    #[test]
    fn test_add_edge() {
        let builder = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .component("b", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .edge("a", "b")
            .unwrap();

        assert_eq!(builder.edge_count(), 1);
    }

    #[test]
    fn test_edge_missing_component() {
        let result = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .edge("a", "nonexistent");

        assert!(result.is_err());
        if let Err(SystemBuilderError::ComponentNotFound(name)) = result {
            assert_eq!(name, "nonexistent");
        } else {
            panic!("Expected ComponentNotFound error");
        }
    }

    #[test]
    fn test_build_empty_system() {
        let result = SystemBuilder::new().build();
        assert!(result.is_err());
    }

    #[test]
    fn test_default() {
        let builder = SystemBuilder::default();
        assert_eq!(builder.component_count(), 0);
    }

    #[test]
    fn test_component_in_circuit_two_circuits_build() {
        use entropyk_core::CircuitId;

        let system = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("c", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .component_in_circuit("d", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .edge("a", "b")
            .unwrap()
            .edge("c", "d")
            .unwrap()
            .build()
            .expect("build should succeed");

        assert_eq!(system.circuit_count(), 2);
        assert_eq!(system.circuit_nodes(CircuitId::ZERO).count(), 2);
        assert_eq!(system.circuit_nodes(CircuitId(1)).count(), 2);
        assert_eq!(system.node_count(), 4);
        assert_eq!(system.edge_count(), 2);
    }

    #[test]
    fn test_edge_cross_circuit_returns_error() {
        use entropyk_core::CircuitId;

        let result = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .edge("a", "b");

        assert!(result.is_err());
        if let Err(SystemBuilderError::CrossCircuitEdge { from, to }) = result {
            assert_eq!(from, "a");
            assert_eq!(to, "b");
        } else {
            panic!("Expected CrossCircuitEdge error");
        }
    }

    #[test]
    fn test_invalid_circuit_id_returns_error() {
        use entropyk_core::CircuitId;

        let result = SystemBuilder::new().component_in_circuit(
            "a",
            Box::new(MockComponent { n_eqs: 1 }),
            CircuitId(5),
        );

        assert!(result.is_err());
        if let Err(SystemBuilderError::TooManyCircuits { requested }) = result {
            assert_eq!(requested, 5);
        } else {
            panic!("Expected TooManyCircuits error");
        }
    }

    #[test]
    fn test_edge_with_ports_missing_component() {
        let result = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .edge_with_ports("nonexistent", "outlet", "a", "inlet");

        assert!(result.is_err());
        if let Err(SystemBuilderError::ComponentNotFound(name)) = result {
            assert_eq!(name, "nonexistent");
        } else {
            panic!("Expected ComponentNotFound error");
        }
    }

    #[test]
    fn test_edge_with_ports_missing_target() {
        let result = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .edge_with_ports("a", "outlet", "nonexistent", "inlet");

        assert!(result.is_err());
        if let Err(SystemBuilderError::ComponentNotFound(name)) = result {
            assert_eq!(name, "nonexistent");
        } else {
            panic!("Expected ComponentNotFound error");
        }
    }

    #[test]
    fn test_edge_with_ports_cross_circuit_error() {
        use entropyk_core::CircuitId;

        let result = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .edge_with_ports("a", "outlet", "b", "inlet");

        assert!(result.is_err());
        if let Err(SystemBuilderError::CrossCircuitEdge { from, to }) = result {
            assert_eq!(from, "a");
            assert_eq!(to, "b");
        } else {
            panic!("Expected CrossCircuitEdge error");
        }
    }

    #[test]
    fn test_edge_with_ports_convention_based_names() {
        let builder = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .component("b", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .edge_with_ports("a", "outlet", "b", "inlet")
            .expect("edge_with_ports should resolve convention-based port names");

        assert_eq!(builder.edge_count(), 1);
    }

    #[test]
    fn test_edge_with_ports_unknown_port_name() {
        let result = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .component("b", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .edge_with_ports("a", "totally_invalid_port", "b", "inlet");

        assert!(result.is_err());
        if let Err(SystemBuilderError::PortNotFound {
            component,
            port_name,
        }) = result
        {
            assert_eq!(component, "a");
            assert!(
                port_name.starts_with("totally_invalid_port"),
                "port_name should start with the port name, got: {port_name}"
            );
        } else {
            panic!("Expected PortNotFound error");
        }
    }

    #[test]
    fn test_with_constraint_success() {
        use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};

        let constraint = Constraint::new(
            ConstraintId::new("sh"),
            ComponentOutput::Superheat {
                component_id: "evap".to_string(),
            },
            5.0,
        );
        let builder = SystemBuilder::new()
            .component("evap", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .with_constraint(constraint)
            .expect("with_constraint should succeed");

        assert_eq!(builder.component_count(), 1);
    }

    #[test]
    fn test_with_constraint_duplicate_id_error() {
        use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};

        let c1 = Constraint::new(
            ConstraintId::new("sh"),
            ComponentOutput::Superheat {
                component_id: "evap".to_string(),
            },
            5.0,
        );
        let c2 = Constraint::new(
            ConstraintId::new("sh"),
            ComponentOutput::Subcooling {
                component_id: "cond".to_string(),
            },
            3.0,
        );
        let result = SystemBuilder::new()
            .component("evap", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .with_constraint(c1)
            .unwrap()
            .with_constraint(c2);

        assert!(result.is_err());
        if let Err(SystemBuilderError::ConstraintFailed { reason }) = result {
            assert!(reason.contains("Duplicate") || reason.contains("sh"));
        } else {
            panic!("Expected ConstraintFailed error");
        }
    }

    #[test]
    fn test_with_bounded_variable_success() {
        use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};

        let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
        let builder = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .with_bounded_variable(var)
            .expect("with_bounded_variable should succeed");

        assert_eq!(builder.component_count(), 1);
    }

    #[test]
    fn test_with_bounded_variable_duplicate_id_error() {
        use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};

        let var1 = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
        let var2 = BoundedVariable::new(BoundedVariableId::new("valve"), 0.6, 0.0, 1.0).unwrap();
        let result = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .with_bounded_variable(var1)
            .unwrap()
            .with_bounded_variable(var2);

        assert!(result.is_err());
        if let Err(SystemBuilderError::BoundedVariableFailed { reason }) = result {
            assert!(reason.contains("Duplicate") || reason.contains("valve"));
        } else {
            panic!("Expected BoundedVariableFailed error");
        }
    }

    #[test]
    fn test_link_constraint_to_control_success() {
        use entropyk_solver::inverse::{
            BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
        };

        let constraint = Constraint::new(
            ConstraintId::new("sh"),
            ComponentOutput::Superheat {
                component_id: "evap".to_string(),
            },
            5.0,
        );
        let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
        let builder = SystemBuilder::new()
            .component("evap", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .with_constraint(constraint)
            .unwrap()
            .with_bounded_variable(var)
            .unwrap()
            .link_constraint_to_control(&ConstraintId::new("sh"), &BoundedVariableId::new("valve"))
            .expect("link_constraint_to_control should succeed");

        assert_eq!(builder.component_count(), 1);
    }

    #[test]
    fn test_link_constraint_to_control_nonexistent_constraint_error() {
        use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, ConstraintId};

        let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
        let result = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .with_bounded_variable(var)
            .unwrap()
            .link_constraint_to_control(
                &ConstraintId::new("nonexistent_constraint"),
                &BoundedVariableId::new("valve"),
            );

        assert!(result.is_err());
        if let Err(SystemBuilderError::InverseControlDoF { reason }) = result {
            assert!(reason.contains("not found") || reason.contains("Nonexistent"));
        } else {
            panic!("Expected InverseControlDoF error");
        }
    }

    #[test]
    fn test_link_constraint_to_control_nonexistent_bounded_variable_error() {
        use entropyk_solver::inverse::{
            BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
        };

        let constraint = Constraint::new(
            ConstraintId::new("sh"),
            ComponentOutput::Superheat {
                component_id: "evap".to_string(),
            },
            5.0,
        );
        let result = SystemBuilder::new()
            .component("evap", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .with_constraint(constraint)
            .unwrap()
            .link_constraint_to_control(
                &ConstraintId::new("sh"),
                &BoundedVariableId::new("nonexistent_control"),
            );

        assert!(result.is_err());
        if let Err(SystemBuilderError::InverseControlDoF { reason }) = result {
            assert!(reason.contains("not found") || reason.contains("Nonexistent"));
        } else {
            panic!("Expected InverseControlDoF error");
        }
    }

    #[test]
    fn test_thermal_coupling_success() {
        // AC1: thermal_coupling(circuit_a, circuit_b, ua_kw_per_k) with valid circuits
        let builder = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .thermal_coupling(0, 1, 5.0)
            .expect("thermal_coupling should succeed");

        assert_eq!(builder.component_count(), 2);
    }

    #[test]
    fn test_thermal_coupling_same_circuit_rejected() {
        // AC3: SameCircuitCoupling when circuit_a == circuit_b
        let result = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .thermal_coupling(0, 0, 5.0);

        assert!(result.is_err());
        if let Err(SystemBuilderError::SameCircuitCoupling { circuit }) = result {
            assert_eq!(circuit, 0);
        } else {
            panic!("Expected SameCircuitCoupling error");
        }
    }

    #[test]
    fn test_thermal_coupling_empty_circuit_rejected() {
        // AC2: EmptyCircuitCoupling when circuit has no components
        let result = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .thermal_coupling(0, 2, 5.0);

        assert!(result.is_err());
        if let Err(SystemBuilderError::EmptyCircuitCoupling { circuit }) = result {
            assert_eq!(circuit, 2);
        } else {
            panic!("Expected EmptyCircuitCoupling error");
        }
    }

    #[test]
    fn test_thermal_coupling_negative_ua_rejected() {
        let result = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .thermal_coupling(0, 1, -1.0);

        assert!(result.is_err());
        if let Err(SystemBuilderError::ThermalCouplingFailed { reason }) = result {
            assert!(reason.contains("positive"));
        } else {
            panic!("Expected ThermalCouplingFailed error for negative UA");
        }
    }

    #[test]
    fn test_thermal_coupling_built_system_has_coupling() {
        // AC4: Built system contains the coupling
        let system = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("c", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .component_in_circuit("d", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .edge("a", "b")
            .unwrap()
            .edge("c", "d")
            .unwrap()
            .thermal_coupling(0, 1, 5.0)
            .unwrap()
            .build()
            .expect("build should succeed");

        assert_eq!(system.thermal_coupling_count(), 1);
        let coupling = system
            .get_thermal_coupling(0)
            .expect("coupling should exist");
        // AC4 & AC5: 5.0 kW/K = 5000 W/K
        approx::assert_relative_eq!(coupling.ua.to_watts_per_kelvin(), 5000.0, epsilon = 1e-10);
    }

    #[test]
    fn test_thermal_coupling_kw_to_w_conversion() {
        // AC5: kW→W conversion (2.5 kW/K → 2500 W/K)
        let system = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("c", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .component_in_circuit("d", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .edge("a", "b")
            .unwrap()
            .edge("c", "d")
            .unwrap()
            .thermal_coupling(0, 1, 2.5)
            .unwrap()
            .build()
            .expect("build should succeed");

        let coupling = system
            .get_thermal_coupling(0)
            .expect("coupling should exist");
        approx::assert_relative_eq!(coupling.ua.to_watts_per_kelvin(), 2500.0, epsilon = 1e-10);
    }

    #[test]
    fn test_thermal_coupling_empty_first_circuit_rejected() {
        // AC2: First circuit empty
        let result = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .thermal_coupling(0, 1, 5.0);

        assert!(result.is_err());
        if let Err(SystemBuilderError::EmptyCircuitCoupling { circuit }) = result {
            assert_eq!(circuit, 0);
        } else {
            panic!("Expected EmptyCircuitCoupling error for circuit 0");
        }
    }

    // ═══════════════════════════════════════════════════════════════
    // Fluid Backend Assignment Tests (Story 13.7)
    // ═══════════════════════════════════════════════════════════════

    #[test]
    fn test_with_fluid_backend_stores_default() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let builder = SystemBuilder::new().with_fluid_backend(backend);
        assert_eq!(builder.component_count(), 0);
    }

    #[test]
    fn test_with_fluid_backend_chainable() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let builder = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .with_fluid_backend(backend);
        assert_eq!(builder.component_count(), 1);
    }

    #[test]
    fn test_with_circuit_fluid_backend_valid_circuit() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let builder = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .with_circuit_fluid_backend(0, backend)
            .expect("should succeed for valid circuit");
        assert_eq!(builder.component_count(), 1);
    }

    #[test]
    fn test_with_circuit_fluid_backend_empty_circuit_rejected() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let result = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .with_circuit_fluid_backend(2, backend);

        assert!(result.is_err());
        if let Err(SystemBuilderError::FluidBackendAssignmentFailed { reason }) = result {
            assert!(reason.contains("no components"));
        } else {
            panic!("Expected FluidBackendAssignmentFailed");
        }
    }

    #[test]
    fn test_with_circuit_fluid_backend_invalid_circuit_id() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let result = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .with_circuit_fluid_backend(5, backend);

        assert!(result.is_err());
        if let Err(SystemBuilderError::FluidBackendAssignmentFailed { reason }) = result {
            assert!(reason.contains("exceeds maximum"));
        } else {
            panic!("Expected FluidBackendAssignmentFailed");
        }
    }

    #[test]
    fn test_build_propagates_default_backend() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());

        // Use a MockComponent — set_fluid_backend_from_builder is a no-op on it,
        // but the build() should not error
        let system = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .component("b", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .edge("a", "b")
            .unwrap()
            .with_fluid_backend(backend)
            .build()
            .expect("build should succeed with backend");

        assert_eq!(system.node_count(), 2);
    }

    #[test]
    fn test_build_propagates_circuit_backend() {
        use entropyk_core::CircuitId;

        let backend_0: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        let backend_1: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());

        let system = SystemBuilder::new()
            .component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
            .unwrap()
            .edge("a", "b")
            .unwrap()
            .component_in_circuit("c", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .component_in_circuit("d", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
            .unwrap()
            .edge("c", "d")
            .unwrap()
            .with_fluid_backend(Arc::clone(&backend_0))
            .with_circuit_fluid_backend(1, backend_1)
            .expect("circuit backend should succeed")
            .build()
            .expect("build should succeed");

        assert_eq!(system.node_count(), 4);
        assert_eq!(system.circuit_count(), 2);
    }

    #[test]
    fn test_build_no_backend_still_works() {
        let system = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .component("b", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .edge("a", "b")
            .unwrap()
            .build()
            .expect("build without backend should succeed");

        assert_eq!(system.node_count(), 2);
    }

    #[test]
    fn test_build_propagates_backend_to_real_node() {
        use entropyk_components::{port::Port, Node};
        use entropyk_core::{Enthalpy, Pressure};
        use entropyk_fluids::FluidId;

        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());

        // Create internal ports for Node (Disconnected)
        let internal_in = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(300000.0),
            Enthalpy::from_joules_per_kg(400000.0),
        );
        let internal_out = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(290000.0),
            Enthalpy::from_joules_per_kg(410000.0),
        );

        let node_disconnected = Node::new("test_node", internal_in, internal_out);

        // Create external ports and connect the Node
        let external_in = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(300000.0),
            Enthalpy::from_joules_per_kg(400000.0),
        );
        let external_out = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(290000.0),
            Enthalpy::from_joules_per_kg(410000.0),
        );
        let node = node_disconnected
            .connect(external_in, external_out)
            .expect("node should connect");

        // Verify node starts without backend
        assert!(
            !node.has_fluid_backend(),
            "Node should start without backend"
        );

        let system = SystemBuilder::new()
            .component("node_a", Box::new(node))
            .unwrap()
            .component("b", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .edge("node_a", "b")
            .unwrap()
            .with_fluid_backend(backend)
            .build()
            .expect("build with real Node should succeed");

        // The real Node's set_fluid_backend_from_builder was called (not no-op like MockComponent).
        // Build succeeded without panic = backend was accepted.
        let node_idx = system
            .get_component_node("node_a")
            .expect("node should exist");
        let comp = system.component(node_idx);
        assert_eq!(comp.n_equations(), 0, "Node is passive (0 equations)");
        assert_eq!(system.node_count(), 2);
    }

    #[test]
    fn test_to_config_json_empty_builder() {
        let builder = SystemBuilder::new();
        let json = builder
            .to_config_json()
            .expect("empty builder should serialize");
        assert!(json.contains("\"version\": \"1.0\""));
        assert!(json.contains("\"parameters\": {}"));
    }

    #[test]
    fn test_to_config_json_with_fluid_name() {
        let builder = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 1 }))
            .unwrap()
            .with_fluid("R134a");
        let json = builder
            .to_config_json()
            .expect("should serialize with fluid");
        assert!(json.contains("\"fluidName\": \"R134a\""));
    }

    #[test]
    fn test_to_config_json_with_components_and_edges() {
        let builder = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .component("b", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .edge("a", "b")
            .unwrap();
        let json = builder.to_config_json().expect("should serialize");
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["parameters"].as_object().unwrap().len(), 2);
        assert_eq!(parsed["topology"]["edges"].as_array().unwrap().len(), 1);
    }

    #[test]
    fn test_from_config_json_invalid_json() {
        let result = SystemBuilder::from_config_json("not json");
        assert!(result.is_err());
        if let Err(SystemBuilderError::ConfigJsonError { reason }) = result {
            assert!(reason.contains("Invalid JSON"));
        } else {
            panic!("Expected ConfigJsonError");
        }
    }

    #[test]
    fn test_from_config_json_version_mismatch() {
        let json = r#"{"version":"2.0","topology":{"edges":[]},"parameters":{},"fluidBackend":{"name":"X","version":"1.0"}}"#;
        let result = SystemBuilder::from_config_json(json);
        assert!(result.is_err());
        if let Err(SystemBuilderError::ConfigJsonError { reason }) = result {
            assert!(reason.contains("Unsupported version"));
        } else {
            panic!("Expected ConfigJsonError");
        }
    }

    #[test]
    fn test_round_trip_mock_components() {
        let original = SystemBuilder::new()
            .component("a", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .component("b", Box::new(MockComponent { n_eqs: 2 }))
            .unwrap()
            .edge("a", "b")
            .unwrap()
            .with_fluid("R410A");

        let json = original.to_config_json().expect("serialize");
        // MockComponent can't be reconstructed by the registry, but JSON structure is valid
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["parameters"].as_object().unwrap().len(), 2);
        assert!(json.contains("\"fluidName\": \"R410A\""));
    }

    #[test]
    fn test_round_trip_with_constraints_and_bounded_vars() {
        use entropyk_components::port::{FluidId, Port};
        use entropyk_components::Node;
        use entropyk_core::{Enthalpy, Pressure};
        use entropyk_solver::inverse::{
            BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
        };

        let in_p = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(300_000.0),
            Enthalpy::from_joules_per_kg(400_000.0),
        );
        let out_p = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(290_000.0),
            Enthalpy::from_joules_per_kg(410_000.0),
        );
        let node = Node::new("probe", in_p, out_p)
            .connect(
                Port::new(
                    FluidId::new("R134a"),
                    Pressure::from_pascals(300_000.0),
                    Enthalpy::from_joules_per_kg(400_000.0),
                ),
                Port::new(
                    FluidId::new("R134a"),
                    Pressure::from_pascals(290_000.0),
                    Enthalpy::from_joules_per_kg(410_000.0),
                ),
            )
            .expect("connect");

        let original = SystemBuilder::new()
            .component("evap", Box::new(node))
            .unwrap()
            .with_constraint(Constraint::new(
                ConstraintId::new("sh"),
                ComponentOutput::Superheat {
                    component_id: "evap".to_string(),
                },
                5.0,
            ))
            .unwrap()
            .with_bounded_variable(
                BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(),
            )
            .unwrap();

        let json = original.to_config_json().expect("serialize");
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["constraints"].as_array().unwrap().len(), 1);
        assert_eq!(parsed["boundedVariables"].as_array().unwrap().len(), 1);

        let restored = SystemBuilder::from_config_json(&json).expect("deserialize");
        assert_eq!(restored.component_count(), 1);
    }

    #[test]
    fn test_round_trip_multi_circuit_with_thermal_coupling() {
        use entropyk_components::port::{FluidId, Port};
        use entropyk_components::Node;
        use entropyk_core::CircuitId;
        use entropyk_core::{Enthalpy, Pressure};

        let in_p = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(300_000.0),
            Enthalpy::from_joules_per_kg(400_000.0),
        );
        let out_p = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(290_000.0),
            Enthalpy::from_joules_per_kg(410_000.0),
        );

        let make_node = |name: &str| {
            let n = Node::new(name, in_p.clone(), out_p.clone())
                .connect(
                    Port::new(
                        FluidId::new("R134a"),
                        Pressure::from_pascals(300_000.0),
                        Enthalpy::from_joules_per_kg(400_000.0),
                    ),
                    Port::new(
                        FluidId::new("R134a"),
                        Pressure::from_pascals(290_000.0),
                        Enthalpy::from_joules_per_kg(410_000.0),
                    ),
                )
                .expect("connect");
            Box::new(n) as Box<dyn entropyk_components::Component>
        };

        let original = SystemBuilder::new()
            .component_in_circuit("a", make_node("a"), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("b", make_node("b"), CircuitId::ZERO)
            .unwrap()
            .component_in_circuit("c", make_node("c"), CircuitId(1))
            .unwrap()
            .component_in_circuit("d", make_node("d"), CircuitId(1))
            .unwrap()
            .edge("a", "b")
            .unwrap()
            .edge("c", "d")
            .unwrap()
            .thermal_coupling(0, 1, 5.0)
            .unwrap();

        let json = original.to_config_json().expect("serialize");
        let restored = SystemBuilder::from_config_json(&json).expect("deserialize");

        assert_eq!(restored.component_count(), 4);
        assert_eq!(restored.edge_count(), 2);
    }

    #[test]
    fn test_save_load_config_json_file() {
        use entropyk_components::port::{FluidId, Port};
        use entropyk_components::Node;
        use entropyk_core::{Enthalpy, Pressure};

        let internal_in = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(300_000.0),
            Enthalpy::from_joules_per_kg(400_000.0),
        );
        let internal_out = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(290_000.0),
            Enthalpy::from_joules_per_kg(410_000.0),
        );
        let node_disconnected = Node::new("probe", internal_in, internal_out);
        let ext_in = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(300_000.0),
            Enthalpy::from_joules_per_kg(400_000.0),
        );
        let ext_out = Port::new(
            FluidId::new("R134a"),
            Pressure::from_pascals(290_000.0),
            Enthalpy::from_joules_per_kg(410_000.0),
        );
        let node = node_disconnected.connect(ext_in, ext_out).expect("connect");

        let dir = std::env::temp_dir().join("entropyk_test_config.json");
        let original = SystemBuilder::new()
            .component("probe", Box::new(node))
            .unwrap()
            .with_fluid("R134a");

        original.save_config_json(&dir).expect("save");
        let restored = SystemBuilder::load_config_json(&dir).expect("load");

        assert_eq!(restored.component_count(), 1);
        let _ = std::fs::remove_file(&dir);
    }
}
