//! Configuration parsing for CLI scenarios.
//!
//! This module defines the JSON schema for scenario configuration files
//! and provides utilities for loading and validating them.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;

use crate::error::{CliError, CliResult};

/// The current Model IR schema version emitted and accepted by this build.
///
/// The IR is versioned so the schema (and any embedded standard/norm references)
/// can evolve without silently misreading older files. See [`SUPPORTED_SCHEMA_VERSIONS`].
pub const CURRENT_SCHEMA_VERSION: &str = "2";

/// Schema versions this build can load. `"1"` is the original flat
/// `circuits/components/edges` schema; `"2"` adds `controls`, `embeddings`,
/// `subsystems`, `instances` and `connections`. Both are read by the same loader.
pub const SUPPORTED_SCHEMA_VERSIONS: &[&str] = &["1", "2"];

fn default_schema_version() -> String {
    // Absent `schema_version` means a legacy v1 document.
    "1".to_string()
}

/// Root configuration for a simulation scenario.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ScenarioConfig {
    /// Model IR schema version (`"1"` legacy flat graph, `"2"` adds
    /// controls/embeddings/subsystems/instances/connections). Absent ⇒ `"1"`.
    #[serde(default = "default_schema_version")]
    pub schema_version: String,
    /// Scenario name.
    #[serde(default)]
    pub name: Option<String>,
    /// Fluid name (e.g., "R134a", "R410A", "R744").
    pub fluid: String,
    /// Fluid backend to use (e.g., "CoolProp", "Test"). Defaults to "Test".
    #[serde(default)]
    pub fluid_backend: Option<String>,
    /// Circuit configurations.
    #[serde(default)]
    pub circuits: Vec<CircuitConfig>,
    /// Thermal couplings between circuits.
    #[serde(default)]
    pub thermal_couplings: Vec<ThermalCouplingConfig>,
    /// Solver configuration.
    #[serde(default)]
    pub solver: SolverConfig,
    /// Optional solver start-value guesses. These are numerical initialization
    /// hints only; they do not impose thermodynamic boundary conditions.
    #[serde(default)]
    pub initialization: Option<InitializationConfig>,
    /// Steady-state **system regulation** loops (co-solved saturated-PI).
    /// For EXV opening, injection, fan speed, etc. — **not** Z-factor calibration.
    /// Z-factors use [`Self::embeddings`] (Modelica parameter(fixed=false) + equation).
    #[serde(default)]
    pub controls: Vec<ControlConfig>,
    /// Modelica-style Z-factor embeddings: one free unknown + one equation
    /// (`output = value`). Distinct from [`Self::controls`] (no SaturatedController).
    #[serde(default)]
    pub embeddings: Vec<EmbeddingConfig>,
    /// Algebraic model equations: `lhs - rhs = 0` with `+ - * / min max exp ln`.
    ///
    /// Free unknown is either inferred from `lhs` when it is `component.z_*`,
    /// or declared via [`EquationConfig::unknown`] (Probe target form).
    #[serde(default)]
    pub equations: Vec<EquationConfig>,
    /// Reusable subsystem templates (parameterized assemblies of components +
    /// internal edges, exposing a reduced external port set). Flattened into
    /// `circuits` at load time — the solver never sees the hierarchy.
    #[serde(default)]
    pub subsystems: HashMap<String, SubsystemTemplate>,
    /// Instantiations of `subsystems` templates. Each instance is expanded into
    /// concrete, name-prefixed components/edges in its target circuit.
    #[serde(default)]
    pub instances: Vec<InstanceConfig>,
    /// External connections between instance ports (and/or literal `component:port`
    /// endpoints). Resolved and routed into the appropriate circuit during flattening.
    #[serde(default)]
    pub connections: Vec<EdgeConfig>,
    /// Workspace directory containing reusable `.ekmod` module files.
    ///
    /// Each `.ekmod` file is a JSON [`SubsystemTemplate`] whose module name is
    /// derived from the filename (e.g. `BaseChiller.ekmod` → `"BaseChiller"`).
    /// Modules are loaded before [`Self::flatten_instances`] and injected into
    /// every template's `subsystems` map so cross-file references resolve. If
    /// absent, the CLI also checks `.entropyk/modules/` relative to the config
    /// file as a fallback.
    #[serde(default)]
    pub workspace: Option<String>,
    /// Optional metadata.
    #[serde(default)]
    pub metadata: Option<HashMap<String, serde_json::Value>>,
}

/// A reusable subsystem template: a parameterized assembly of components and
/// internal edges, exposing a reduced set of external ports.
///
/// This is Modelica-style `model` composition. Templates are declared once under
/// `subsystems` and instantiated any number of times via `instances`, each with
/// its own parameter overrides. At load time every instance is **flattened** into
/// the flat `circuits/components/edges` graph (component names prefixed by
/// `"{instance}."`), so the solver and every downstream consumer are unchanged.
///
/// # Parameter substitution
/// Any component parameter whose JSON value is a string of the form `"$name"` is
/// replaced by the resolved value of parameter `name` (instance override, falling
/// back to the template default in `params`). Because `ua`, `secondary_*`,
/// `isentropic_efficiency`, `t_cond_k`, … all flow through the component
/// `params` catch-all, this covers essentially every physical knob.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SubsystemTemplate {
    /// Default parameter values, overridable per instance.
    #[serde(default)]
    pub params: HashMap<String, serde_json::Value>,
    /// Components inside the template (names are local to the template).
    pub components: Vec<ComponentConfig>,
    /// Internal edges between the template's own components.
    #[serde(default)]
    pub edges: Vec<EdgeConfig>,
    /// External ports: maps an exposed port name to an internal `"component:port"`
    /// endpoint. `connections` reference these as `"{instance}.{external_port}"`.
    #[serde(default)]
    pub ports: HashMap<String, String>,
    /// Nested subsystem templates (for recursive composition). A template can
    /// instantiate other templates declared in this map via [`Self::instances`].
    /// Flattened recursively at load time — the solver never sees the nesting.
    #[serde(default)]
    pub subsystems: HashMap<String, SubsystemTemplate>,
    /// Instantiations of nested [`Self::subsystems`] templates. Each instance is
    /// expanded recursively, with component names prefixed by `"{name}."`.
    #[serde(default)]
    pub instances: Vec<InstanceConfig>,
    /// External connections between nested instances (and/or the template's own
    /// atomic components). Endpoints use `"instance.external_port"` or
    /// `"component:port"` syntax, resolved during recursive flattening.
    #[serde(default)]
    pub connections: Vec<EdgeConfig>,
}

/// An instantiation of a [`SubsystemTemplate`].
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct InstanceConfig {
    /// Name of the template to instantiate (key in `subsystems`).
    #[serde(rename = "of")]
    pub template: String,
    /// Unique instance name; becomes the `"{name}."` prefix on expanded components.
    pub name: String,
    /// Target circuit id the expanded components/edges are placed into (default 0).
    #[serde(default)]
    pub circuit: usize,
    /// Parameter overrides for this instance (override template defaults).
    #[serde(default)]
    pub params: HashMap<String, serde_json::Value>,
}

/// Modelica-style Z-factor embedding: free unknown + binding equation.
///
/// Equivalent to:
/// ```text
/// parameter Real z(fixed = false, start = …);
/// equation
///   component.output = value;
/// ```
/// Registered as plain `Constraint` + `BoundedVariable` — never a SaturatedController.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EmbeddingConfig {
    /// Unique id (also used as the constraint id).
    pub id: String,
    /// Free Z-factor unknown on a component.
    pub unknown: EmbeddingUnknownConfig,
    /// Binding equation: `component.output = value`.
    pub equation: EmbeddingEquationConfig,
}

/// Free Z-factor unknown (`parameter ...(fixed=false)`).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EmbeddingUnknownConfig {
    /// Component owning the Z-factor.
    pub component: String,
    /// Factor name: `z_ua`, `z_dp`, `z_flow`, `z_power`, `z_etav`, `f_w`, …
    pub factor: String,
    /// Start / guess value.
    #[serde(default = "default_actuator_initial")]
    pub start: f64,
    /// Lower bound.
    pub min: f64,
    /// Upper bound.
    pub max: f64,
}

/// Binding equation residual: `component.output − value = 0`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EmbeddingEquationConfig {
    /// Component providing the measured output (typically a Probe).
    pub component: String,
    /// Output kind: `saturationTemperature`, `temperature`, `pressure`, …
    pub output: String,
    /// Right-hand side value (SI).
    pub value: f64,
}

/// Algebraic model equation with a minimal expression language.
///
/// ```text
/// lhs = rhs;   // residual: lhs − rhs = 0
/// ```
///
/// Supported ops: `+ - * /`, parentheses, unary `-`, `min`, `max`, `exp`, `ln`/`log`.
/// References: `component.field` or `"Component Name".field`
/// (`field` = `z_ua`, `T`, `Tsat`, `P`, …).
///
/// **Form A** — free Z-factor on the left:
/// ```json
/// { "id": "eq1", "lhs": "evap.z_ua", "rhs": "min(1, exp(probe.T/300))",
///   "start": 0.3, "min": 0.05, "max": 2.0 }
/// ```
///
/// **Form B** — Probe target with explicit unknown (same role as embeddings[]):
/// ```json
/// { "id": "eq2", "lhs": "probe.Tsat", "rhs": "278.15",
///   "unknown": { "component": "evap", "factor": "z_ua", "start": 0.3, "min": 0.05, "max": 2.0 } }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EquationConfig {
    /// Unique id (also used as the residual / link id).
    pub id: String,
    /// Left-hand side expression.
    pub lhs: String,
    /// Right-hand side expression.
    pub rhs: String,
    /// Explicit free unknown (required when `lhs` is not itself a Z-factor ref).
    #[serde(default)]
    pub unknown: Option<EmbeddingUnknownConfig>,
    /// Start guess when the unknown is inferred from `lhs` (Form A).
    #[serde(default = "default_actuator_initial")]
    pub start: f64,
    /// Lower bound when the unknown is inferred from `lhs` (Form A).
    #[serde(default = "default_equation_min")]
    pub min: f64,
    /// Upper bound when the unknown is inferred from `lhs` (Form A).
    #[serde(default = "default_equation_max")]
    pub max: f64,
}

fn default_equation_min() -> f64 {
    0.05
}
fn default_equation_max() -> f64 {
    2.0
}

/// A steady-state **system regulation** loop (EXV, injection, fan, …).
///
/// Supports `SaturatedController`: saturated-PI with anti-windup, co-solved
/// inside Newton. **Not** for Z-factor calibration — use [`EmbeddingConfig`].
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ControlConfig {
    /// Controller type. Only `"SaturatedController"` is supported today.
    #[serde(rename = "type", default = "default_control_type")]
    pub control_type: String,
    /// Unique identifier for this control loop.
    pub id: String,
    /// The measured plant output to regulate.
    pub measure: MeasureConfig,
    /// The manipulated actuator.
    pub actuator: ActuatorConfig,
    /// Target value for the measured output (SI units: W, K, Pa, kg/s).
    pub target: f64,
    /// Loop gain `K` (sign must match actuator→output sensitivity).
    #[serde(default = "default_control_gain")]
    pub gain: f64,
    /// Saturation band half-width `Q > 0`.
    #[serde(default = "default_control_band")]
    pub band: f64,
    /// If set, uses the C¹ smooth saturation with this `eps`; otherwise hard clamp.
    #[serde(default)]
    pub smooth_eps: Option<f64>,
    /// Optional **override / selector** objectives. When non-empty, the primary
    /// `measure`/`target`/`gain` above becomes the first objective and each entry
    /// here is folded in via its `combine` (`min`/`max`) selector — one actuator
    /// driven by several competing objectives (setpoint + envelope protections).
    /// The fold order encodes priority (later = higher priority).
    #[serde(default)]
    pub objectives: Vec<ObjectiveConfig>,
    /// Selector smoothing sharpness for the override network (`softMin`/`softMax`).
    /// Smaller ⇒ sharper selection, larger ⇒ smoother/more robust. Default `1e-3`.
    #[serde(default)]
    pub alpha: Option<f64>,
}

/// One objective in an override/selector control network.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ObjectiveConfig {
    /// Name of the component whose output is measured.
    pub component: String,
    /// Output kind: one of `capacity`, `heatTransferRate`, `superheat`,
    /// `subcooling`, `saturationTemperature`, `massFlowRate`, `pressure`,
    /// `temperature`.
    pub output: String,
    /// Target value for this objective (SI units: W, K, Pa, kg/s).
    pub setpoint: f64,
    /// Normalization/sign gain for this objective's error `gain·(setpoint−measure)`.
    #[serde(default = "default_control_gain")]
    pub gain: f64,
    /// Selector applied between the running accumulator and this objective:
    /// `"min"` or `"max"`.
    pub combine: String,
}

/// Reference to a measurable component output.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MeasureConfig {
    /// Name of the component whose output is measured.
    pub component: String,
    /// Output kind: one of `capacity`, `heatTransferRate`, `superheat`,
    /// `subcooling`, `saturationTemperature`, `massFlowRate`, `pressure`,
    /// `temperature`.
    pub output: String,
}

/// Reference to a manipulated **physical** actuator on a component.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ActuatorConfig {
    /// Name of the component carrying the actuator.
    pub component: String,
    /// Physical actuator factor for regulation: `opening`, `injection`, …
    /// Z-factors (`z_ua`, …) must use [`EmbeddingConfig`], not controls.
    pub factor: String,
    /// Initial actuator value (nominal, e.g. 1.0).
    #[serde(default = "default_actuator_initial")]
    pub initial: f64,
    /// Lower bound for the actuator.
    pub min: f64,
    /// Upper bound for the actuator.
    pub max: f64,
}

fn default_control_type() -> String {
    "SaturatedController".to_string()
}

fn default_control_gain() -> f64 {
    1.0
}

fn default_control_band() -> f64 {
    1.0
}

fn default_actuator_initial() -> f64 {
    1.0
}

/// Thermal coupling configuration between two circuits.
///
/// A coupling is **physical** only when both `hot_component` and
/// `cold_component` are set: the hot component's measured duty (e.g. a real
/// ε-NTU `Condenser` capacity) is transferred, scaled by `efficiency` and
/// `duty_scale`, into the cold circuit's receiver energy balance. Without them the coupling
/// is an inert legacy stub (Q pinned to 0) and the CLI warns.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ThermalCouplingConfig {
    /// Hot circuit ID.
    pub hot_circuit: usize,
    /// Cold circuit ID.
    pub cold_circuit: usize,
    /// Thermal conductance in W/K.
    pub ua: f64,
    /// Heat exchanger efficiency (0.0 to 1.0).
    #[serde(default = "default_efficiency")]
    pub efficiency: f64,
    /// Signed multiplier applied to the measured duty before injection.
    ///
    /// Use `1.0` for condenser water heating and `-1.0` for chilled-water
    /// cooling from an evaporator duty.
    #[serde(default = "default_duty_scale")]
    pub duty_scale: f64,
    /// Name of the hot-circuit component whose measured duty is transferred
    /// (e.g. a `Condenser` with a configured secondary stream).
    #[serde(default)]
    pub hot_component: Option<String>,
    /// Name of the cold-circuit `ThermalLoad` component that receives the heat.
    #[serde(default)]
    pub cold_component: Option<String>,
}

fn default_efficiency() -> f64 {
    0.95
}

fn default_duty_scale() -> f64 {
    1.0
}

/// Configuration for a single circuit.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CircuitConfig {
    /// Circuit ID (default: 0).
    #[serde(default)]
    pub id: usize,
    /// Whether this circuit is energized (default: true). A disabled circuit is
    /// staged OFF: its components, edges and any thermal coupling that references
    /// it are excluded from the solve entirely (zero mass flow, zero duty), which
    /// is the physically correct steady-state behaviour for an unenergized
    /// refrigerant circuit. Used for multi-circuit part-load staging (e.g. running
    /// only circuit A of a dual-circuit chiller).
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Components in this circuit.
    pub components: Vec<ComponentConfig>,
    /// Edge connections between components.
    #[serde(default)]
    pub edges: Vec<EdgeConfig>,
    /// Initial state for edges.
    #[serde(default)]
    pub initial_state: Option<InitialStateConfig>,
}

fn default_true() -> bool {
    true
}

/// Configuration for a component.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ComponentConfig {
    /// Component type (e.g., "Compressor", "Condenser", "Evaporator", "ExpansionValve", "HeatExchanger").
    #[serde(rename = "type")]
    pub component_type: String,
    /// Component name for referencing in edges.
    pub name: String,

    // --- MchxCondenserCoil Specific Fields ---
    /// Nominal UA value (kW/K). Maps to ua_nominal_kw_k.
    #[serde(default)]
    pub ua_nominal_kw_k: Option<f64>,
    /// Fan speed ratio (0.0 to 1.0).
    #[serde(default)]
    pub fan_speed: Option<f64>,
    /// Air inlet temperature in Celsius.
    #[serde(default)]
    pub air_inlet_temp_c: Option<f64>,
    /// Air side heat transfer exponent.
    #[serde(default)]
    pub n_air_exponent: Option<f64>,
    /// Condenser bank spec identifier (used for creating multiple instances).
    #[serde(default)]
    pub condenser_bank: Option<CondenserBankConfig>,
    // -----------------------------------------
    /// Component-specific parameters (catch-all).
    #[serde(flatten)]
    pub params: HashMap<String, serde_json::Value>,
}

/// Configuration for a condenser bank (multi-circuit, multi-coil).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CondenserBankConfig {
    /// Number of circuits.
    pub circuits: usize,
    /// Number of coils per circuit.
    pub coils_per_circuit: usize,
}

/// Side conditions for a heat exchanger (hot or cold fluid).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SideConditionsConfig {
    /// Fluid name (e.g., "R134a", "Water", "Air").
    pub fluid: String,
    /// Inlet temperature in °C.
    pub t_inlet_c: f64,
    /// Pressure in bar.
    #[serde(default = "default_pressure")]
    pub pressure_bar: f64,
    /// Mass flow rate in kg/s.
    #[serde(default = "default_mass_flow")]
    pub mass_flow_kg_s: f64,
}

fn default_pressure() -> f64 {
    1.0
}

fn default_mass_flow() -> f64 {
    0.1
}

/// Compressor AHRI 540 coefficients configuration.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Ahri540Config {
    /// Flow coefficient M1.
    pub m1: f64,
    /// Pressure ratio exponent M2.
    pub m2: f64,
    /// Power coefficients M3-M6 (cooling) and M7-M10 (heating).
    #[serde(default)]
    pub m3: Option<f64>,
    #[serde(default)]
    pub m4: Option<f64>,
    #[serde(default)]
    pub m5: Option<f64>,
    #[serde(default)]
    pub m6: Option<f64>,
    #[serde(default)]
    pub m7: Option<f64>,
    #[serde(default)]
    pub m8: Option<f64>,
    #[serde(default)]
    pub m9: Option<f64>,
    #[serde(default)]
    pub m10: Option<f64>,
}

/// Configuration for an edge between components.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EdgeConfig {
    /// Source component and port (e.g., "comp1:outlet").
    pub from: String,
    /// Target component and port (e.g., "cond1:inlet").
    pub to: String,
}

/// Initial state configuration.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct InitialStateConfig {
    /// Initial pressure in bar.
    pub pressure_bar: Option<f64>,
    /// Initial enthalpy in kJ/kg.
    pub enthalpy_kj_kg: Option<f64>,
    /// Initial temperature in Kelvin.
    pub temperature_k: Option<f64>,
}

/// Explicit numerical start-value guesses for system initialization.
///
/// These fields are intentionally top-level so design/start guesses do not look
/// like physical component inputs. Legacy component-level fields (`t_cond_k`,
/// `t_evap_k`, `superheat_k`) are still accepted as compatibility shims.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct InitializationConfig {
    /// Initial condensing saturation temperature guess [K].
    #[serde(default)]
    pub t_cond_guess_k: Option<f64>,
    /// Initial evaporating saturation temperature guess [K].
    #[serde(default)]
    pub t_evap_guess_k: Option<f64>,
    /// Initial suction superheat guess [K].
    #[serde(default)]
    pub superheat_guess_k: Option<f64>,
}

/// Solver configuration.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SolverConfig {
    /// Solver strategy: "newton" or "picard".
    #[serde(default = "default_solver_strategy")]
    pub strategy: String,
    /// Dense linear backend: "nalgebra" (default) or "faer" (Story 1.5 opt-in).
    #[serde(default = "default_linear_backend")]
    pub linear_backend: String,
    /// Maximum iterations.
    #[serde(default = "default_max_iterations")]
    pub max_iterations: usize,
    /// Convergence tolerance.
    #[serde(default = "default_tolerance")]
    pub tolerance: f64,
    /// Timeout in milliseconds (0 = no timeout).
    #[serde(default)]
    pub timeout_ms: u64,
    /// Enable verbose output.
    #[serde(default)]
    pub verbose: bool,
}

fn default_solver_strategy() -> String {
    "newton".to_string()
}

fn default_linear_backend() -> String {
    "nalgebra".to_string()
}

fn default_max_iterations() -> usize {
    100
}

fn default_tolerance() -> f64 {
    1e-6
}

impl Default for SolverConfig {
    fn default() -> Self {
        Self {
            strategy: default_solver_strategy(),
            linear_backend: default_linear_backend(),
            max_iterations: default_max_iterations(),
            tolerance: default_tolerance(),
            timeout_ms: 0,
            verbose: false,
        }
    }
}

impl ScenarioConfig {
    /// Load a scenario configuration from a file.
    pub fn from_file(path: &std::path::Path) -> CliResult<Self> {
        let content = std::fs::read_to_string(path).map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                CliError::ConfigNotFound(path.to_path_buf())
            } else {
                CliError::Io(e)
            }
        })?;

        let config: Self = serde_json::from_str(&content).map_err(CliError::InvalidConfig)?;

        let mut config = config;
        // Auto-load workspace modules (.ekmod files) before flattening.
        config.load_workspace_auto(Some(path))?;
        config.flatten_instances()?;
        config.validate()?;

        Ok(config)
    }

    /// Load a scenario configuration from a JSON string.
    pub fn from_json(json: &str) -> CliResult<Self> {
        let config: Self = serde_json::from_str(json).map_err(CliError::InvalidConfig)?;
        let mut config = config;
        // For from_json, only explicit `workspace` field is checked (no file
        // path context for auto-discovery of .entropyk/modules/).
        config.load_workspace_auto::<&std::path::Path>(None)?;
        config.flatten_instances()?;
        config.validate()?;
        Ok(config)
    }

    /// Emit the canonical Model IR JSON Schema (draft-07) as a pretty JSON string.
    ///
    /// The schema is derived directly from the Rust `ScenarioConfig` structs via
    /// `schemars`, so it can never drift from what the loader accepts. This is the
    /// single source of truth the UI, external tooling, and future Python/WASM
    /// bindings validate against. Exposed through the CLI `schema` command.
    pub fn json_schema() -> String {
        let schema = schemars::schema_for!(ScenarioConfig);
        serde_json::to_string_pretty(&schema)
            .unwrap_or_else(|e| format!("{{\"error\":\"failed to serialize schema: {e}\"}}"))
    }

    /// Load reusable `.ekmod` module files from a workspace directory and merge
    /// them into [`Self::subsystems`].
    ///
    /// Each `.ekmod` file is a JSON [`SubsystemTemplate`]; the module name is the
    /// filename without extension (e.g. `BaseChiller.ekmod` → `"BaseChiller"`).
    /// If a subsystem with the same name already exists in the config, the
    /// workspace module is skipped (inline definitions take precedence).
    pub fn load_workspace(&mut self, dir: &Path) -> CliResult<()> {
        if !dir.is_dir() {
            return Err(CliError::Config(format!(
                "workspace directory '{}' does not exist or is not a directory",
                dir.display()
            )));
        }
        let mut loaded = 0;
        for entry in std::fs::read_dir(dir)? {
            let path = entry?.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ekmod") {
                continue;
            }
            let name = path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
                CliError::Config(format!("invalid .ekmod filename: '{}'", path.display()))
            })?;
            // Inline subsystems take precedence over workspace files.
            if self.subsystems.contains_key(name) {
                tracing::debug!(module = name, "workspace module skipped (inline override)");
                continue;
            }
            let json = std::fs::read_to_string(&path)?;
            let template: SubsystemTemplate = serde_json::from_str(&json).map_err(|e| {
                CliError::Config(format!(
                    "failed to parse workspace module '{}': {}",
                    path.display(),
                    e
                ))
            })?;
            self.subsystems.insert(name.to_string(), template);
            loaded += 1;
        }
        if loaded > 0 {
            tracing::info!(loaded, dir = ?dir, "Loaded workspace modules");
        }
        Ok(())
    }

    /// Auto-discover and load a workspace directory. Checks, in order:
    /// 1. The `workspace` field in the config (if set)
    /// 2. `.entropyk/modules/` relative to `config_path` (if given)
    ///
    /// Silently skips if neither exists (no workspace = no extra modules).
    pub fn load_workspace_auto<P: AsRef<Path>>(&mut self, config_path: Option<P>) -> CliResult<()> {
        // 1. Explicit workspace path
        let ws_path = self.workspace.clone();
        if let Some(ref ws) = ws_path {
            return self.load_workspace(Path::new(ws));
        }
        // 2. Auto-discover .entropyk/modules/ next to the config file
        if let Some(p) = config_path {
            if let Some(parent) = p.as_ref().parent() {
                let auto = parent.join(".entropyk").join("modules");
                if auto.is_dir() {
                    return self.load_workspace(&auto);
                }
            }
        }
        Ok(())
    }

    /// Flatten every [`InstanceConfig`] into concrete circuits.
    ///
    /// Each instance's components are cloned with names prefixed by `"{instance}."`,
    /// parameter references (`"$name"`) are substituted, internal edges are rewritten
    /// with the prefixed names, and the result is merged into the target circuit.
    /// External `connections` referencing `"{instance}.{external_port}"` are resolved
    /// via each template's `ports` map and routed into the circuit of their source
    /// endpoint. After this pass, `subsystems`/`instances`/`connections` are consumed
    /// and the config is an ordinary flat `circuits` graph — the solver never sees the
    /// hierarchy. A no-op when `instances` and `connections` are both empty.
    pub fn flatten_instances(&mut self) -> CliResult<()> {
        if self.instances.is_empty() && self.connections.is_empty() {
            return Ok(());
        }

        // The top-level `subsystems` map serves as the workspace registry for
        // recursive flattening: when a nested template's `instances` reference
        // a subsystem by name, the resolver first checks the template's own
        // `subsystems` map, then falls back to this top-level registry. This
        // lets `.ekmod` files cross-reference each other without cloning.

        // Recursively flatten nested templates first: a SubsystemTemplate may
        // itself contain subsystems/instances/connections. After this pass,
        // every template in self.subsystems is flat (only atomic components).
        let workspace = self.subsystems.clone();
        for tmpl in self.subsystems.values_mut() {
            *tmpl = flatten_template_recursive_with_workspace(tmpl, &workspace)?;
        }

        // Expand each instance into its target circuit. Collect first (immutable
        // borrow of templates), then apply (mutable borrow of circuits).
        struct Expansion {
            circuit: usize,
            components: Vec<ComponentConfig>,
            edges: Vec<EdgeConfig>,
        }
        let mut expansions = Vec::with_capacity(self.instances.len());
        for inst in &self.instances {
            let tmpl = self.subsystems.get(&inst.template).ok_or_else(|| {
                CliError::Config(format!(
                    "instance '{}' references unknown subsystem template '{}'. Available: {}",
                    inst.name,
                    inst.template,
                    self.subsystems
                        .keys()
                        .cloned()
                        .collect::<Vec<_>>()
                        .join(", ")
                ))
            })?;

            // Resolve params: template defaults overlaid by instance overrides.
            let mut params = tmpl.params.clone();
            for (k, v) in &inst.params {
                params.insert(k.clone(), v.clone());
            }

            let prefix = format!("{}.", inst.name);

            let mut new_components = Vec::with_capacity(tmpl.components.len());
            for comp in &tmpl.components {
                let mut c = comp.clone();
                c.name = format!("{}{}", prefix, comp.name);
                substitute_component_params(&mut c, &params).map_err(|missing| {
                    CliError::Config(format!(
                        "instance '{}' component '{}' references undefined parameter '${}'",
                        inst.name, comp.name, missing
                    ))
                })?;
                new_components.push(c);
            }

            let mut new_edges = Vec::with_capacity(tmpl.edges.len());
            for edge in &tmpl.edges {
                new_edges.push(EdgeConfig {
                    from: prefix_internal_endpoint(&edge.from, &prefix),
                    to: prefix_internal_endpoint(&edge.to, &prefix),
                });
            }

            expansions.push(Expansion {
                circuit: inst.circuit,
                components: new_components,
                edges: new_edges,
            });
        }

        for exp in expansions {
            let circuit = self.circuit_mut(exp.circuit);
            circuit.components.extend(exp.components);
            circuit.edges.extend(exp.edges);
        }

        // Resolve external connections (instance port refs or literal component:port).
        let connections = std::mem::take(&mut self.connections);
        let mut resolved = Vec::with_capacity(connections.len());
        for conn in &connections {
            let (from_ep, from_circuit) = self.resolve_external_endpoint(&conn.from)?;
            let (to_ep, _to_circuit) = self.resolve_external_endpoint(&conn.to)?;
            resolved.push((from_circuit, from_ep, to_ep));
        }
        for (circuit, from, to) in resolved {
            self.circuit_mut(circuit)
                .edges
                .push(EdgeConfig { from, to });
        }

        Ok(())
    }

    /// Get a mutable reference to the circuit with `id`, creating it if absent.
    fn circuit_mut(&mut self, id: usize) -> &mut CircuitConfig {
        if let Some(pos) = self.circuits.iter().position(|c| c.id == id) {
            return &mut self.circuits[pos];
        }
        self.circuits.push(CircuitConfig {
            id,
            enabled: true,
            components: Vec::new(),
            edges: Vec::new(),
            initial_state: None,
        });
        let last = self.circuits.len() - 1;
        &mut self.circuits[last]
    }

    /// Resolve a connection endpoint to a concrete `"component:port"` string and the
    /// circuit id it belongs to.
    ///
    /// - A literal `"component:port"` endpoint is returned unchanged; its circuit is
    ///   inferred from the component name (falling back to circuit 0).
    /// - An external port reference `"{instance}.{external_port}"` (no colon) is
    ///   resolved through the instance's template `ports` map into
    ///   `"{instance}.{internal_component}:{port}"`.
    fn resolve_external_endpoint(&self, endpoint: &str) -> CliResult<(String, usize)> {
        if endpoint.contains(':') {
            let comp = endpoint.split(':').next().unwrap_or("");
            let circuit = self.circuit_of_component(comp).unwrap_or(0);
            return Ok((endpoint.to_string(), circuit));
        }

        let (inst_name, port_name) = endpoint.split_once('.').ok_or_else(|| {
            CliError::Config(format!(
                "connection endpoint '{}' must be 'component:port' or 'instance.external_port'",
                endpoint
            ))
        })?;

        let inst = self
            .instances
            .iter()
            .find(|i| i.name == inst_name)
            .ok_or_else(|| {
                CliError::Config(format!(
                    "connection endpoint '{}' references unknown instance '{}'",
                    endpoint, inst_name
                ))
            })?;

        let tmpl = self.subsystems.get(&inst.template).ok_or_else(|| {
            CliError::Config(format!(
                "instance '{}' references unknown subsystem template '{}'",
                inst.name, inst.template
            ))
        })?;

        let internal = tmpl.ports.get(port_name).ok_or_else(|| {
            CliError::Config(format!(
                "instance '{}' (template '{}') exposes no external port '{}'. Available: {}",
                inst_name,
                inst.template,
                port_name,
                tmpl.ports.keys().cloned().collect::<Vec<_>>().join(", ")
            ))
        })?;

        Ok((format!("{}.{}", inst_name, internal), inst.circuit))
    }

    /// Find which circuit contains a component (by name).
    fn circuit_of_component(&self, name: &str) -> Option<usize> {
        self.circuits
            .iter()
            .find(|c| c.components.iter().any(|comp| comp.name == name))
            .map(|c| c.id)
    }

    /// Validate the configuration.
    pub fn validate(&self) -> CliResult<()> {
        if !SUPPORTED_SCHEMA_VERSIONS.contains(&self.schema_version.as_str()) {
            return Err(CliError::Config(format!(
                "unsupported schema_version '{}'. This build supports: {}",
                self.schema_version,
                SUPPORTED_SCHEMA_VERSIONS.join(", ")
            )));
        }

        if self.fluid.is_empty() {
            return Err(CliError::Config("fluid field is required".to_string()));
        }

        for (i, circuit) in self.circuits.iter().enumerate() {
            if circuit.components.is_empty() {
                return Err(CliError::Config(format!("circuit {} has no components", i)));
            }

            let component_names: std::collections::HashSet<&str> =
                circuit.components.iter().map(|c| c.name.as_str()).collect();

            for edge in &circuit.edges {
                let from_parts: Vec<&str> = edge.from.split(':').collect();
                let to_parts: Vec<&str> = edge.to.split(':').collect();

                if from_parts.len() != 2 || to_parts.len() != 2 {
                    return Err(CliError::Config(format!(
                        "invalid edge format '{} -> {}'. Expected 'component:port'",
                        edge.from, edge.to
                    )));
                }

                let from_component = from_parts[0];
                let to_component = to_parts[0];

                if !component_names.contains(from_component) {
                    return Err(CliError::Config(format!(
                        "edge references unknown component '{}' (in '{}'). Available: {}",
                        from_component,
                        edge.from,
                        component_names
                            .iter()
                            .cloned()
                            .collect::<Vec<_>>()
                            .join(", ")
                    )));
                }

                if !component_names.contains(to_component) {
                    return Err(CliError::Config(format!(
                        "edge references unknown component '{}' (in '{}'). Available: {}",
                        to_component,
                        edge.to,
                        component_names
                            .iter()
                            .cloned()
                            .collect::<Vec<_>>()
                            .join(", ")
                    )));
                }
            }
        }

        Ok(())
    }
}

/// Substitute `"$name"` parameter references in a component's `params` map with the
/// resolved parameter values. Returns `Err(name)` if a referenced parameter is
/// undefined. Values that are not `"$..."` strings are left untouched.
fn substitute_component_params(
    comp: &mut ComponentConfig,
    params: &HashMap<String, serde_json::Value>,
) -> Result<(), String> {
    for value in comp.params.values_mut() {
        if let Some(name) = value.as_str().and_then(|s| s.strip_prefix('$')) {
            let resolved = params.get(name).ok_or_else(|| name.to_string())?.clone();
            *value = resolved;
        }
    }
    Ok(())
}

/// Prefix the component part of an internal `"component:port"` endpoint with `prefix`.
/// Endpoints without a `:` are prefixed wholesale (defensive; internal edges should
/// always use `component:port`).
fn prefix_internal_endpoint(endpoint: &str, prefix: &str) -> String {
    match endpoint.split_once(':') {
        Some((comp, port)) => format!("{}{}:{}", prefix, comp, port),
        None => format!("{}{}", prefix, endpoint),
    }
}

/// Recursively flatten a [`SubsystemTemplate`]: expand nested `instances` into
/// concrete components and edges, producing a flat template with only atomic
/// components. After this pass, the template's `subsystems`/`instances`/
/// `connections` are consumed (emptied) and only `components`/`edges`/`ports`
/// remain. A no-op clone when the template has no nested instances.
///
/// # Recursive composition
///
/// A template can itself instantiate other templates (declared in its own
/// `subsystems` map). Each nested instance is expanded depth-first, with
/// component names prefixed cumulatively (`"parent.child.comp"`). Exposed
/// ports of child instances are resolved to their concrete internal endpoints
/// via the child template's `ports` map, then prefixed.
fn flatten_template_recursive(template: &SubsystemTemplate) -> CliResult<SubsystemTemplate> {
    flatten_template_recursive_with_workspace(template, &HashMap::new())
}

fn flatten_template_recursive_with_workspace(
    template: &SubsystemTemplate,
    workspace: &HashMap<String, SubsystemTemplate>,
) -> CliResult<SubsystemTemplate> {
    // Base case: no nested instances to expand.
    if template.instances.is_empty() {
        return Ok(template.clone());
    }

    let mut flat_components = template.components.clone();
    let mut flat_edges = template.edges.clone();
    let mut instance_port_map: HashMap<String, String> = HashMap::new();

    for inst in &template.instances {
        // Resolve child template: first check local subsystems, then workspace.
        let child_template = template
            .subsystems
            .get(&inst.template)
            .or_else(|| workspace.get(&inst.template))
            .ok_or_else(|| {
                CliError::Config(format!(
                    "nested instance '{}' references unknown subsystem template '{}'. Available locally: {} | workspace: {}",
                    inst.name,
                    inst.template,
                    template.subsystems.keys().cloned().collect::<Vec<_>>().join(", "),
                    workspace.keys().cloned().collect::<Vec<_>>().join(", "),
                ))
            })?;

        // Recurse: flatten the child template first (depth-first), passing the
        // workspace so cross-file references resolve at every nesting level.
        let flat_child = flatten_template_recursive_with_workspace(child_template, workspace)?;

        // Resolve params: child defaults overlaid by instance overrides.
        let mut params = flat_child.params.clone();
        for (k, v) in &inst.params {
            params.insert(k.clone(), v.clone());
        }

        let prefix = format!("{}.", inst.name);

        // Expand child components with prefix + param substitution.
        for comp in &flat_child.components {
            let mut c = comp.clone();
            c.name = format!("{}{}", prefix, comp.name);
            substitute_component_params(&mut c, &params).map_err(|missing| {
                CliError::Config(format!(
                    "nested instance '{}' component '{}' references undefined parameter '${}'",
                    inst.name, comp.name, missing
                ))
            })?;
            flat_components.push(c);
        }

        // Expand child edges with prefix.
        for edge in &flat_child.edges {
            flat_edges.push(EdgeConfig {
                from: prefix_internal_endpoint(&edge.from, &prefix),
                to: prefix_internal_endpoint(&edge.to, &prefix),
            });
        }

        // Map exposed ports: "instance.port" → "prefix.internal_comp:port".
        for (port_name, internal_path) in &flat_child.ports {
            let resolved = prefix_internal_endpoint(internal_path, &prefix);
            instance_port_map.insert(format!("{}.{}", inst.name, port_name), resolved);
        }
    }

    // Resolve the template's own connections (between nested instances and/or
    // atomic components). Endpoints referencing "instance.port" are resolved
    // via the map above; literal "component:port" endpoints are kept as-is.
    for conn in &template.connections {
        let from = instance_port_map
            .get(&conn.from)
            .cloned()
            .unwrap_or_else(|| conn.from.clone());
        let to = instance_port_map
            .get(&conn.to)
            .cloned()
            .unwrap_or_else(|| conn.to.clone());
        flat_edges.push(EdgeConfig { from, to });
    }

    Ok(SubsystemTemplate {
        params: template.params.clone(),
        components: flat_components,
        edges: flat_edges,
        ports: template.ports.clone(),
        subsystems: HashMap::new(),
        instances: Vec::new(),
        connections: Vec::new(),
    })
}

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

    #[test]
    fn test_parse_minimal_config() {
        let json = r#"{ "fluid": "R134a" }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        assert_eq!(config.fluid, "R134a");
        assert_eq!(config.fluid_backend, None);
        assert!(config.circuits.is_empty());
    }

    #[test]
    fn test_parse_config_with_backend() {
        let json = r#"{ "fluid": "R134a", "fluid_backend": "CoolProp" }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        assert_eq!(config.fluid_backend.as_deref(), Some("CoolProp"));
    }

    #[test]
    fn test_parse_initialization_guesses() {
        let json = r#"{
            "fluid": "R134a",
            "initialization": {
                "t_cond_guess_k": 318.15,
                "t_evap_guess_k": 278.15,
                "superheat_guess_k": 7.0
            }
        }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        let init = config.initialization.expect("initialization config");
        assert_eq!(init.t_cond_guess_k, Some(318.15));
        assert_eq!(init.t_evap_guess_k, Some(278.15));
        assert_eq!(init.superheat_guess_k, Some(7.0));
    }

    #[test]
    fn test_parse_full_config() {
        let json = r#"
        {
            "fluid": "R410A",
            "circuits": [
                {
                    "id": 0,
                    "components": [
                        { "type": "Compressor", "name": "comp1", "ua": 5000.0 },
                        { "type": "Condenser", "name": "cond1", "ua": 5000.0 }
                    ],
                    "edges": [
                        { "from": "comp1:outlet", "to": "cond1:inlet" }
                    ],
                    "initial_state": {
                        "pressure_bar": 10.0,
                        "enthalpy_kj_kg": 400.0
                    }
                }
            ],
            "solver": {
                "strategy": "newton",
                "max_iterations": 50,
                "tolerance": 1e-8
            }
        }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        assert_eq!(config.fluid, "R410A");
        assert_eq!(config.circuits.len(), 1);
        assert_eq!(config.circuits[0].components.len(), 2);
        assert_eq!(config.solver.strategy, "newton");
    }

    #[test]
    fn test_validate_missing_fluid() {
        let json = r#"{ "fluid": "" }"#;
        let result = ScenarioConfig::from_json(json);
        assert!(result.is_err());
    }

    #[test]
    fn test_validate_invalid_edge_format() {
        let json = r#"
        {
            "fluid": "R134a",
            "circuits": [{
                "id": 0,
                "components": [{ "type": "Compressor", "name": "comp1", "ua": 5000.0 }],
                "edges": [{ "from": "invalid", "to": "also_invalid" }]
            }]
        }"#;
        let result = ScenarioConfig::from_json(json);
        assert!(result.is_err());
    }

    #[test]
    fn test_parse_mchx_condenser_coil() {
        let json = r#"
        {
            "fluid": "R134a",
            "circuits": [{
                "id": 0,
                "components": [{
                    "type": "MchxCondenserCoil",
                    "name": "mchx_coil",
                    "ua_nominal_kw_k": 25.5,
                    "fan_speed": 0.8,
                    "air_inlet_temp_c": 35.0,
                    "condenser_bank": {
                        "circuits": 2,
                        "coils_per_circuit": 3
                    }
                }],
                "edges": []
            }]
        }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        let comp = &config.circuits[0].components[0];

        assert_eq!(comp.component_type, "MchxCondenserCoil");
        assert_eq!(comp.ua_nominal_kw_k, Some(25.5));
        assert_eq!(comp.fan_speed, Some(0.8));
        assert_eq!(comp.air_inlet_temp_c, Some(35.0));

        let bank = comp.condenser_bank.as_ref().unwrap();
        assert_eq!(bank.circuits, 2);
        assert_eq!(bank.coils_per_circuit, 3);
    }

    // ---- arch-4: subsystem templates + instances (flattening) ----

    const TWO_CIRCUIT_TEMPLATE: &str = r#"
    {
        "fluid": "R134a",
        "subsystems": {
            "SimpleCircuit": {
                "params": { "ua_cond": 5000.0, "ua_evap": 6000.0, "t_sec_evap_c": 12.0 },
                "components": [
                    { "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.7 },
                    { "type": "Condenser", "name": "cond", "ua": "$ua_cond", "subcooling_k": 5.0 },
                    { "type": "IsenthalpicExpansionValve", "name": "exv" },
                    { "type": "Evaporator", "name": "evap", "ua": "$ua_evap", "secondary_inlet_temp_c": "$t_sec_evap_c" }
                ],
                "edges": [
                    { "from": "comp:outlet", "to": "cond:inlet" },
                    { "from": "cond:outlet", "to": "exv:inlet" },
                    { "from": "exv:outlet", "to": "evap:inlet" },
                    { "from": "evap:outlet", "to": "comp:inlet" }
                ],
                "ports": { "suction": "evap:outlet", "discharge": "comp:outlet" }
            }
        },
        "instances": [
            { "of": "SimpleCircuit", "name": "A", "circuit": 0, "params": { "ua_cond": 7000.0 } },
            { "of": "SimpleCircuit", "name": "B", "circuit": 1, "params": { "t_sec_evap_c": 7.0 } }
        ]
    }"#;

    #[test]
    fn test_flatten_instances_expands_and_prefixes() {
        let config = ScenarioConfig::from_json(TWO_CIRCUIT_TEMPLATE).unwrap();

        // Two instances -> two circuits, each with 4 prefixed components + 4 edges.
        assert_eq!(config.circuits.len(), 2);
        let circ_a = config.circuits.iter().find(|c| c.id == 0).unwrap();
        let circ_b = config.circuits.iter().find(|c| c.id == 1).unwrap();
        assert_eq!(circ_a.components.len(), 4);
        assert_eq!(circ_a.edges.len(), 4);
        assert_eq!(circ_b.components.len(), 4);

        let names_a: Vec<&str> = circ_a.components.iter().map(|c| c.name.as_str()).collect();
        assert!(names_a.contains(&"A.comp"));
        assert!(names_a.contains(&"A.evap"));
        let names_b: Vec<&str> = circ_b.components.iter().map(|c| c.name.as_str()).collect();
        assert!(names_b.contains(&"B.comp"));

        // Edges are rewritten with the instance prefix.
        assert!(circ_a
            .edges
            .iter()
            .any(|e| e.from == "A.comp:outlet" && e.to == "A.cond:inlet"));
    }

    #[test]
    fn test_flatten_param_substitution_and_override() {
        let config = ScenarioConfig::from_json(TWO_CIRCUIT_TEMPLATE).unwrap();
        let circ_a = config.circuits.iter().find(|c| c.id == 0).unwrap();
        let circ_b = config.circuits.iter().find(|c| c.id == 1).unwrap();

        // Instance A overrides ua_cond -> 7000; ua_evap keeps template default 6000.
        let cond_a = circ_a
            .components
            .iter()
            .find(|c| c.name == "A.cond")
            .unwrap();
        assert_eq!(cond_a.params.get("ua").unwrap().as_f64(), Some(7000.0));
        let evap_a = circ_a
            .components
            .iter()
            .find(|c| c.name == "A.evap")
            .unwrap();
        assert_eq!(evap_a.params.get("ua").unwrap().as_f64(), Some(6000.0));
        // Template default t_sec_evap_c 12.0 for A.
        assert_eq!(
            evap_a
                .params
                .get("secondary_inlet_temp_c")
                .unwrap()
                .as_f64(),
            Some(12.0)
        );

        // Instance B overrides t_sec_evap_c -> 7.0, keeps ua_cond default 5000.
        let cond_b = circ_b
            .components
            .iter()
            .find(|c| c.name == "B.cond")
            .unwrap();
        assert_eq!(cond_b.params.get("ua").unwrap().as_f64(), Some(5000.0));
        let evap_b = circ_b
            .components
            .iter()
            .find(|c| c.name == "B.evap")
            .unwrap();
        assert_eq!(
            evap_b
                .params
                .get("secondary_inlet_temp_c")
                .unwrap()
                .as_f64(),
            Some(7.0)
        );
    }

    #[test]
    fn test_flatten_external_connection_resolves_port() {
        // Two instances in the SAME circuit, wired together via external ports.
        let json = r#"
        {
            "fluid": "R134a",
            "subsystems": {
                "Half": {
                    "components": [
                        { "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.7 },
                        { "type": "Condenser", "name": "cond", "ua": 5000.0 }
                    ],
                    "edges": [ { "from": "comp:outlet", "to": "cond:inlet" } ],
                    "ports": { "out": "cond:outlet", "in": "comp:inlet" }
                }
            },
            "instances": [
                { "of": "Half", "name": "A", "circuit": 0 },
                { "of": "Half", "name": "B", "circuit": 0 }
            ],
            "connections": [
                { "from": "A.out", "to": "B.in" }
            ]
        }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        assert_eq!(config.circuits.len(), 1);
        let circ = &config.circuits[0];
        assert_eq!(circ.components.len(), 4);
        // A.out -> cond:outlet, B.in -> comp:inlet, both prefixed.
        assert!(circ
            .edges
            .iter()
            .any(|e| e.from == "A.cond:outlet" && e.to == "B.comp:inlet"));
    }

    #[test]
    fn test_flatten_unknown_template_errors() {
        let json = r#"
        {
            "fluid": "R134a",
            "instances": [ { "of": "DoesNotExist", "name": "A" } ]
        }"#;
        let err = ScenarioConfig::from_json(json).unwrap_err();
        assert!(format!("{err}").contains("unknown subsystem template"));
    }

    #[test]
    fn test_flatten_undefined_param_errors() {
        let json = r#"
        {
            "fluid": "R134a",
            "subsystems": {
                "T": {
                    "components": [ { "type": "Condenser", "name": "cond", "ua": "$missing" } ]
                }
            },
            "instances": [ { "of": "T", "name": "A" } ]
        }"#;
        let err = ScenarioConfig::from_json(json).unwrap_err();
        assert!(format!("{err}").contains("undefined parameter"));
    }

    #[test]
    fn test_flatten_no_op_without_instances() {
        // A plain config with no instances/connections is untouched.
        let json = r#"
        {
            "fluid": "R134a",
            "circuits": [{
                "id": 0,
                "components": [{ "type": "Condenser", "name": "cond", "ua": 5000.0 }],
                "edges": []
            }]
        }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        assert_eq!(config.circuits.len(), 1);
        assert_eq!(config.circuits[0].components.len(), 1);
        assert_eq!(config.circuits[0].components[0].name, "cond");
    }

    // ---- arch-5: schema_version + unified IR schema ----

    #[test]
    fn test_schema_version_defaults_to_v1() {
        let config = ScenarioConfig::from_json(r#"{ "fluid": "R134a" }"#).unwrap();
        assert_eq!(config.schema_version, "1");
    }

    #[test]
    fn test_schema_version_v2_accepted() {
        let json = r#"{ "schema_version": "2", "fluid": "R134a" }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        assert_eq!(config.schema_version, "2");
    }

    #[test]
    fn test_schema_version_unknown_rejected() {
        let json = r#"{ "schema_version": "99", "fluid": "R134a" }"#;
        let err = ScenarioConfig::from_json(json).unwrap_err();
        assert!(format!("{err}").contains("unsupported schema_version"));
    }

    #[test]
    fn test_json_schema_emits_ir_fields() {
        let schema = ScenarioConfig::json_schema();
        // The emitted schema must be the single source of truth covering every IR
        // pillar: circuits (v1) + controls/embeddings/subsystems/instances/connections (v2).
        for field in [
            "schema_version",
            "circuits",
            "controls",
            "embeddings",
            "equations",
            "subsystems",
            "instances",
            "connections",
        ] {
            assert!(
                schema.contains(field),
                "schema should document IR field '{field}'"
            );
        }
        // Valid JSON, draft-07.
        let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
        assert!(parsed.get("$schema").is_some());
    }

    #[test]
    fn test_parse_embeddings() {
        let json = r#"{
            "schema_version": "2",
            "fluid": "R134a",
            "embeddings": [{
                "id": "emb_evap_z_ua",
                "unknown": { "component": "evap", "factor": "z_ua", "start": 0.3, "min": 0.05, "max": 3.0 },
                "equation": { "component": "SST probe", "output": "saturationTemperature", "value": 278.15 }
            }]
        }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        assert_eq!(config.embeddings.len(), 1);
        assert_eq!(config.embeddings[0].id, "emb_evap_z_ua");
        assert_eq!(config.embeddings[0].unknown.factor, "z_ua");
        assert!((config.embeddings[0].equation.value - 278.15).abs() < 1e-9);
        assert!(config.controls.is_empty());
    }

    #[test]
    fn test_parse_equations() {
        let json = r#"{
            "schema_version": "2",
            "fluid": "R134a",
            "equations": [{
                "id": "eq_evap_z_ua",
                "lhs": "evap.z_ua",
                "rhs": "min(1.0, exp(\"SST probe\".T / 300))",
                "start": 0.3,
                "min": 0.05,
                "max": 2.0
            }]
        }"#;
        let config = ScenarioConfig::from_json(json).unwrap();
        assert_eq!(config.equations.len(), 1);
        assert_eq!(config.equations[0].id, "eq_evap_z_ua");
        assert_eq!(config.equations[0].lhs, "evap.z_ua");
        assert!(config.equations[0].rhs.contains("min"));
        assert!(config.equations[0].unknown.is_none());
    }
}
