//! Tests for single simulation execution.

use entropyk_cli::error::ExitCode;
use entropyk_cli::run::{FailureDiagnostics, SimulationResult, SimulationStatus};
use tempfile::tempdir;

#[test]
fn test_simulation_result_serialization() {
    let result = SimulationResult {
        input: "test.json".to_string(),
        status: SimulationStatus::Converged,
        convergence: Some(entropyk_cli::run::ConvergenceInfo {
            final_residual: 1e-8,
            tolerance: 1e-6,
            iterations: None,
            strategy: None,
            iteration_history: vec![],
        }),
        iterations: Some(25),
        state: Some(vec![entropyk_cli::run::StateEntry {
            edge: 0,
            pressure_bar: 10.0,
            enthalpy_kj_kg: 400.0,
            ..Default::default()
        }]),
        performance: None,
        error: None,
        failure_diagnostics: None,
        initialization_diagnostics: None,
        dof: None,
        elapsed_ms: 50,
        raw_state_vector: None,
        solved_variables: Vec::new(),
    };

    let json = serde_json::to_string_pretty(&result).unwrap();
    assert!(json.contains("\"status\": \"converged\""));
    assert!(json.contains("\"iterations\": 25"));
    assert!(json.contains("\"pressure_bar\": 10.0"));
}

#[test]
fn test_simulation_status_values() {
    assert_eq!(SimulationStatus::Converged, SimulationStatus::Converged);
    assert_ne!(SimulationStatus::Converged, SimulationStatus::Error);

    let status = SimulationStatus::NonConverged;
    let json = serde_json::to_string(&status).unwrap();
    assert_eq!(json, "\"non_converged\"");
}

#[test]
fn test_exit_codes() {
    assert_eq!(ExitCode::Success as i32, 0);
    assert_eq!(ExitCode::SimulationError as i32, 1);
    assert_eq!(ExitCode::ConfigError as i32, 2);
    assert_eq!(ExitCode::IoError as i32, 3);
}

#[test]
fn test_error_result_serialization() {
    let result = SimulationResult {
        input: "invalid.json".to_string(),
        status: SimulationStatus::Error,
        convergence: None,
        iterations: None,
        state: None,
        performance: None,
        error: Some("Configuration error".to_string()),
        failure_diagnostics: None,
        initialization_diagnostics: None,
        dof: None,
        elapsed_ms: 0,
        raw_state_vector: None,
        solved_variables: Vec::new(),
    };

    let json = serde_json::to_string(&result).unwrap();
    assert!(json.contains("Configuration error"));
}

#[test]
fn test_error_result_serializes_failure_diagnostics() {
    let result = SimulationResult {
        input: "hard_demo.json".to_string(),
        status: SimulationStatus::Error,
        convergence: None,
        iterations: None,
        state: None,
        performance: None,
        error: Some("Solver error: NonConvergence".to_string()),
        failure_diagnostics: Some(FailureDiagnostics {
            final_residual_norm: 42.0,
            last_residual_norm: Some(42.0),
            dominant_residual_index: Some(3),
            dominant_residual_value: Some(41.0),
        }),
        initialization_diagnostics: None,
        dof: None,
        elapsed_ms: 0,
        raw_state_vector: None,
        solved_variables: Vec::new(),
    };

    let json = serde_json::to_string(&result).unwrap();
    assert!(json.contains("failure_diagnostics"));
    assert!(json.contains("dominant_residual_index"));
    assert!(json.contains("dominant_residual_value"));
}

#[test]
fn test_create_minimal_config_file() {
    let dir = tempdir().unwrap();
    let config_path = dir.path().join("minimal.json");

    let json = r#"{ "fluid": "R134a" }"#;
    std::fs::write(&config_path, json).unwrap();

    assert!(config_path.exists());
    let content = std::fs::read_to_string(&config_path).unwrap();
    assert!(content.contains("R134a"));
}

#[test]
fn test_screw_compressor_frequency_hz_config() {
    use entropyk_cli::config::ScenarioConfig;
    use tempfile::tempdir;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("screw_vfd.json");

    let json = r#"
    {
        "name": "Screw VFD Test",
        "fluid": "R134a",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "ScrewEconomizerCompressor",
                        "name": "screw_test",
                        "fluid": "R134a",
                        "nominal_frequency_hz": 50.0,
                        "frequency_hz": 40.0,
                        "mechanical_efficiency": 0.92,
                        "economizer_fraction": 0.12,
                        "mf_a00": 1.2,
                        "mf_a10": 0.003,
                        "mf_a01": -0.002,
                        "mf_a11": 0.00001,
                        "pw_b00": 55000.0,
                        "pw_b10": 200.0,
                        "pw_b01": -300.0,
                        "pw_b11": 0.5,
                        "p_suction_bar": 3.2,
                        "h_suction_kj_kg": 400.0,
                        "p_discharge_bar": 12.8,
                        "h_discharge_kj_kg": 440.0,
                        "p_eco_bar": 6.4,
                        "h_eco_kj_kg": 260.0
                    }
                ],
                "edges": []
            }
        ],
        "solver": {
            "strategy": "fallback",
            "max_iterations": 10
        }
    }
    "#;

    std::fs::write(&config_path, json).unwrap();

    let config = ScenarioConfig::from_file(&config_path);
    assert!(config.is_ok(), "Config should parse successfully");

    let config = config.unwrap();
    assert_eq!(config.circuits.len(), 1);

    let screw_params = &config.circuits[0].components[0].params;
    assert_eq!(
        screw_params.get("frequency_hz").and_then(|v| v.as_f64()),
        Some(40.0)
    );
    assert_eq!(
        screw_params
            .get("nominal_frequency_hz")
            .and_then(|v| v.as_f64()),
        Some(50.0)
    );
}

#[test]
fn test_run_simulation_with_coolprop() {
    use entropyk_cli::run::run_simulation;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("coolprop.json");

    let json = r#"
    {
        "fluid": "R134a",
        "fluid_backend": "CoolProp",
        "circuits": [
            {
                "id": 0,
                "components": [
                    { 
                        "type": "HeatExchanger", 
                        "name": "hx1", 
                        "ua": 1000.0,
                        "hot_fluid": "Water",
                        "hot_t_inlet_c": 25.0,
                        "cold_fluid": "R134a",
                        "cold_t_inlet_c": 15.0
                    }
                ],
                "edges": []
            }
        ],
        "solver": { "max_iterations": 1 }
    }
    "#;
    std::fs::write(&config_path, json).unwrap();

    let result = run_simulation(&config_path, None, false).unwrap();

    match result.status {
        SimulationStatus::Converged | SimulationStatus::NonConverged => {}
        SimulationStatus::Error => {
            let err_msg = result.error.unwrap();
            assert!(
                err_msg.contains("CoolProp")
                    || err_msg.contains("Fluid")
                    || err_msg.contains("Component")
                    || err_msg.contains("IsolatedNode")
                    || err_msg.contains("finalization"),
                "Unexpected error: {}",
                err_msg
            );
        }
        _ => panic!("Unexpected status: {:?}", result.status),
    }
}

/// arch-3: A declared `SaturatedController` capacity loop is co-solved inside the
/// same Newton system and genuinely regulates the evaporator cooling capacity to the
/// requested target by manipulating the compressor mass-flow factor `f_m`. This proves
/// the control block is a real steady-state DoF (no bricolage): changing only the
/// control target moves the solved operating point so that capacity tracks it.
#[test]
fn test_saturated_capacity_control_tracks_target() {
    use entropyk_cli::run::run_simulation;

    // Fully-coupled emergent-pressure chiller + one saturated-PI capacity loop.
    // `{TARGET}` is substituted per run so we can assert the solved capacity follows it.
    let template = r#"
    {
        "fluid": "R134a",
        "fluid_backend": "CoolProp",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "IsentropicCompressor", "name": "comp",
                        "isentropic_efficiency": 0.70,
                        "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0,
                        "emergent_pressure": true,
                        "displacement_m3": 6.5e-5, "speed_hz": 50.0,
                        "volumetric_efficiency": 0.92
                    },
                    {
                        "type": "Condenser", "name": "cond", "ua": 766.0,
                        "emergent_pressure": true, "subcooling_k": 5.0,
                        "secondary_fluid": "Water"
                    },
                    {
                        "type": "IsenthalpicExpansionValve", "name": "exv",
                        "t_evap_k": 278.15, "emergent_pressure": true
                    },
                    {
                        "type": "Evaporator", "name": "evap", "ua": 1468.0,
                        "emergent_pressure": true,
                        "secondary_fluid": "Water"
                    },
                    { "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
                    { "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
                    { "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
                    { "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
                ],
                "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" },
                    { "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
                    { "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
                    { "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
                    { "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
                ]
            }
        ],
        "controls": [
            {
                "type": "SaturatedController", "id": "evap_capacity",
                "measure": { "component": "evap", "output": "capacity" },
                "actuator": {
                    "component": "comp", "factor": "f_m",
                    "initial": 1.0, "min": 0.5, "max": 1.5
                },
                "target": {TARGET}, "gain": 0.01, "band": 1.0
            }
        ],
        "solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
    }
    "#;

    let solve = |target_w: f64| -> f64 {
        let dir = tempdir().unwrap();
        let config_path = dir.path().join("capacity_control.json");
        let json = template.replace("{TARGET}", &format!("{target_w}"));
        std::fs::write(&config_path, json).unwrap();

        let result = run_simulation(&config_path, None, false).unwrap();
        assert!(
            matches!(result.status, SimulationStatus::Converged),
            "capacity-control cycle did not converge: {:?} ({:?})",
            result.status,
            result.error
        );
        result
            .performance
            .expect("performance metrics present")
            .q_cooling_kw
            .expect("cooling capacity computed")
    };

    // The loop must drive the solved cooling capacity to each target (within 5 %).
    let q_high = solve(7000.0);
    let q_low = solve(6000.0);

    assert!(
        (q_high - 7.0).abs() / 7.0 < 0.05,
        "capacity should track 7 kW target, got {q_high:.3} kW"
    );
    assert!(
        (q_low - 6.0).abs() / 6.0 < 0.05,
        "capacity should track 6 kW target, got {q_low:.3} kW"
    );
    // And a lower target must yield a genuinely lower solved capacity (loop acts).
    assert!(
        q_low < q_high - 0.5,
        "lower capacity target must reduce solved capacity: {q_low:.3} !< {q_high:.3}"
    );
}

/// Override / selector network end-to-end in a real emergent-pressure cycle:
/// one actuator (EXV opening) driven by a primary superheat setpoint AND a
/// capacity-max protection folded through a softMin selector (the Modelica-style
/// Min/Max override tree). This exercises the new offset-free control law + the
/// override wiring (network residuals + analytic Jacobian) inside a full Newton
/// solve, and proves the selector keeps the primary objective in authority while
/// the protection is slack: the solved duty is invariant to a slack limit.
#[test]
fn test_exv_override_network_solves_and_primary_keeps_authority() {
    use entropyk_cli::run::run_simulation;

    let template = r#"
    {
        "fluid": "R134a",
        "fluid_backend": "CoolProp",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "IsentropicCompressor", "name": "comp",
                        "isentropic_efficiency": 0.70,
                        "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0,
                        "emergent_pressure": true,
                        "displacement_m3": 6.5e-5, "speed_hz": 50.0,
                        "volumetric_efficiency": 0.92
                    },
                    {
                        "type": "Condenser", "name": "cond", "ua": 766.0,
                        "emergent_pressure": true, "subcooling_k": 5.0,
                        "secondary_fluid": "Water"
                    },
                    {
                        "type": "IsenthalpicExpansionValve", "name": "exv",
                        "t_evap_k": 278.15, "emergent_pressure": true,
                        "orifice_kv": 2.0e-6, "orifice_opening_init": 0.5,
                        "orifice_opening_min": 0.02, "orifice_opening_max": 1.0
                    },
                    {
                        "type": "Evaporator", "name": "evap", "ua": 1468.0,
                        "emergent_pressure": true, "superheat_regulated": true,
                        "secondary_fluid": "Water"
                    },
                    { "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
                    { "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
                    { "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
                    { "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
                ],
                "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" },
                    { "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
                    { "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
                    { "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
                    { "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
                ]
            }
        ],
        "controls": [
            {
                "type": "SaturatedController", "id": "sh_with_cap_limit",
                "measure": { "component": "evap", "output": "superheat" },
                "actuator": {
                    "component": "exv", "factor": "opening",
                    "initial": 0.5, "min": 0.02, "max": 1.0
                },
                "target": 5.0, "gain": -0.8, "band": 1.0, "alpha": 5e-2,
                "objectives": [
                    {
                        "component": "evap", "output": "capacity",
                        "setpoint": {CAPMAX}, "gain": 1e-4, "combine": "min"
                    }
                ]
            }
        ],
        "solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
    }
    "#;

    let solve = |cap_max_w: f64| -> f64 {
        let dir = tempdir().unwrap();
        let config_path = dir.path().join("exv_override.json");
        let json = template.replace("{CAPMAX}", &format!("{cap_max_w}"));
        std::fs::write(&config_path, json).unwrap();

        let result = run_simulation(&config_path, None, false).unwrap();
        assert!(
            matches!(result.status, SimulationStatus::Converged),
            "override cycle did not converge (cap_max={cap_max_w} W): {:?} ({:?})",
            result.status,
            result.error
        );
        result
            .performance
            .expect("performance metrics present")
            .q_cooling_kw
            .expect("cooling capacity computed")
    };

    // Two slack limits, both far above the natural duty (~7 kW): the softMin
    // selector must keep the superheat objective in authority in BOTH runs, so
    // the solved duty is essentially identical (invariant to the slack limit).
    let q_a = solve(1.0e6);
    let q_b = solve(2.0e5);
    assert!(
        q_a > 0.5,
        "override network must solve to a positive duty: {q_a:.3} kW"
    );
    assert!(
        (q_a - q_b).abs() / q_a < 0.02,
        "slack capacity protection must not change the primary solution: {q_a:.3} vs {q_b:.3} kW"
    );
}

/// Override network where the protection must TAKE AUTHORITY. The primary
/// objective asks for an unreachable capacity (so it always wants max compressor
/// speed), and a capacity-max protection is folded in via softMin. When the limit
/// is set below the natural duty, the protection wins and holds the duty at the
/// limit — the hard cold-start switching case. The warm-started **activation
/// continuation** (λ: primary-only → full network) plus the now-honoured
/// `solver.max_iterations` make it converge where a direct solve would slam the
/// actuator and diverge.
#[test]
fn test_override_capacity_limit_takes_authority_via_continuation() {
    use entropyk_cli::run::run_simulation;

    let template = r#"
    {
        "fluid": "R134a",
        "fluid_backend": "CoolProp",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "IsentropicCompressor", "name": "comp",
                        "isentropic_efficiency": 0.70,
                        "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0,
                        "emergent_pressure": true,
                        "displacement_m3": 6.5e-5, "speed_hz": 50.0,
                        "volumetric_efficiency": 0.92
                    },
                    {
                        "type": "Condenser", "name": "cond", "ua": 766.0,
                        "emergent_pressure": true, "subcooling_k": 5.0,
                        "secondary_fluid": "Water"
                    },
                    {
                        "type": "IsenthalpicExpansionValve", "name": "exv",
                        "t_evap_k": 278.15, "emergent_pressure": true
                    },
                    {
                        "type": "Evaporator", "name": "evap", "ua": 1468.0,
                        "emergent_pressure": true,
                        "secondary_fluid": "Water"
                    },
                    { "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
                    { "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
                    { "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
                    { "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
                ],
                "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" },
                    { "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
                    { "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
                    { "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
                    { "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
                ]
            }
        ],
        "controls": [
            {
                "type": "SaturatedController", "id": "cap_with_cap_limit",
                "measure": { "component": "evap", "output": "capacity" },
                "actuator": {
                    "component": "comp", "factor": "f_m",
                    "initial": 1.0, "min": 0.5, "max": 1.5
                },
                "target": 20000.0, "gain": 0.01, "band": 1.0, "alpha": 5e-2,
                "objectives": [
                    {
                        "component": "evap", "output": "capacity",
                        "setpoint": {CAPMAX}, "gain": 0.01, "combine": "min"
                    }
                ]
            }
        ],
        "solver": { "strategy": "fallback", "max_iterations": 400, "tolerance": 1e-6 }
    }
    "#;

    let solve = |cap_max_w: f64| -> f64 {
        let dir = tempdir().unwrap();
        let config_path = dir.path().join("override_active.json");
        let json = template.replace("{CAPMAX}", &format!("{cap_max_w}"));
        std::fs::write(&config_path, json).unwrap();
        let result = run_simulation(&config_path, None, false).unwrap();
        assert!(
            matches!(result.status, SimulationStatus::Converged),
            "override cycle did not converge (cap_max={cap_max_w} W): {:?} ({:?})",
            result.status,
            result.error
        );
        result
            .performance
            .expect("performance metrics present")
            .q_cooling_kw
            .expect("cooling capacity computed")
    };

    // Baseline (slack limit) → primary wants 20 kW → compressor pinned at max.
    let q0_kw = solve(1.0e7);
    // Active limit well below baseline → protection takes authority.
    let cap_max_w = 0.80 * q0_kw * 1000.0;
    let q1_kw = solve(cap_max_w);

    assert!(
        q1_kw < q0_kw - 0.2,
        "override must reduce duty below baseline: {q1_kw:.3} !< {q0_kw:.3} kW"
    );
    let target_kw = cap_max_w / 1000.0;
    assert!(
        (q1_kw - target_kw).abs() / target_kw < 0.10,
        "override must hold duty near the capacity limit: got {q1_kw:.3} kW, limit {target_kw:.3} kW"
    );
}

/// arch-4: A subsystem template instantiated twice (circuits A and B) is flattened
/// into an ordinary two-loop graph and co-solved. This proves hierarchical modeling
/// end-to-end: one parameterized template -> two independent emergent-pressure loops,
/// each with its own secondary conditions, both converging in the same Newton system
/// (the multi-loop staged-seed fix makes the 61XW System_2C topology solvable).
#[test]
fn test_dual_circuit_subsystem_flatten_and_solve() {
    use entropyk_cli::run::run_simulation;

    let json = r#"
    {
        "fluid": "R134a",
        "fluid_backend": "CoolProp",
        "subsystems": {
            "EmergentCircuit": {
                "params": {
                    "ua_cond": 766.0, "ua_evap": 1468.0,
                    "t_evap_secondary_c": 12.0
                },
                "components": [
                    {
                        "type": "IsentropicCompressor", "name": "comp",
                        "isentropic_efficiency": 0.70,
                        "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0,
                        "emergent_pressure": true,
                        "displacement_m3": 6.5e-5, "speed_hz": 50.0,
                        "volumetric_efficiency": 0.92
                    },
                    {
                        "type": "Condenser", "name": "cond", "ua": "$ua_cond",
                        "emergent_pressure": true, "subcooling_k": 5.0,
                        "secondary_fluid": "Water"
                    },
                    {
                        "type": "IsenthalpicExpansionValve", "name": "exv",
                        "t_evap_k": 278.15, "emergent_pressure": true
                    },
                    {
                        "type": "Evaporator", "name": "evap", "ua": "$ua_evap",
                        "emergent_pressure": true,
                        "secondary_fluid": "Water"
                    },
                    { "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
                    { "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
                    { "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": "$t_evap_secondary_c", "m_flow_kg_s": 0.4778 },
                    { "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
                ],
                "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" },
                    { "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
                    { "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
                    { "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
                    { "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
                ],
                "ports": { "suction": "evap:outlet", "discharge": "comp:outlet" }
            }
        },
        "instances": [
            { "of": "EmergentCircuit", "name": "A", "circuit": 0,
              "params": { "ua_cond": 766.0, "t_evap_secondary_c": 12.0 } },
            { "of": "EmergentCircuit", "name": "B", "circuit": 1,
              "params": { "ua_cond": 900.0, "t_evap_secondary_c": 10.0 } }
        ],
        "solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
    }
    "#;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("dual_circuit.json");
    std::fs::write(&config_path, json).unwrap();

    let result = run_simulation(&config_path, None, false).unwrap();
    assert!(
        matches!(result.status, SimulationStatus::Converged),
        "dual-circuit subsystem cycle did not converge: {:?} ({:?})",
        result.status,
        result.error
    );

    let perf = result.performance.expect("performance metrics present");
    let q = perf.q_cooling_kw.expect("cooling capacity computed");
    // Two ~7 kW circuits -> total cooling well above a single circuit.
    assert!(
        q > 12.0 && q < 20.0,
        "dual-circuit total cooling capacity should be ~14 kW, got {q:.3} kW"
    );
}

/// Task 3.3: Verify that port-spec syntax in edges (e.g., "screw_0:discharge")
/// is correctly parsed - the config should parse and the component/type info should
/// be available with named port reference.
#[test]
fn test_edge_port_spec_syntax_parsed() {
    use entropyk_cli::config::ScenarioConfig;
    use tempfile::tempdir;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("screw_port_spec.json");

    // Config with correct port spec syntax: "component:port_name"
    let json = r#"
    {
        "name": "Port Spec Test",
        "fluid": "R134a",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "ScrewEconomizerCompressor",
                        "name": "screw_0",
                        "nominal_frequency_hz": 50.0,
                        "mechanical_efficiency": 0.92,
                        "economizer_fraction": 0.12,
                        "mf_a00": 1.2, "mf_a10": 0.003, "mf_a01": -0.002, "mf_a11": 0.00001,
                        "pw_b00": 55000.0, "pw_b10": 200.0, "pw_b01": -300.0, "pw_b11": 0.5,
                        "p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
                        "p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
                        "p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
                    },
                    {
                        "type": "Placeholder",
                        "name": "condenser",
                        "n_equations": 2
                    },
                    {
                        "type": "Placeholder",
                        "name": "evaporator",
                        "n_equations": 2
                    }
                ],
                "edges": [
                    { "from": "screw_0:discharge", "to": "condenser:inlet" },
                    { "from": "condenser:outlet",  "to": "evaporator:inlet" },
                    { "from": "evaporator:outlet", "to": "screw_0:suction" }
                ]
            }
        ],
        "solver": { "strategy": "fallback", "max_iterations": 5 }
    }
    "#;

    std::fs::write(&config_path, json).unwrap();

    let config = ScenarioConfig::from_file(&config_path);
    assert!(config.is_ok(), "Config should parse successfully");

    let config = config.unwrap();
    // Verify the edge port specs are preserved in the raw config
    let edges = &config.circuits[0].edges;
    assert_eq!(edges.len(), 3);
    assert_eq!(edges[0].from, "screw_0:discharge");
    assert_eq!(edges[0].to, "condenser:inlet");
    assert_eq!(edges[2].from, "evaporator:outlet");
    assert_eq!(edges[2].to, "screw_0:suction");
}

/// Task 3.4: Verify preset configuration is correctly parsed and overridable.
#[test]
fn test_screw_compressor_preset_config() {
    use entropyk_cli::config::ScenarioConfig;
    use tempfile::tempdir;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("screw_preset.json");

    // Config using preset with explicit frequency override
    let json = r#"
    {
        "name": "Preset Bitzer Test",
        "fluid": "R134a",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "ScrewEconomizerCompressor",
                        "name": "screw_0",
                        "preset": "bitzer_generic_200kw",
                        "nominal_frequency_hz": 50.0,
                        "frequency_hz": 45.0,
                        "mechanical_efficiency": 0.92,
                        "p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
                        "p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
                        "p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
                    }
                ],
                "edges": []
            }
        ],
        "solver": { "strategy": "fallback", "max_iterations": 5 }
    }
    "#;

    std::fs::write(&config_path, json).unwrap();

    let config = ScenarioConfig::from_file(&config_path);
    assert!(
        config.is_ok(),
        "Config with preset should parse successfully"
    );

    let config = config.unwrap();
    let params = &config.circuits[0].components[0].params;

    // Verify preset is stored as param
    assert_eq!(
        params.get("preset").and_then(|v| v.as_str()),
        Some("bitzer_generic_200kw"),
        "preset field should be in params"
    );

    // Verify frequency_hz override
    assert_eq!(
        params.get("frequency_hz").and_then(|v| v.as_f64()),
        Some(45.0),
        "frequency_hz should be overridden to 45.0"
    );

    // Verify that explicit mf coefficients can coexist with preset
    // (no explicit mf_a00 means it will use the preset default 1.35)
    assert!(
        params.get("mf_a00").is_none(),
        "Preset should not require explicit mf_a00"
    );
}

/// Task 3.4: Verify grasso preset is also recognized.
#[test]
fn test_screw_compressor_grasso_preset_config() {
    use entropyk_cli::config::ScenarioConfig;
    use tempfile::tempdir;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("screw_grasso.json");

    let json = r#"
    {
        "fluid": "R134a",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "ScrewEconomizerCompressor",
                        "name": "screw_0",
                        "preset": "grasso_generic_200kw",
                        "nominal_frequency_hz": 50.0,
                        "mechanical_efficiency": 0.90,
                        "p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
                        "p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
                        "p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
                    }
                ],
                "edges": []
            }
        ],
        "solver": { "max_iterations": 1 }
    }
    "#;

    std::fs::write(&config_path, json).unwrap();

    let config = ScenarioConfig::from_file(&config_path).unwrap();
    let params = &config.circuits[0].components[0].params;

    assert_eq!(
        params.get("preset").and_then(|v| v.as_str()),
        Some("grasso_generic_200kw")
    );
}

/// AC2 validation: Given frequency_hz: 40.0 in config, the CLI path correctly applies
/// set_frequency_hz(), yielding frequency_ratio() == 0.8.
///
/// Replicates the create_component() logic for ScrewEconomizerCompressor to validate AC2.
#[test]
fn test_ac2_frequency_ratio_set_correctly_by_cli() {
    use entropyk_components::{
        polynomials::Polynomial2D,
        port::{FluidId, Port},
        screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves},
    };
    use entropyk_core::{Enthalpy, Pressure};

    let make_port = |p_bar: f64, h_kj_kg: f64| {
        let a = Port::new(
            FluidId::new("R134a"),
            Pressure::from_bar(p_bar),
            Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
        );
        let b = Port::new(
            FluidId::new("R134a"),
            Pressure::from_bar(p_bar),
            Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
        );
        a.connect(b).unwrap().0
    };

    let curves = ScrewPerformanceCurves::with_fixed_eco_fraction(
        Polynomial2D::bilinear(1.2, 0.003, -0.002, 1e-5),
        Polynomial2D::bilinear(55_000.0, 200.0, -300.0, 0.5),
        0.12,
    );

    let mut comp = ScrewEconomizerCompressor::new(
        curves,
        "R134a",
        50.0, // nominal_frequency_hz: 50 Hz
        0.92,
        make_port(3.2, 400.0),
        make_port(12.8, 440.0),
        make_port(6.4, 260.0),
    )
    .expect("valid compressor");

    // Mirrors what create_component() does when "frequency_hz" present in JSON params
    comp.set_frequency_hz(40.0)
        .expect("set_frequency_hz(40.0) should succeed");

    // AC2 core assertion: 40 / 50 == 0.8
    assert!(
        (comp.frequency_ratio() - 0.8).abs() < 1e-10,
        "AC2 FAILED: expected frequency_ratio 0.8 but got {:.6}",
        comp.frequency_ratio()
    );
}

/// AC1: Given ua_nominal_kw_k: 8.5, component's ua_nominal() == 8500.0 W/K.
#[test]
fn test_ac1_mchx_ua_nominal_parsed_from_config() {
    use entropyk_cli::config::ScenarioConfig;

    let json = r#"
    {
        "fluid": "R134a",
        "circuits": [{
            "id": 0,
            "components": [{
                "type": "MchxCondenserCoil",
                "name": "mchx_coil",
                "ua_nominal_kw_k": 8.5,
                "fan_speed": 1.0,
                "air_inlet_temp_c": 35.0
            }],
            "edges": []
        }]
    }"#;

    let config = ScenarioConfig::from_json(json).unwrap();
    let comp = &config.circuits[0].components[0];

    // AC1: ua_nominal_kw_k field parsed correctly
    assert_eq!(
        comp.ua_nominal_kw_k,
        Some(8.5),
        "ua_nominal_kw_k should be 8.5 kW/K"
    );
    assert_eq!(comp.fan_speed, Some(1.0));
    assert_eq!(comp.air_inlet_temp_c, Some(35.0));
}

/// AC2: Given fan_speed=0.64, n_air_exponent=0.5, UA_eff ≈ UA_nom × √0.64 = UA_nom × 0.8.
#[test]
fn test_ac2_fan_speed_064_yields_ua_eff_08() {
    use approx::assert_relative_eq;
    use entropyk_components::heat_exchanger::MchxCondenserCoil;

    let ua_nominal = 8_500.0; // W/K (8.5 kW/K)
    let n_air = 0.5;
    let mut coil = MchxCondenserCoil::new(ua_nominal, n_air, 0);

    // Set design conditions: 35°C air, fan_speed=0.64
    coil.set_air_temperature_celsius(35.0);
    coil.set_fan_speed_ratio(0.64);

    // AC2: UA_eff ≈ UA_nom × 0.64^0.5 = UA_nom × 0.8
    let expected_ua = ua_nominal * 0.8; // 0.64^0.5 = 0.8
                                        // Allow 5% tolerance for density correction at 35°C
    let ua_eff = coil.ua_effective();
    assert_relative_eq!(ua_eff, expected_ua, epsilon = expected_ua * 0.05);
}

/// AC3: condenser_bank with 2 circuits × 2 coils → 4 components with names mchx_0a..mchx_1b.
#[test]
fn test_ac3_condenser_bank_2x2_generates_4_components() {
    use entropyk_cli::config::ScenarioConfig;

    let json = r#"
    {
        "fluid": "R134a",
        "circuits": [{
            "id": 0,
            "components": [{
                "type": "MchxCondenserCoil",
                "name": "mchx",
                "ua_nominal_kw_k": 8.5,
                "fan_speed": 1.0,
                "air_inlet_temp_c": 35.0,
                "condenser_bank": {
                    "circuits": 2,
                    "coils_per_circuit": 2
                }
            }],
            "edges": []
        }]
    }"#;

    let config = ScenarioConfig::from_json(json).unwrap();
    let bank_comp = &config.circuits[0].components[0];

    // Verify bank config parsed
    let bank = bank_comp
        .condenser_bank
        .as_ref()
        .expect("condenser_bank must be present");
    assert_eq!(bank.circuits, 2);
    assert_eq!(bank.coils_per_circuit, 2);

    // Verify bank expansion logic: 2*2 = 4 coils with correct names
    // This mirrors the bank expansion in execute_simulation()
    let mut expanded_names = Vec::new();
    for c in 0..bank.circuits {
        for i in 0..bank.coils_per_circuit {
            let letter = (b'a' + (i as u8)) as char;
            expanded_names.push(format!("{}_{}{}", bank_comp.name, c, letter));
        }
    }
    assert_eq!(expanded_names.len(), 4, "2×2 bank should expand to 4 coils");
    assert_eq!(expanded_names[0], "mchx_0a");
    assert_eq!(expanded_names[1], "mchx_0b");
    assert_eq!(expanded_names[2], "mchx_1a");
    assert_eq!(expanded_names[3], "mchx_1b");
}

/// Integration: run_simulation() with frequency_hz: 40.0 in a complete 3-port
/// screw topology does not produce a frequency-validation error.
#[test]
fn test_frequency_hz_40_passes_cli_simulation() {
    use entropyk_cli::run::run_simulation;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("screw_freq_integration.json");

    let json = r#"
    {
        "name": "AC2 Integration",
        "fluid": "R134a",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "ScrewEconomizerCompressor",
                        "name": "screw_0",
                        "nominal_frequency_hz": 50.0,
                        "frequency_hz": 40.0,
                        "mechanical_efficiency": 0.92,
                        "economizer_fraction": 0.12,
                        "mf_a00": 1.2, "mf_a10": 0.003, "mf_a01": -0.002, "mf_a11": 0.00001,
                        "pw_b00": 55000.0, "pw_b10": 200.0, "pw_b01": -300.0, "pw_b11": 0.5,
                        "p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
                        "p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
                        "p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
                    },
                    { "type": "Placeholder", "name": "cond",   "n_equations": 2 },
                    { "type": "Placeholder", "name": "evap",   "n_equations": 2 },
                    { "type": "Placeholder", "name": "eco_hx", "n_equations": 2 }
                ],
                "edges": [
                    { "from": "screw_0:discharge",   "to": "cond:inlet"          },
                    { "from": "cond:outlet",          "to": "evap:inlet"          },
                    { "from": "evap:outlet",          "to": "screw_0:suction"     },
                    { "from": "eco_hx:outlet",        "to": "screw_0:economizer"  }
                ]
            }
        ],
        "solver": { "strategy": "fallback", "max_iterations": 5 }
    }
    "#;

    std::fs::write(&config_path, json).unwrap();

    let result = run_simulation(&config_path, None, false).unwrap();

    // The simulation may fail due to topology/solver mismatches with placeholder components.
    // Critical assertion: it must NOT error because of frequency validation (= AC2 would fail).
    if let Some(err) = &result.error {
        assert!(
            !err.to_lowercase().contains("frequency"),
            "CLI must not error on frequency validation (AC2): {}",
            err
        );
    }
}

/// Task 4.3: Verify that fan_control: "bounded" config goes through the full CLI pipeline
/// without panicking or erroring at the BoundedVariable insertion step.
///
/// This exercises the post-finalize() control path in execute_simulation().
#[test]
fn test_fan_control_bounded_does_not_error() {
    use entropyk_cli::run::run_simulation;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("mchx_fan_bounded.json");

    let json = r#"
    {
        "fluid": "R134a",
        "circuits": [{
            "id": 0,
            "components": [{
                "type": "MchxCondenserCoil",
                "name": "mchx_coil",
                "ua_nominal_kw_k": 8.5,
                "fan_speed": 0.8,
                "air_inlet_temp_c": 35.0,
                "fan_control": "bounded",
                "fan_speed_min": 0.1,
                "fan_speed_max": 1.0
            }],
            "edges": []
        }],
        "solver": { "strategy": "fallback", "max_iterations": 3 }
    }
    "#;

    std::fs::write(&config_path, json).unwrap();

    let result = run_simulation(&config_path, None, false).unwrap();

    // The simulation should proceed without erroring at config/finalize/variable-insertion stage.
    // It may not converge (isolated single-port component) but must not produce a
    // fan_speed-related or bounded-variable insertion error.
    if let Some(ref err) = result.error {
        assert!(
            !err.to_lowercase().contains("bounded"),
            "CLI must not error on bounded-variable insertion (Task 4.3): {}",
            err
        );
        assert!(
            !err.to_lowercase().contains("fan_speed"),
            "CLI must not error on fan_speed variable creation (Task 4.3): {}",
            err
        );
    }
}

/// Integration test for story 15-3: CLI uses real Pump<Connected> (2 equations), not stub.
/// A config with two Pumps in a loop must not fail with "State dimension does not match equation count".
#[test]
fn test_pump_real_component_used() {
    use entropyk_cli::run::run_simulation;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("water_loop.json");

    let json = r#"
    {
        "name": "Water loop two pumps",
        "fluid": "Water",
        "circuits": [{
            "id": 0,
            "name": "Water",
            "components": [
                { "type": "Pump", "name": "pump1" },
                { "type": "Pump", "name": "pump2" }
            ],
            "edges": [
                { "from": "pump1:outlet", "to": "pump2:inlet" },
                { "from": "pump2:outlet", "to": "pump1:inlet" }
            ]
        }],
        "solver": { "strategy": "newton", "max_iterations": 50, "tolerance": 1e-6 }
    }
    "#;
    std::fs::write(&config_path, json).unwrap();

    let result = run_simulation(&config_path, None, false).unwrap();

    // Real Pump has 2 equations each -> 4 equations, 2 edges -> 4 state. No dimension mismatch.
    if let Some(ref err) = result.error {
        assert!(
            !err.contains("State dimension") || !err.contains("equation count"),
            "Real Pump must be used (no stub); dimension mismatch indicates stub: {}",
            err
        );
    }
}

/// Story 15-4: BphxEvaporator and BphxCondenser are accepted by create_component (config parsing).
/// Asserts that a config with both types does not yield "Unknown component type".
#[test]
fn test_bphx_evaporator_and_condenser_config_parsing() {
    use entropyk_cli::run::run_simulation;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("bphx_parsing.json");

    let json = r#"
    {
        "name": "BPHX parsing test",
        "fluid": "R410A",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "BphxEvaporator",
                        "name": "evap",
                        "refrigerant": "R410A",
                        "secondary_fluid": "Water",
                        "dh_m": 0.003,
                        "area_m2": 0.5,
                        "n_plates": 20
                    },
                    {
                        "type": "BphxCondenser",
                        "name": "cond",
                        "refrigerant": "R410A",
                        "secondary_fluid": "Water",
                        "target_subcooling_k": 3.0,
                        "dh_m": 0.003,
                        "area_m2": 0.5,
                        "n_plates": 20
                    }
                ],
                "edges": []
            }
        ],
        "solver": { "strategy": "newton", "max_iterations": 10, "tolerance": 1e-6 }
    }
    "#;
    std::fs::write(&config_path, json).unwrap();

    let result = run_simulation(&config_path, None, false).unwrap();

    // create_component must accept both types. Two distinct assertions:
    // (a) no "Unknown component type" — both Bphx types must be registered.
    // (b) no "Failed to create component" — construction must succeed, not just be recognised.
    if let Some(ref err) = result.error {
        assert!(
            !err.contains("Unknown component type"),
            "BphxEvaporator and BphxCondenser must be registered in create_component: {}",
            err
        );
        assert!(
            !err.contains("Failed to create component"),
            "BphxEvaporator/BphxCondenser construction must not fail: {}",
            err
        );
    }

    // We expect Error or NonConverged (edges empty -> topology/finalization failure), not config parse failure.
    match result.status {
        SimulationStatus::Error => {
            // Failure is expected (e.g. isolated nodes); config parsing and construction succeeded.
        }
        SimulationStatus::NonConverged
        | SimulationStatus::Converged
        | SimulationStatus::Timeout => {
            // Also acceptable if we get to solver stage.
        }
    }
}

/// Story 15-4 — Integration: BphxEvaporator and BphxCondenser in bounded circuits
/// (RefrigerantSource → Bphx → RefrigerantSink) must reach the solver stage.
/// Validates that config parsing, component construction, AND edge routing all succeed.
#[test]
fn test_bphx_bounded_circuit_reaches_solver_stage() {
    use entropyk_cli::run::run_simulation;

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/bphx_evaporator_condenser.json");

    if !example.exists() {
        panic!(
            "Test fixture missing: {} — this test requires the example file to exist",
            example.display()
        );
    }

    let result = run_simulation(&example, None, false).unwrap();

    // Three-gate assertion: config → construction → edge routing must all succeed.
    if let Some(ref err) = result.error {
        assert!(
            !err.contains("Unknown component type"),
            "[Gate 1] Bphx type not registered: {}",
            err
        );
        assert!(
            !err.contains("Failed to create component"),
            "[Gate 2] Bphx construction failed: {}",
            err
        );
        assert!(
            !err.contains("Failed to add edge") && !err.contains("Edge references unknown"),
            "[Gate 3] Edge routing failed: {}",
            err
        );
        // Any remaining error (e.g. solver non-convergence) is acceptable.
    }
}

/// AC2 + spec-cli-failure-diagnostics.md: Given a DoF-square closed-loop simulation
/// that the solver fails to converge (iteration budget exhausted), the JSON result
/// includes `failure_diagnostics` with a finite residual norm.
///
/// Fixture: a DoF-square R134a vapor-compression cycle. Solver budget is capped so
/// Newton cannot reach tolerance; the run must fail at the solver stage (not during
/// finalize) and surface post-mortem diagnostics.
#[test]
fn test_failed_run_json_includes_failure_diagnostics() {
    use entropyk_cli::run::run_simulation;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("tight_tolerance_failure.json");

    let json = r#"
    {
        "fluid": "R134a",
        "fluid_backend": "CoolProp",
        "circuits": [
            {
                "id": 0,
                "components": [
                    {
                        "type": "IsentropicCompressor", "name": "comp",
                        "isentropic_efficiency": 0.70,
                        "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0,
                        "emergent_pressure": true,
                        "displacement_m3": 6.5e-5, "speed_hz": 50.0,
                        "volumetric_efficiency": 0.92
                    },
                    {
                        "type": "Condenser", "name": "cond", "ua": 766.0,
                        "emergent_pressure": true, "subcooling_k": 5.0,
                        "secondary_fluid": "Water"
                    },
                    {
                        "type": "IsenthalpicExpansionValve", "name": "exv",
                        "t_evap_k": 278.15, "emergent_pressure": true
                    },
                    {
                        "type": "Evaporator", "name": "evap", "ua": 1468.0,
                        "emergent_pressure": true,
                        "secondary_fluid": "Water"
                    },
                    { "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
                    { "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
                    { "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
                    { "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
                ],
                "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" },
                    { "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
                    { "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
                    { "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
                    { "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
                ]
            }
        ],
        "solver": {
            "strategy": "newton",
            "max_iterations": 2,
            "tolerance": 1e-100
        }
    }
    "#;
    std::fs::write(&config_path, json).unwrap();

    let result = run_simulation(&config_path, None, false).unwrap();

    assert!(
        matches!(
            result.status,
            SimulationStatus::NonConverged | SimulationStatus::Error
        ),
        "expected solver-stage failure, got {:?} (error: {:?})",
        result.status,
        result.error
    );

    let diagnostics = result.failure_diagnostics.as_ref().unwrap_or_else(|| {
        panic!(
            "failure_diagnostics must be present after solver-stage failure \
             (status: {:?}, error: {:?})",
            result.status, result.error
        )
    });

    let json_str = serde_json::to_string(&result).unwrap();
    assert!(
        json_str.contains("failure_diagnostics"),
        "JSON result must contain 'failure_diagnostics' key"
    );
    assert!(
        diagnostics.final_residual_norm.is_finite() && diagnostics.final_residual_norm >= 0.0,
        "failure_diagnostics.final_residual_norm must be finite and non-negative, got {}",
        diagnostics.final_residual_norm
    );
}

/// spec-cli-failure-diagnostics.md AC3 (integration): Given an empty system config that
/// triggers `InvalidSystem` before any solver iterations, the CLI serializes the result
/// successfully with `failure_diagnostics` absent from the JSON and no panic.
#[test]
fn test_empty_system_error_omits_failure_diagnostics() {
    use entropyk_cli::run::run_simulation;

    let dir = tempdir().unwrap();
    let config_path = dir.path().join("empty_system.json");

    let json = r#"
    {
        "fluid": "R134a",
        "circuits": [],
        "solver": { "strategy": "newton", "max_iterations": 5, "tolerance": 1e-6 }
    }
    "#;
    std::fs::write(&config_path, json).unwrap();

    let result = run_simulation(&config_path, None, false).unwrap();

    // Empty system must fail (no state variables or equations).
    assert_eq!(
        result.status,
        SimulationStatus::Error,
        "Empty system must result in Error status"
    );
    assert!(result.error.is_some(), "error field must be present");

    // No solver iterations ran: failure_diagnostics must be absent.
    assert!(
        result.failure_diagnostics.is_none(),
        "failure_diagnostics must be absent for pre-iteration structural failures"
    );

    // Serialization must succeed and omit the field.
    let json_str = serde_json::to_string(&result).unwrap();
    assert!(
        !json_str.contains("failure_diagnostics"),
        "failure_diagnostics key must be absent from JSON for structural failures"
    );
}

/// Integration: the emergent-pressure chiller example must converge AND report
/// genuine cycle performance (cooling/heating capacity, power, COP) that closes
/// the First Law (Q_heating = Q_cooling + W_input). Also checks that raising the
/// condenser secondary water temperature lowers the COP — i.e. performance truly
/// responds to the secondary conditions, not fixed design points.
#[test]
fn test_emergent_chiller_reports_performance_and_reacts_to_secondary() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r134a_emergent_pressure.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    // CoolProp may be unavailable in some build environments; only assert the
    // performance contract when the solve actually converged.
    if result.status != SimulationStatus::Converged {
        return;
    }

    let perf = result
        .performance
        .expect("converged emergent cycle must report performance");
    let q_cool = perf.q_cooling_kw.expect("cooling capacity");
    let q_heat = perf.q_heating_kw.expect("heating capacity");
    let power = perf.compressor_power_kw.expect("power input");
    let cop = perf.cop.expect("COP");

    assert!(q_cool > 0.0, "cooling capacity must be positive: {q_cool}");
    assert!(power > 0.0, "power input must be positive: {power}");
    assert!(cop > 1.0 && cop < 15.0, "COP must be physical: {cop}");
    // First Law: rejected heat = absorbed heat + shaft work.
    assert!(
        (q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
        "First Law must close: q_heat={q_heat}, q_cool={q_cool}, power={power}"
    );

    // Raising the condenser secondary (water) inlet temperature raises the
    // condensing pressure and lowers the COP — a genuine coupled response.
    let base_json = std::fs::read_to_string(&example).unwrap();
    let hot_json = base_json.replace("\"t_set_c\": 30.0", "\"t_set_c\": 40.0");
    assert_ne!(
        base_json, hot_json,
        "condenser secondary temp must be patched"
    );
    let hot = simulate_from_json(&hot_json).unwrap();
    if hot.status == SimulationStatus::Converged {
        let hot_cop = hot.performance.and_then(|p| p.cop).expect("hot COP");
        assert!(
            hot_cop < cop,
            "warmer condenser water must lower COP: {hot_cop} !< {cop}"
        );
    }
}

/// Integration (Story 3.4 completion): a PHYSICAL `thermal_couplings` entry
/// (`hot_component` + `cold_component`) must genuinely transfer the condenser's
/// measured duty into the coupled water loop's `ThermalLoad`:
///
/// 1. the two-circuit system converges;
/// 2. the water-side enthalpy rise carries EXACTLY the rejected duty
///    (ṁ_w·Δh = Q_heating, First Law across circuits);
/// 3. halving the coupling `efficiency` halves the transferred heat — proving
///    the coupled response is real, not an inert stub.
#[test]
fn test_physical_thermal_coupling_transfers_condenser_duty_to_water_loop() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r410a_coupled_water_loop.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    // CoolProp may be unavailable in some build environments; only assert the
    // coupling contract when the solve actually converged.
    if result.status != SimulationStatus::Converged {
        return;
    }

    let perf = result.performance.expect("performance metrics");
    let q_heat_kw = perf.q_heating_kw.expect("heating capacity");
    let q_cool_kw = perf.q_cooling_kw.expect("cooling capacity");
    let power_kw = perf.compressor_power_kw.expect("power input");
    assert!(q_heat_kw > 1.0, "rejected duty must be real: {q_heat_kw}");
    // First Law on the refrigerant circuit (ThermalLoad must NOT be counted
    // as extra cooling capacity).
    assert!(
        (q_heat_kw - (q_cool_kw + power_kw)).abs() < 1e-3 * q_heat_kw,
        "First Law must close: q_heat={q_heat_kw}, q_cool={q_cool_kw}, power={power_kw}"
    );

    // Water loop = edges 6 (load inlet) and 7 (load outlet), per config order.
    // Circuit 0 has 6 edges (4 refrigerant + 2 evaporator secondary); circuit 1
    // (water loop) edges follow at indices 6 and 7.
    let state = result.state.expect("state entries");
    let h_in = state[6].enthalpy_kj_kg;
    let h_out = state[7].enthalpy_kj_kg;
    let m_water = 0.9; // kg/s, from the fixture
    let q_water_kw = m_water * (h_out - h_in);
    assert!(
        (q_water_kw - q_heat_kw).abs() < 1e-2 * q_heat_kw,
        "coupled water loop must absorb the rejected duty: \
         m*dh = {q_water_kw} kW vs Q_heating = {q_heat_kw} kW"
    );
    // Loop pressure pinned by BrineSource/BrineSink boundaries at 2 bar.
    assert!(
        (state[6].pressure_bar - 2.0).abs() < 1e-6 && (state[7].pressure_bar - 2.0).abs() < 1e-6,
        "water loop pressure must be pinned by the boundaries: {} / {}",
        state[6].pressure_bar,
        state[7].pressure_bar
    );

    // Halving the coupling efficiency must halve the transferred heat.
    let base_json = std::fs::read_to_string(&example).unwrap();
    let half_json = base_json.replace("\"efficiency\": 1.0", "\"efficiency\": 0.5");
    assert_ne!(base_json, half_json, "efficiency must be patched");
    let half = simulate_from_json(&half_json).unwrap();
    if half.status == SimulationStatus::Converged {
        let half_state = half.state.expect("state");
        let dh_half = half_state[7].enthalpy_kj_kg - half_state[6].enthalpy_kj_kg;
        let dh_full = h_out - h_in;
        assert!(
            (dh_half - 0.5 * dh_full).abs() < 1e-2 * dh_full,
            "half efficiency must halve the water enthalpy rise: \
             {dh_half} vs 0.5*{dh_full}"
        );
    }
}

/// Integration (BOLT node parity): inline `Anchor` (probe mode) and
/// `HeatSource` (fixed motor-cooling duty) inserted into the full-physics
/// chiller must:
///
/// 1. keep the cycle square and convergent (both are DoF-neutral inline);
/// 2. preserve pass-through continuity across the anchor (liquid line);
/// 3. inject EXACTLY q_w into the suction gas: ṁ·Δh_suction = 500 W;
/// 4. close the First Law including the parasitic heat:
///    Q_cond = Q_evap + W_comp + Q_motor (HeatSource is excluded from the
///    cooling-capacity aggregation).
#[test]
fn test_bolt_inline_nodes_anchor_probe_and_heat_source() {
    use entropyk_cli::run::run_simulation;

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r410a_bolt_nodes.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    if result.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }

    // Edge order per config: 0 comp→cond, 1 cond→anchor, 2 anchor→exv,
    // 3 exv→evap, 4 evap→heat_source, 5 heat_source→comp.
    let state = result.state.expect("state entries");

    // (2) Anchor continuity: identical P and h on both sides.
    assert!(
        (state[1].pressure_bar - state[2].pressure_bar).abs() < 1e-9
            && (state[1].enthalpy_kj_kg - state[2].enthalpy_kj_kg).abs() < 1e-9,
        "anchor must be transparent: {:?} vs {:?}",
        (state[1].pressure_bar, state[1].enthalpy_kj_kg),
        (state[2].pressure_bar, state[2].enthalpy_kj_kg),
    );

    let perf = result.performance.expect("performance metrics");
    let q_cool_kw = perf.q_cooling_kw.expect("cooling capacity");
    let q_heat_kw = perf.q_heating_kw.expect("heating capacity");
    let power_kw = perf.compressor_power_kw.expect("power input");

    // (3) HeatSource: suction enthalpy rise carries exactly q_w = 500 W.
    // Recover ṁ from the evaporator duty: ṁ = Q_evap / Δh_evap.
    let dh_evap = state[4].enthalpy_kj_kg - state[3].enthalpy_kj_kg;
    assert!(
        dh_evap > 1.0,
        "evaporator enthalpy rise must be real: {dh_evap}"
    );
    let m_dot = q_cool_kw / dh_evap;
    assert!(m_dot > 1e-3, "refrigerant flow must be real: {m_dot}");
    let q_injected_w = m_dot * (state[5].enthalpy_kj_kg - state[4].enthalpy_kj_kg) * 1e3;
    assert!(
        (q_injected_w - 500.0).abs() < 5.0,
        "motor-cooling duty must be injected: {q_injected_w} W"
    );

    // (4) First Law with the parasitic term.
    assert!(
        (q_heat_kw - (q_cool_kw + power_kw + 0.5)).abs() < 1e-2 * q_heat_kw,
        "First Law with motor heat must close: q_heat={q_heat_kw}, \
         q_cool={q_cool_kw}, power={power_kw}, q_motor=0.5"
    );
}

/// Integration (arch-6): the physical EXV orifice actuator must (a) keep the
/// emergent cycle DoF-balanced and convergent, and (b) act as a genuine inverse
/// design — since the compressor fixes the mass flow, the orifice equation solves
/// for the required fractional opening. Changing only `Kv` must therefore leave
/// the solved thermodynamic cycle unchanged (the opening absorbs the difference),
/// proving the extra unknown/equation are balanced and non-invasive.
#[test]
fn test_exv_orifice_actuator_balances_dof_and_is_inverse_design() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r134a_exv_orifice.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    if result.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }

    let perf = result
        .performance
        .expect("converged orifice cycle must report performance");
    let q_cool = perf.q_cooling_kw.expect("cooling capacity");
    let q_heat = perf.q_heating_kw.expect("heating capacity");
    let power = perf.compressor_power_kw.expect("power input");
    let cop = perf.cop.expect("COP");
    assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
    assert!(
        (q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
        "First Law must close with the orifice actuator active"
    );

    // Baseline evaporating/condensing pressures from the solved refrigerant edges.
    // Only the first 4 edges are refrigerant; the rest are secondary water loop.
    let base_state = result.state.expect("state");
    let base_p_cond = base_state
        .iter()
        .take(4)
        .map(|e| e.pressure_bar)
        .fold(f64::MIN, f64::max);
    let base_p_evap = base_state
        .iter()
        .take(4)
        .map(|e| e.pressure_bar)
        .fold(f64::MAX, f64::min);

    // Double Kv: the required opening halves, but the cycle is unchanged.
    let base_json = std::fs::read_to_string(&example).unwrap();
    let big_kv = base_json.replace("\"orifice_kv\": 2.0e-6", "\"orifice_kv\": 4.0e-6");
    assert_ne!(base_json, big_kv, "Kv must be patched");
    let alt = simulate_from_json(&big_kv).unwrap();
    if alt.status == SimulationStatus::Converged {
        let alt_cop = alt.performance.and_then(|p| p.cop).expect("alt COP");
        assert!(
            (alt_cop - cop).abs() < 1e-3 * cop.max(1.0),
            "orifice is inverse design: doubling Kv must not change COP ({alt_cop} vs {cop})"
        );
        let alt_state = alt.state.expect("alt state");
        let alt_p_cond = alt_state
            .iter()
            .take(4)
            .map(|e| e.pressure_bar)
            .fold(f64::MIN, f64::max);
        let alt_p_evap = alt_state
            .iter()
            .take(4)
            .map(|e| e.pressure_bar)
            .fold(f64::MAX, f64::min);
        assert!(
            (alt_p_cond - base_p_cond).abs() < 1e-2 && (alt_p_evap - base_p_evap).abs() < 1e-2,
            "emergent pressures must be invariant to Kv (inverse design)"
        );
    }
}

/// Integration: the condenser fan head-pressure actuator (arch-6) must hold the
/// condensing temperature at its setpoint by modulating fan speed. Raising the
/// target must raise the emergent condensing pressure (genuine control, not a
/// fixed design point), and the DoF must stay balanced (converges).
#[test]
fn test_fan_head_pressure_actuator_tracks_condensing_setpoint() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/heatpump_r134a_fan_headpressure.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    if result.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }

    let perf = result
        .performance
        .expect("converged fan cycle must report performance");
    let q_cool = perf.q_cooling_kw.expect("cooling capacity");
    let q_heat = perf.q_heating_kw.expect("heating capacity");
    let power = perf.compressor_power_kw.expect("power input");
    let cop = perf.cop.expect("COP");
    assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
    assert!(
        (q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
        "First Law must close with the fan actuator active"
    );

    // Condensing pressure from the solved edges (highest edge pressure).
    let base_state = result.state.expect("state");
    let base_p_cond = base_state
        .iter()
        .take(4)
        .map(|e| e.pressure_bar)
        .fold(f64::MIN, f64::max);
    // R134a saturation at 45 °C ≈ 11.6 bar → the fan must hold the setpoint.
    assert!(
        (base_p_cond - 11.6).abs() < 0.6,
        "fan must hold T_cond ≈ 45 °C (P_cond ≈ 11.6 bar), got {base_p_cond} bar"
    );

    // Raise the head-pressure target 45 → 52 °C: the fan slows and the emergent
    // condensing pressure must rise (genuine setpoint tracking).
    let base_json = std::fs::read_to_string(&example).unwrap();
    let hotter = base_json.replace(
        "\"fan_head_pressure_target_c\": 45.0",
        "\"fan_head_pressure_target_c\": 52.0",
    );
    assert_ne!(base_json, hotter, "target must be patched");
    let alt = simulate_from_json(&hotter).unwrap();
    if alt.status == SimulationStatus::Converged {
        let alt_state = alt.state.expect("alt state");
        let alt_p_cond = alt_state
            .iter()
            .take(4)
            .map(|e| e.pressure_bar)
            .fold(f64::MIN, f64::max);
        assert!(
            alt_p_cond > base_p_cond + 0.5,
            "higher condensing setpoint must raise P_cond: {alt_p_cond} !> {base_p_cond}"
        );
    }
}

/// Integration: the flooded-condenser head-pressure actuator (arch-6) must hold
/// the condensing-saturated temperature at its setpoint by flooding the condenser
/// with liquid (reducing the active area UA_eff = (1-lambda)*UA) in low ambient.
/// Lowering the secondary air temperature further must NOT collapse the condensing
/// pressure (the flood level rises to hold it), and raising the target must raise
/// the emergent condensing pressure (genuine head-pressure control), with the DoF
/// balanced (converges).
#[test]
fn test_flooded_head_pressure_actuator_tracks_condensing_setpoint() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r134a_flooded_headpressure.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    if result.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }

    let perf = result
        .performance
        .expect("converged flooded cycle must report performance");
    let q_cool = perf.q_cooling_kw.expect("cooling capacity");
    let q_heat = perf.q_heating_kw.expect("heating capacity");
    let power = perf.compressor_power_kw.expect("power input");
    let cop = perf.cop.expect("COP");
    assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
    assert!(
        (q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
        "First Law must close with the flooded head-pressure actuator active"
    );

    // Condensing pressure from the solved edges (highest edge pressure).
    let base_state = result.state.expect("state");
    let base_p_cond = base_state
        .iter()
        .take(4)
        .map(|e| e.pressure_bar)
        .fold(f64::MIN, f64::max);
    // R134a saturation at 45 °C ≈ 11.6 bar → flooding must hold the setpoint even
    // though the ambient air is cold (5 °C).
    assert!(
        (base_p_cond - 11.6).abs() < 0.6,
        "flooding must hold T_cond ≈ 45 °C (P_cond ≈ 11.6 bar), got {base_p_cond} bar"
    );

    // Drop the ambient air 5 → -5 °C: the flood level rises to compensate but the
    // condensing pressure must NOT collapse (still held near the setpoint).
    let base_json = std::fs::read_to_string(&example).unwrap();
    let colder = base_json.replace("\"t_dry_c\": 5.0", "\"t_dry_c\": -5.0");
    assert_ne!(base_json, colder, "ambient must be patched");
    let alt = simulate_from_json(&colder).unwrap();
    if alt.status == SimulationStatus::Converged {
        let alt_state = alt.state.expect("alt state");
        let alt_p_cond = alt_state
            .iter()
            .take(4)
            .map(|e| e.pressure_bar)
            .fold(f64::MIN, f64::max);
        assert!(
            (alt_p_cond - 11.6).abs() < 0.8,
            "flooding must still hold T_cond ≈ 45 °C in colder ambient, got {alt_p_cond} bar"
        );
    }

    // Raise the head-pressure target 45 → 52 °C: the emergent condensing pressure
    // must rise (genuine setpoint tracking).
    let hotter = base_json.replace(
        "\"flooded_head_pressure_target_c\": 45.0",
        "\"flooded_head_pressure_target_c\": 52.0",
    );
    assert_ne!(base_json, hotter, "target must be patched");
    let alt2 = simulate_from_json(&hotter).unwrap();
    if alt2.status == SimulationStatus::Converged {
        let alt2_state = alt2.state.expect("alt2 state");
        let alt2_p_cond = alt2_state
            .iter()
            .take(4)
            .map(|e| e.pressure_bar)
            .fold(f64::MIN, f64::max);
        assert!(
            alt2_p_cond > base_p_cond + 0.5,
            "higher condensing setpoint must raise P_cond: {alt2_p_cond} !> {base_p_cond}"
        );
    }
}

/// Integration: multi-circuit staging (circuit on/off). A dual-circuit chiller
/// with both circuits energized must deliver ~2x the cooling capacity of the same
/// unit with one circuit staged OFF (enabled=false). The staged-off circuit must
/// be excluded from the solve entirely (its components/edges never added), the DoF
/// must stay balanced (converges), and the COP must be unchanged for identical
/// circuits (staging halves both capacity and power).
#[test]
fn test_circuit_staging_on_off_scales_capacity() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r134a_dual_circuit_staging.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    // Full load: both circuits energized.
    let full = run_simulation(&example, None, false).unwrap();
    if full.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }
    let full_perf = full
        .performance
        .expect("full-load dual-circuit run must report performance");
    let full_q = full_perf.q_cooling_kw.expect("full cooling capacity");
    let full_cop = full_perf.cop.expect("full COP");
    // Both circuits solved: 16 edges total (8 per circuit: 4 refrigerant + 4 secondary).
    let full_edges = full.state.expect("full state").len();
    assert_eq!(full_edges, 16, "both circuits must contribute 8 edges each");

    // Part load: stage circuit B (id 1) OFF by patching enabled -> false.
    // Part load: stage circuit B (id 1) OFF by patching its enabled flag ->
    // false. Split at "Circuit B" so we flip the second circuit's flag only,
    // independent of CRLF/LF line endings.
    let base_json = std::fs::read_to_string(&example).unwrap();
    let split_at = base_json.find("Circuit B").expect("circuit B marker");
    let (head, tail) = base_json.split_at(split_at);
    let tail = tail.replacen("\"enabled\": true", "\"enabled\": false", 1);
    let staged = format!("{head}{tail}");
    assert_ne!(base_json, staged, "circuit B enabled flag must be patched");
    let part = simulate_from_json(&staged).unwrap();
    assert_eq!(
        part.status,
        SimulationStatus::Converged,
        "single staged circuit must still converge"
    );
    let part_perf = part
        .performance
        .expect("part-load run must report performance");
    let part_q = part_perf.q_cooling_kw.expect("part cooling capacity");
    let part_cop = part_perf.cop.expect("part COP");

    // Only circuit A solved: 8 edges (the staged-off circuit is excluded).
    let part_edges = part.state.expect("part state").len();
    assert_eq!(
        part_edges, 8,
        "the staged-off circuit's edges must be excluded from the solve"
    );

    // Identical circuits => staging halves capacity but keeps COP.
    assert!(
        (part_q - 0.5 * full_q).abs() < 0.05 * full_q,
        "staging one of two identical circuits must ~halve capacity: {part_q} vs {full_q}"
    );
    assert!(
        (part_cop - full_cop).abs() < 0.02 * full_cop,
        "COP must be unchanged when staging identical circuits: {part_cop} vs {full_cop}"
    );
}

/// Integration: staging every circuit OFF must be rejected with a clear error
/// rather than producing an empty/degenerate solve.
#[test]
fn test_all_circuits_disabled_is_rejected() {
    use entropyk_cli::run::simulate_from_json;

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r134a_dual_circuit_staging.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }
    let base_json = std::fs::read_to_string(&example).unwrap();
    let all_off = base_json.replace("\"enabled\": true", "\"enabled\": false");
    let result = simulate_from_json(&all_off).unwrap();
    assert_eq!(result.status, SimulationStatus::Error);
    assert!(
        result
            .error
            .as_deref()
            .unwrap_or_default()
            .contains("All circuits are disabled"),
        "must report a clear all-circuits-disabled error, got: {:?}",
        result.error
    );
}

/// Integration: the four-way reversing valve (arch-6) must impose a genuine
/// pressure drop and internal-leakage penalty on the cycle. The condenser-inlet
/// pressure (edge downstream of the valve) must be measurably below the
/// compressor-discharge pressure, and removing the valve's pressure drop must
/// raise the cooling EER (proving the penalty is real thermodynamics, not a
/// cosmetic derating). DoF must stay balanced (converges).
#[test]
fn test_reversing_valve_imposes_pressure_drop_penalty() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/heatpump_r410a_reversing_valve.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    if result.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }

    let perf = result
        .performance
        .expect("converged reversing-valve cycle must report performance");
    let q_cool = perf.q_cooling_kw.expect("cooling capacity");
    let q_heat = perf.q_heating_kw.expect("heating capacity");
    let power = perf.compressor_power_kw.expect("power input");
    let base_eer = perf.cop.expect("EER");
    assert!(q_cool > 0.0 && power > 0.0 && base_eer > 0.5 && base_eer < 15.0);
    assert!(
        (q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
        "First Law must close with the reversing valve active"
    );

    // The valve sits between the compressor discharge (edge 0) and the condenser
    // inlet (edge 1). The condenser-inlet pressure must be below the discharge
    // pressure by the valve ΔP (~0.25 bar baseline + quadratic term).
    let state = result.state.expect("state");
    let p_disch = state[0].pressure_bar;
    let p_cond_in = state[1].pressure_bar;
    assert!(
        p_disch - p_cond_in > 0.15,
        "reversing valve must drop pressure discharge->condenser: {p_disch} -> {p_cond_in} bar"
    );

    // Remove the valve's pressure drop (and leak): the cooling EER must rise,
    // confirming the valve imposes a genuine penalty rather than a cosmetic one.
    let base_json = std::fs::read_to_string(&example).unwrap();
    let no_dp = base_json
        .replace("\"pressure_drop_kpa\": 25.0", "\"pressure_drop_kpa\": 0.0")
        .replace(
            "\"pressure_drop_coeff\": 5.0e5",
            "\"pressure_drop_coeff\": 0.0",
        );
    assert_ne!(base_json, no_dp, "valve penalty params must be patched out");
    let ideal = simulate_from_json(&no_dp).unwrap();
    if ideal.status == SimulationStatus::Converged {
        let ideal_eer = ideal.performance.expect("ideal perf").cop.expect("EER");
        assert!(
            ideal_eer > base_eer,
            "removing the valve pressure drop must raise EER: {ideal_eer} !> {base_eer}"
        );
        // And the condenser-inlet pressure must now equal the discharge pressure.
        let ideal_state = ideal.state.expect("ideal state");
        assert!(
            (ideal_state[0].pressure_bar - ideal_state[1].pressure_bar).abs() < 0.02,
            "with ΔP removed the valve must be a pass-through"
        );
    }
}

/// Integration: the compressor slide-valve actuator (arch-6) must hold the
/// suction-saturated temperature (SST) at its setpoint by unloading the screw
/// compressor. Raising the SST target must raise the emergent evaporating
/// pressure (genuine capacity control, not a fixed design point), and the DoF
/// must stay balanced (converges).
#[test]
fn test_slide_valve_actuator_tracks_suction_setpoint() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r134a_slide_valve.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    if result.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }

    let perf = result
        .performance
        .expect("converged slide-valve cycle must report performance");
    let q_cool = perf.q_cooling_kw.expect("cooling capacity");
    let q_heat = perf.q_heating_kw.expect("heating capacity");
    let power = perf.compressor_power_kw.expect("power input");
    let cop = perf.cop.expect("COP");
    assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
    assert!(
        (q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
        "First Law must close with the slide-valve actuator active"
    );

    // Evaporating pressure from the solved refrigerant edges (lowest edge pressure).
    // Only the first 4 edges are refrigerant; the rest are secondary water/air loop.
    let base_state = result.state.expect("state");
    let base_p_evap = base_state
        .iter()
        .take(4)
        .map(|e| e.pressure_bar)
        .fold(f64::MAX, f64::min);
    // R134a saturation at 3 °C ≈ 3.26 bar → the slide must hold the SST setpoint.
    assert!(
        (base_p_evap - 3.26).abs() < 0.4,
        "slide must hold SST ≈ 3 °C (P_evap ≈ 3.26 bar), got {base_p_evap} bar"
    );

    // Raise the SST target 3 → 6 °C: the slide unloads and the emergent
    // evaporating pressure must rise (genuine setpoint tracking).
    let base_json = std::fs::read_to_string(&example).unwrap();
    let warmer = base_json.replace(
        "\"slide_valve_sst_target_c\": 3.0",
        "\"slide_valve_sst_target_c\": 6.0",
    );
    assert_ne!(base_json, warmer, "target must be patched");
    let alt = simulate_from_json(&warmer).unwrap();
    if alt.status == SimulationStatus::Converged {
        let alt_state = alt.state.expect("alt state");
        let alt_p_evap = alt_state
            .iter()
            .take(4)
            .map(|e| e.pressure_bar)
            .fold(f64::MAX, f64::min);
        assert!(
            alt_p_evap > base_p_evap + 0.2,
            "higher SST setpoint must raise P_evap: {alt_p_evap} !> {base_p_evap}"
        );
    }
}

/// Integration: the compressor liquid-injection actuator (arch-6) must be driven
/// by a declared `controls[]` loop that holds the discharge gas temperature (DGT)
/// at a maximum limit. Enabling the limiter (lower target) must genuinely
/// desuperheat the discharge stream (lower comp-outlet enthalpy) versus a run
/// where the limit is inactive, while the DoF stays balanced (converges) and the
/// reported electrical power / COP stay physical (the shaft work must NOT collapse
/// to the desuperheated edge enthalpy).
#[test]
fn test_liquid_injection_actuator_desuperheats_discharge() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r134a_liquid_injection.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    if result.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }

    // Physical performance contract: injection must not deflate the electrical
    // power. A collapsed power (≈0) would signal the shaft work was mistakenly
    // taken from the desuperheated edge enthalpy.
    let perf = result
        .performance
        .expect("converged injection cycle must report performance");
    let q_cool = perf.q_cooling_kw.expect("cooling capacity");
    let power = perf.compressor_power_kw.expect("power input");
    let cop = perf.cop.expect("COP");
    assert!(q_cool > 0.0, "cooling capacity must be positive: {q_cool}");
    assert!(
        power > 0.5,
        "electrical power must stay physical with injection, got {power} kW"
    );
    assert!(cop > 1.0 && cop < 12.0, "COP must be physical: {cop}");

    // Comp-outlet (edge 0) enthalpy with the active DGT limiter.
    let base_state = result.state.expect("state");
    let h_dis_limited = base_state[0].enthalpy_kj_kg;

    // Raise the DGT target far above the natural discharge temperature so the
    // limiter never engages (injection ≈ off): the discharge enthalpy must then
    // be HIGHER (no desuperheat). This proves the injection genuinely acts.
    let base_json = std::fs::read_to_string(&example).unwrap();
    // The fixture's DGT limit (330 K) sits below the cycle's natural discharge
    // temperature, so the limiter genuinely binds in the base run.
    let no_inj = base_json.replace("\"target\": 330.0", "\"target\": 500.0");
    assert_ne!(base_json, no_inj, "target must be patched");
    let alt = simulate_from_json(&no_inj).unwrap();
    if alt.status == SimulationStatus::Converged {
        let alt_state = alt.state.expect("alt state");
        let h_dis_natural = alt_state[0].enthalpy_kj_kg;
        assert!(
            h_dis_natural > h_dis_limited + 5.0,
            "active DGT limiter must desuperheat the discharge: \
             limited {h_dis_limited} !< natural {h_dis_natural} kJ/kg"
        );
    }
}

/// Integration: the `rate` command must re-solve the emergent chiller at four
/// standardized part-load points and integrate a genuine IPLV. Also verifies the
/// coupled physics: as load drops (colder condenser water + reduced compressor
/// speed), the pressure lift falls and the per-point EER increases monotonically.
#[test]
fn test_rate_command_computes_iplv_and_part_load_eers() {
    use entropyk_cli::rate::run_rate;

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/rate_chiller_iplv_ahri.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let report = run_rate(&example, None).expect("rate command must run");
    assert_eq!(report.metric, "AHRI 550/590 IPLV");
    assert_eq!(report.points.len(), 4, "four part-load points expected");

    // CoolProp may be unavailable in some build environments; only assert the
    // physics contract when every point actually converged.
    if report.points.iter().any(|p| p.status != "converged") {
        return;
    }

    // Points are ordered by descending load fraction (100/75/50/25 %).
    let eers: Vec<f64> = report
        .points
        .iter()
        .map(|p| p.eer.expect("converged point must report EER"))
        .collect();
    for e in &eers {
        assert!(*e > 1.0 && *e < 20.0, "EER must be physical: {e}");
    }
    // Colder condenser water + lower speed at part load ⇒ smaller lift ⇒ higher EER.
    assert!(
        eers[0] < eers[1] && eers[1] < eers[2] && eers[2] < eers[3],
        "part-load EER must increase as load drops: {eers:?}"
    );

    let iplv = report
        .integrated_value
        .expect("IPLV must be integrated when all points converge");
    // IPLV is the AHRI-weighted average, so it must lie within the EER range.
    let min = eers.iter().cloned().fold(f64::INFINITY, f64::min);
    let max = eers.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    assert!(
        iplv >= min && iplv <= max,
        "IPLV {iplv} must lie within per-point EER range [{min}, {max}]"
    );
}

/// End-to-end SCOP (EN 14825 bin method): the air-source heat pump example is
/// re-solved at every climate bin. As the outdoor temperature rises the evaporator
/// warms, the pressure lift shrinks and the full-load COP increases monotonically;
/// the aggregated SCOP must be a physical value bounded by the per-bin COPs.
#[test]
fn test_scop_command_computes_seasonal_cop_over_bins() {
    use entropyk_cli::seasonal::{run_seasonal, SeasonalMode};

    let example =
        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/scop_heatpump_r134a.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let report = run_seasonal(&example, None, SeasonalMode::Scop).expect("scop command must run");
    assert_eq!(report.metric, "SCOP");
    assert_eq!(
        report.total_hours, 4910.0,
        "EN 14825 average season total hours"
    );
    assert_eq!(report.bins.len(), 26, "EN 14825 average has 26 bins");

    // CoolProp may be unavailable in some build environments; only assert the
    // physics contract when every demanded bin actually converged.
    if report
        .bins
        .iter()
        .any(|b| b.demand_w > 0.0 && b.full_cop.is_none())
    {
        return;
    }

    // Full-load COP must rise monotonically as the outdoor (evaporator) air warms.
    let cops: Vec<f64> = report.bins.iter().filter_map(|b| b.full_cop).collect();
    for w in cops.windows(2) {
        assert!(
            w[1] > w[0],
            "full-load COP must increase as outdoor temp rises: {cops:?}"
        );
    }

    let scop = report
        .integrated_value
        .expect("SCOP must be integrated when all demanded bins converge");
    let min = cops.iter().cloned().fold(f64::INFINITY, f64::min);
    let max = cops.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    // The seasonal COP is an energy-weighted average degraded by cycling and
    // backup, so it must not exceed the best full-load COP.
    assert!(
        scop > 1.0 && scop <= max,
        "SCOP {scop} must be physical and <= best full COP {max}"
    );
    assert!(
        scop > min * 0.5,
        "SCOP {scop} unreasonably low vs min COP {min}"
    );
}

/// spec-cli-failure-diagnostics.md AC3: Given a structural invalid system that fails
/// before any solver iterations (e.g. empty system), the JSON serializes successfully
/// with diagnostics absent and no panic.
#[test]
fn test_structural_failure_serializes_without_diagnostics() {
    use entropyk_cli::run::{SimulationResult, SimulationStatus};

    // A result representing an InvalidSystem failure (no iterations occurred).
    let result = SimulationResult {
        input: "empty.json".to_string(),
        status: SimulationStatus::Error,
        convergence: None,
        iterations: None,
        state: None,
        performance: None,
        error: Some("Invalid system: Empty system has no state variables or equations".to_string()),
        failure_diagnostics: None, // no diagnostics for structural failure
        initialization_diagnostics: None,
        dof: None,
        elapsed_ms: 0,
        raw_state_vector: None,
        solved_variables: Vec::new(),
    };

    let json = serde_json::to_string(&result).unwrap();

    // Must serialize successfully (no panic).
    assert!(json.contains("\"status\":\"error\"") || json.contains("\"status\": \"error\""));
    assert!(json.contains("Empty system"));

    // `failure_diagnostics` key must be absent when None (skip_serializing_if).
    assert!(
        !json.contains("failure_diagnostics"),
        "failure_diagnostics must be omitted when None (backward-compatible)"
    );
}

/// spec-cli-failure-diagnostics.md AC4: `simple_working.json` success path is
/// backward-compatible — `failure_diagnostics` is absent from successful JSON output.
#[test]
fn test_success_result_does_not_include_failure_diagnostics() {
    use entropyk_cli::run::{ConvergenceInfo, SimulationResult, SimulationStatus, StateEntry};

    let result = SimulationResult {
        input: "simple_working.json".to_string(),
        status: SimulationStatus::Converged,
        convergence: Some(ConvergenceInfo {
            final_residual: 1e-8,
            tolerance: 1e-6,
            iterations: None,
            strategy: None,
            iteration_history: vec![],
        }),
        iterations: Some(12),
        state: Some(vec![StateEntry {
            edge: 0,
            pressure_bar: 10.0,
            enthalpy_kj_kg: 420.0,
            ..Default::default()
        }]),
        performance: None,
        error: None,
        failure_diagnostics: None,
        initialization_diagnostics: None,
        dof: None,
        elapsed_ms: 120,
        raw_state_vector: None,
        solved_variables: Vec::new(),
    };

    let json = serde_json::to_string(&result).unwrap();

    assert!(json.contains("\"converged\"") || json.contains("converged"));
    assert!(
        !json.contains("failure_diagnostics"),
        "Successful result must not include failure_diagnostics (backward-compatible)"
    );
}

/// Real evaporator-outlet superheat [K] from solved edge states, using the same
/// definition the controller measures: `SH = T(P_out, h_out) − T_sat(P_out)`.
/// The suction line is the lowest-pressure edge carrying the highest enthalpy.
fn evap_outlet_superheat_k(state: &[entropyk_cli::run::StateEntry], fluid: &str) -> f64 {
    use entropyk_core::{Enthalpy, Pressure};
    use entropyk_fluids::{CoolPropBackend, FluidBackend, FluidId, FluidState, Property, Quality};

    // Only consider refrigerant edges (first 4); secondary water/air loop edges
    // sit at lower pressures and would corrupt the suction-edge search.
    let p_min = state
        .iter()
        .take(4)
        .map(|e| e.pressure_bar)
        .fold(f64::MAX, f64::min);
    let suction = state
        .iter()
        .take(4)
        .filter(|e| (e.pressure_bar - p_min).abs() < 0.1)
        .max_by(|a, b| a.enthalpy_kj_kg.partial_cmp(&b.enthalpy_kj_kg).unwrap())
        .expect("suction (evaporator outlet) edge");

    let backend = CoolPropBackend::new();
    let id = FluidId::new(fluid);
    let p = Pressure::from_bar(suction.pressure_bar);
    let h = Enthalpy::from_joules_per_kg(suction.enthalpy_kj_kg * 1000.0);
    let t = backend
        .property(
            id.clone(),
            Property::Temperature,
            FluidState::PressureEnthalpy(p, h),
        )
        .expect("T(P,h)");
    let t_sat = backend
        .property(
            id,
            Property::Temperature,
            FluidState::PressureQuality(p, Quality(1.0)),
        )
        .expect("T_sat(P)");
    t - t_sat
}

/// Integration (p0b): the evaporator superheat is REGULATED by the expansion-valve
/// opening through a co-solved saturated-control loop. The evaporator runs in
/// regulated-superheat mode (its outlet-closure is dropped), so superheat emerges
/// from the ε-NTU energy balance and the loop drives the EXV opening to the target.
/// Changing the target must move the solved superheat toward it, shift the operating
/// point (opening moved), keep the DoF balanced (converges), and close the First Law.
#[test]
fn test_superheat_control_regulates_via_exv_opening() {
    use entropyk_cli::run::{run_simulation, simulate_from_json};

    let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("examples/chiller_r134a_superheat_control.json");
    if !example.exists() {
        panic!("Test fixture missing: {}", example.display());
    }

    let result = run_simulation(&example, None, false).unwrap();
    if result.status != SimulationStatus::Converged {
        return; // CoolProp may be unavailable in some environments.
    }

    // First Law must close with the superheat-control loop active.
    let perf = result
        .performance
        .expect("converged superheat-control cycle must report performance");
    let q_cool = perf.q_cooling_kw.expect("cooling capacity");
    let q_heat = perf.q_heating_kw.expect("heating capacity");
    let power = perf.compressor_power_kw.expect("power input");
    let cop = perf.cop.expect("COP");
    assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
    assert!(
        (q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
        "First Law must close with the superheat actuator active"
    );

    // Target 5 K: the solved superheat must track it (proportional loop tolerance).
    let base_state = result.state.expect("state");
    let sh_5 = evap_outlet_superheat_k(&base_state, "R134a");
    assert!(
        (sh_5 - 5.0).abs() < 0.5,
        "superheat should track the 5 K target, got {sh_5:.3} K"
    );
    let p_evap_5 = base_state
        .iter()
        .take(4)
        .map(|e| e.pressure_bar)
        .fold(f64::MAX, f64::min);

    // Raise the target to 8 K: the loop must drive a genuinely higher superheat and
    // a different operating point (the EXV opening moved).
    let base_json = std::fs::read_to_string(&example).unwrap();
    let hotter = base_json.replace("\"target\": 5.0", "\"target\": 8.0");
    assert_ne!(base_json, hotter, "superheat target must be patched");
    let alt = simulate_from_json(&hotter).unwrap();
    if alt.status == SimulationStatus::Converged {
        let alt_state = alt.state.expect("alt state");
        let sh_8 = evap_outlet_superheat_k(&alt_state, "R134a");
        assert!(
            (sh_8 - 8.0).abs() < 0.5,
            "superheat should track the 8 K target, got {sh_8:.3} K"
        );
        assert!(
            sh_8 > sh_5 + 1.0,
            "a higher superheat target must raise the solved superheat: {sh_8:.3} !> {sh_5:.3}"
        );
        let p_evap_8 = alt_state
            .iter()
            .take(4)
            .map(|e| e.pressure_bar)
            .fold(f64::MAX, f64::min);
        assert!(
            (p_evap_8 - p_evap_5).abs() > 1e-3,
            "regulating a different superheat must shift the operating point (opening moved): \
             P_evap {p_evap_8:.4} vs {p_evap_5:.4} bar"
        );
    }
}
