//! Structured simulation result types.
//!
//! This module provides [`SimulationResult`] — a high-level, user-friendly wrapper
//! around the raw [`ConvergedState`] that decomposes the solver output into
//! per-component, per-edge, and system-level summaries.
//!
//! # Usage
//!
//! ```ignore
//! use entropyk::{SystemBuilder, Solver, FallbackSolver, extract_simulation_result};
//!
//! let system = SystemBuilder::new()
//!     .component("comp", compressor)?
//!     .edge("comp", "cond")?
//!     .build()?;
//!
//! let mut solver = FallbackSolver::default_solver();
//! let converged = solver.solve(&mut system)?;
//!
//! let result = extract_simulation_result(&system, &converged);
//! println!("{}", result.to_json()?);
//! ```

use std::collections::HashMap;

use entropyk_solver::{ConvergedState, ConvergenceStatus, SimulationMetadata, System};
use petgraph::graph::{EdgeIndex, NodeIndex};
use serde::{Deserialize, Serialize};

// ─────────────────────────────────────────────────────────────────────────────
// Status enum
// ─────────────────────────────────────────────────────────────────────────────

/// Simulation outcome status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SimulationOutcome {
    /// Solver converged within tolerance and iteration budget.
    Converged,
    /// Solver converged but one or more control variables saturated at bounds.
    ControlSaturation,
    /// Solver exceeded time budget but returned the best-known state.
    TimedOut,
    /// Solver did not converge (max iterations, divergence, etc.).
    NonConverged,
}

impl From<ConvergenceStatus> for SimulationOutcome {
    fn from(status: ConvergenceStatus) -> Self {
        match status {
            ConvergenceStatus::Converged => SimulationOutcome::Converged,
            ConvergenceStatus::ControlSaturation => SimulationOutcome::ControlSaturation,
            ConvergenceStatus::TimedOutWithBestState => SimulationOutcome::TimedOut,
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Convergence summary
// ─────────────────────────────────────────────────────────────────────────────

/// User-friendly convergence summary extracted from [`ConvergedState`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConvergenceSummary {
    /// Number of solver iterations performed.
    pub iterations: usize,
    /// L2 norm of the residual vector at the final state.
    pub final_residual: f64,
    /// Whether the solver converged.
    pub converged: bool,
    /// Solver status.
    pub status: SimulationOutcome,
}

// ─────────────────────────────────────────────────────────────────────────────
// Per-port state
// ─────────────────────────────────────────────────────────────────────────────

/// Thermodynamic state at a component port (inlet or outlet).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PortState {
    /// Pressure in Pascals.
    pub pressure_pa: f64,
    /// Specific enthalpy in J/kg.
    pub enthalpy_j_kg: f64,
    /// Mass flow rate in kg/s (if available from the component).
    pub mass_flow_kg_s: Option<f64>,
}

// ─────────────────────────────────────────────────────────────────────────────
// Energy result
// ─────────────────────────────────────────────────────────────────────────────

/// Energy transfers for a single component.
///
/// Sign convention follows the `Component::energy_transfers` method:
/// - `heat_transfer` > 0 means heat added TO the component.
/// - `work` > 0 means work done BY the component on the environment.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnergyResult {
    /// Heat transfer in Watts.
    pub heat_transfer_w: f64,
    /// Work in Watts.
    pub work_w: f64,
}

// ─────────────────────────────────────────────────────────────────────────────
// Per-component result
// ─────────────────────────────────────────────────────────────────────────────

/// Structured result for a single named component.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentResult {
    /// Component name as registered in [`SystemBuilder`](crate::SystemBuilder).
    pub name: String,
    /// Component type signature string.
    pub component_type: String,
    /// Circuit ID (0-based).
    pub circuit: u16,
    /// Inlet port state (first incoming edge), if any.
    pub inlet: Option<PortState>,
    /// Outlet port state (first outgoing edge), if any.
    pub outlet: Option<PortState>,
    /// Energy transfers, if the component reports them.
    pub energy: Option<EnergyResult>,
}

// ─────────────────────────────────────────────────────────────────────────────
// Per-edge result
// ─────────────────────────────────────────────────────────────────────────────

/// Thermodynamic state on a single graph edge.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EdgeResult {
    /// Edge index in the system graph.
    pub edge_id: usize,
    /// Pressure in Pascals.
    pub pressure_pa: f64,
    /// Specific enthalpy in J/kg.
    pub enthalpy_j_kg: f64,
    /// Name of the source component, if resolvable.
    pub source: Option<String>,
    /// Name of the target component, if resolvable.
    pub target: Option<String>,
}

// ─────────────────────────────────────────────────────────────────────────────
// System-level summary
// ─────────────────────────────────────────────────────────────────────────────

/// Aggregated system-level performance metrics.
///
/// Derived by summing `Component::energy_transfers` across all components.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemSummary {
    /// Total cooling capacity in Watts (negative heat_transfer sum from evaporators).
    pub total_cooling_capacity_w: Option<f64>,
    /// Total heating capacity in Watts (positive heat_transfer sum from condensers).
    pub total_heating_capacity_w: Option<f64>,
    /// Total compressor power in Watts.
    pub total_compressor_power_w: Option<f64>,
    /// Total pump power in Watts.
    pub total_pump_power_w: Option<f64>,
    /// Coefficient of Performance (cooling): COP_cooling = Q_cooling / W_compressor.
    pub cop_cooling: Option<f64>,
    /// Coefficient of Performance (heating): COP_heating = Q_heating / W_compressor.
    pub cop_heating: Option<f64>,
}

// ─────────────────────────────────────────────────────────────────────────────
// Solved unknown (free actuator / calibration factor)
// ─────────────────────────────────────────────────────────────────────────────

/// A named solver-computed unknown with its converged value and physical bounds.
///
/// These correspond to bounded control variables that the Newton solve treats
/// as unknowns — free actuators (e.g. expansion-valve `opening`, condenser
/// `fan_speed`), hard inverse-control links, and saturated-controller
/// actuators (calibration factors like `z_ua`, `z_dp`, `z_flow`). They ride
/// inside the flat `raw_state_vector` returned by the solver; this struct
/// attaches a stable id, owning component, short label, and `[min, max]`
/// bounds so the UI can surface them without parsing the raw vector.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SolvedVariable {
    /// Stable id from [`BoundedVariableId`] (e.g. `"exv__opening"`).
    pub id: String,
    /// Owning component name, when known (`None` for globals).
    pub component: Option<String>,
    /// Short human label, e.g. `"opening"`, `"z_ua"`, `"z_dp"`.
    pub variable: String,
    /// Converged value of the unknown.
    pub value: f64,
    /// Lower bound (inclusive).
    pub min: f64,
    /// Upper bound (inclusive).
    pub max: f64,
}

// ─────────────────────────────────────────────────────────────────────────────
// Top-level SimulationResult
// ─────────────────────────────────────────────────────────────────────────────

/// Structured simulation result with per-component, per-edge, and system-level data.
///
/// Constructed via [`extract_simulation_result`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimulationResult {
    /// Simulation outcome status.
    pub status: SimulationOutcome,
    /// Convergence summary.
    pub convergence: ConvergenceSummary,
    /// Traceability metadata from the solver.
    pub metadata: SimulationMetadata,
    /// Per-component results, one entry per named component.
    pub components: Vec<ComponentResult>,
    /// Per-edge results.
    pub edges: Vec<EdgeResult>,
    /// Aggregated system performance summary.
    pub summary: SystemSummary,
    /// Named solver-computed unknowns (free actuators + calibration factors).
    ///
    /// Empty for systems with no bounded control variables. Skipped on
    /// serialization when empty so older consumers keep working.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub solved_variables: Vec<SolvedVariable>,
}

impl SimulationResult {
    /// Serializes the result to a pretty-printed JSON string.
    ///
    /// # Errors
    ///
    /// Returns an error if serialization fails (should not happen with standard types).
    pub fn to_json(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string_pretty(self)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Extraction function
// ─────────────────────────────────────────────────────────────────────────────

/// Extracts a structured [`SimulationResult`] from a solved system.
///
/// This function iterates over all named components and edges in `system`,
/// reads the converged state vector, and assembles a user-friendly result.
///
/// # Arguments
///
/// * `system` - The solved system (must be finalized).
/// * `converged` - The converged state returned by the solver.
pub fn extract_simulation_result(system: &System, converged: &ConvergedState) -> SimulationResult {
    let state = &converged.state;

    // Validate state vector length matches system topology.
    // CM1.4 layout: |branches| + 2*|edges| + internal_component_state.
    let expected_len = system.state_vector_len();
    if state.len() != expected_len {
        tracing::warn!(
            state_len = state.len(),
            expected_len,
            "State vector length does not match system state length; results may be incomplete"
        );
    }

    // Build reverse mapping: NodeIndex -> component name
    let node_to_name: HashMap<NodeIndex, String> = system
        .registered_component_names()
        .filter_map(|name| {
            system
                .get_component_node(name)
                .map(|node| (node, name.to_string()))
        })
        .collect();

    // Build edge incidence: for each node, collect incoming and outgoing edges
    let mut incoming: HashMap<NodeIndex, Vec<EdgeIndex>> = HashMap::new();
    let mut outgoing: HashMap<NodeIndex, Vec<EdgeIndex>> = HashMap::new();
    for edge in system.edge_indices() {
        if let Some((src, tgt)) = system.edge_endpoints(edge) {
            outgoing.entry(src).or_default().push(edge);
            incoming.entry(tgt).or_default().push(edge);
        }
    }

    // --- Per-component results ---
    let mut components = Vec::new();
    let mut total_cooling_w: f64 = 0.0;
    let mut total_heating_w: f64 = 0.0;
    let mut total_compressor_power_w: f64 = 0.0;
    let mut total_pump_power_w: f64 = 0.0;
    let mut has_cooling = false;
    let mut has_heating = false;
    let mut has_compressor_power = false;
    let mut has_pump_power = false;

    for name in system.registered_component_names() {
        let node = match system.get_component_node(name) {
            Some(n) => n,
            None => continue,
        };
        let comp = system.component(node);
        let circuit = system.node_circuit(node).0;

        // Energy transfers
        let energy = comp.energy_transfers(state).map(|(heat, work)| {
            let q = heat.to_watts();
            let w = work.to_watts();

            // Guard against NaN/Inf propagating into system totals
            let q = if q.is_finite() { q } else { 0.0 };
            let w = if w.is_finite() { w } else { 0.0 };

            // Accumulate system totals based on sign conventions
            // Q > 0 = heat into component (evaporator absorbs heat = cooling)
            // Q < 0 = heat out of component (condenser rejects heat = heating)
            if q > 0.0 {
                total_cooling_w += q;
                has_cooling = true;
            } else if q < 0.0 {
                total_heating_w += q.abs();
                has_heating = true;
            }

            // W > 0 = work by component (compressors, pumps consume power)
            if w > 0.0 {
                // Best-effort classification by signature string.
                // Known work-producing components: Compressor, ScrewEconomizerCompressor,
                // Pump, Fan. Unknown work producers are logged and default to compressor.
                let sig = comp.signature().to_lowercase();
                if sig.contains("compressor") || sig.contains("screw") {
                    total_compressor_power_w += w;
                    has_compressor_power = true;
                } else if sig.contains("pump") || sig.contains("fan") {
                    total_pump_power_w += w;
                    has_pump_power = true;
                } else {
                    tracing::debug!(
                        component = name,
                        signature = %comp.signature(),
                        work_w = w,
                        "Unknown work-producing component classified as compressor"
                    );
                    total_compressor_power_w += w;
                    has_compressor_power = true;
                }
            }

            EnergyResult {
                heat_transfer_w: q,
                work_w: w,
            }
        });

        // Mass flow from port_mass_flows (graceful fallback on Err/empty)
        let mass_flows: Vec<f64> = comp
            .port_mass_flows(state)
            .map(|flows| flows.iter().map(|mf| mf.to_kg_per_s()).collect())
            .unwrap_or_default();

        // Inlet: first incoming edge
        let inlet = incoming
            .get(&node)
            .and_then(|edges| edges.first())
            .and_then(|&edge| {
                let (p_idx, h_idx) = system.edge_state_indices(edge);
                Some(PortState {
                    pressure_pa: state.get(p_idx).copied()?,
                    enthalpy_j_kg: state.get(h_idx).copied()?,
                    mass_flow_kg_s: mass_flows.first().copied(),
                })
            });

        // Outlet: first outgoing edge
        let outlet = outgoing
            .get(&node)
            .and_then(|edges| edges.first())
            .and_then(|&edge| {
                let (p_idx, h_idx) = system.edge_state_indices(edge);
                Some(PortState {
                    pressure_pa: state.get(p_idx).copied()?,
                    enthalpy_j_kg: state.get(h_idx).copied()?,
                    mass_flow_kg_s: mass_flows
                        .get(1)
                        .copied()
                        .or_else(|| mass_flows.first().copied()),
                })
            });

        components.push(ComponentResult {
            name: name.to_string(),
            component_type: comp.signature(),
            circuit,
            inlet,
            outlet,
            energy,
        });
    }

    // --- Per-edge results ---
    let edges: Vec<EdgeResult> = system
        .edge_indices()
        .map(|edge| {
            let (p_idx, h_idx) = system.edge_state_indices(edge);
            let p = state.get(p_idx).copied();
            let h = state.get(h_idx).copied();
            let (source, target) = system
                .edge_endpoints(edge)
                .map(|(src, tgt)| {
                    (
                        node_to_name.get(&src).cloned(),
                        node_to_name.get(&tgt).cloned(),
                    )
                })
                .unwrap_or((None, None));
            EdgeResult {
                edge_id: edge.index(),
                pressure_pa: p.unwrap_or(0.0),
                enthalpy_j_kg: h.unwrap_or(0.0),
                source,
                target,
            }
        })
        .collect();

    // --- System summary ---
    let summary = SystemSummary {
        total_cooling_capacity_w: has_cooling.then_some(total_cooling_w),
        total_heating_capacity_w: has_heating.then_some(total_heating_w),
        total_compressor_power_w: has_compressor_power.then_some(total_compressor_power_w),
        total_pump_power_w: has_pump_power.then_some(total_pump_power_w),
        cop_cooling: if has_cooling && has_compressor_power && total_compressor_power_w > 0.0 {
            Some(total_cooling_w / total_compressor_power_w)
        } else {
            None
        },
        cop_heating: if has_heating && has_compressor_power && total_compressor_power_w > 0.0 {
            Some(total_heating_w / total_compressor_power_w)
        } else {
            None
        },
    };

    // --- Convergence summary ---
    let status = SimulationOutcome::from(converged.status.clone());
    let convergence = ConvergenceSummary {
        iterations: converged.iterations,
        final_residual: converged.final_residual,
        converged: converged.is_converged(),
        status,
    };

    // --- Solved unknowns (free actuators + calibration factors) ---
    let solved_variables = extract_solved_variables(system, state);

    SimulationResult {
        status,
        convergence,
        metadata: converged.metadata.clone(),
        components,
        edges,
        summary,
        solved_variables,
    }
}

/// Extracts named solver unknowns (bounded variables / free actuators) from a
/// solved state vector.
///
/// Every bounded variable registered on `system` whose value lives in the
/// Newton state vector — as a physical free actuator, a hard inverse-control
/// link, or a saturated-controller actuator — is materialized into a
/// [`SolvedVariable`] carrying its converged value and `[min, max]` bounds.
///
/// Bounded variables with no resolvable state slot (purely registered, never
/// wired into the solve) are silently skipped; a single `tracing::debug!`
/// line summarizes how many were skipped so coverage gaps are diagnosable
/// without panic risk. Out-of-range indices (state shorter than expected,
/// e.g. a truncated warm-start vector) are likewise skipped.
///
/// # Arguments
///
/// * `system` - The solved system (must be finalized).
/// * `state` - Converged Newton state vector slice (length =
///   `system.full_state_vector_len()` when complete).
pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVariable> {
    let mut out = Vec::new();
    let mut skipped = 0usize;

    for bv in system.bounded_variables() {
        let id = bv.id();
        // Free actuators are laid out in their own block at the tail of the
        // state vector; everything else (hard-control links and saturated-
        // controller actuators) is resolvable via control_variable_state_index.
        let idx = system
            .free_actuators()
            .position(|fid| fid == id)
            .map(|i| system.free_actuator_index(i))
            .or_else(|| system.control_variable_state_index(id));

        match idx {
            Some(i) if i < state.len() => {
                let id_str = id.as_str();
                let component = bv.component_id().map(|s| s.to_string());
                let variable = derive_solved_variable_label(id_str, bv.component_id());
                out.push(SolvedVariable {
                    id: id_str.to_string(),
                    component,
                    variable,
                    value: state[i],
                    min: bv.min(),
                    max: bv.max(),
                });
            }
            _ => {
                skipped += 1;
            }
        }
    }

    // Calibration factors promoted to free unknowns via control loops.
    // Each `Some(idx)` slot means the solver computed a value at `state[idx]`.
    // The generic `actuator` slot is already covered by the bounded-variable path.
    for (name, indices) in system.calib_indices_by_name() {
        for (factor, slot, min, max) in [
            ("z_flow", indices.z_flow, 0.5, 2.0),
            ("z_flow_eco", indices.z_flow_eco, 0.5, 2.0),
            ("z_dp", indices.z_dp, 0.5, 2.0),
            ("z_ua", indices.z_ua, 0.5, 2.0),
            ("z_power", indices.z_power, 0.5, 2.0),
            ("z_etav", indices.z_etav, 0.5, 2.0),
            ("f_w", indices.f_w, 0.0, 1.0),
        ] {
            if let Some(idx) = slot {
                if idx < state.len() {
                    let id = format!("{}__{}", name, factor);
                    // Skip if already emitted by the bounded-variable path.
                    if out.iter().any(|v| v.id == id) {
                        continue;
                    }
                    out.push(SolvedVariable {
                        id,
                        component: Some(name.clone()),
                        variable: factor.to_string(),
                        value: state[idx],
                        min,
                        max,
                    });
                }
            }
        }
    }

    if skipped > 0 {
        tracing::debug!(
            skipped,
            total = system.bounded_variable_count(),
            "Bounded variables skipped when extracting solved variables (no resolvable state slot)"
        );
    }

    out
}

/// Derives the short user-facing variable label from a bounded-variable id.
///
/// Bounded-variable ids follow the convention `"{component}__{factor}"`, e.g.
/// `"exv__opening"`, `"cond__z_ua"`. When the component is known we strip the
/// `"{component}__"` prefix; otherwise we fall back to the segment after the
/// last `__`. The solver-internal `"actuator"` suffix (used for saturated
/// controllers driving an `opening`/`injection` factor) is remapped to the
/// user-facing `"opening"` label.
fn derive_solved_variable_label(id: &str, component_id: Option<&str>) -> String {
    let stripped = match component_id {
        Some(c) => id.strip_prefix(&format!("{}__", c)).unwrap_or(id),
        None => id.rfind("__").map(|i| &id[i + 2..]).unwrap_or(id),
    };
    match stripped {
        "actuator" => "opening".to_string(),
        other => other.to_string(),
    }
}

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

    #[test]
    fn test_simulation_outcome_from_convergence_status() {
        assert_eq!(
            SimulationOutcome::from(ConvergenceStatus::Converged),
            SimulationOutcome::Converged
        );
        assert_eq!(
            SimulationOutcome::from(ConvergenceStatus::TimedOutWithBestState),
            SimulationOutcome::TimedOut
        );
        assert_eq!(
            SimulationOutcome::from(ConvergenceStatus::ControlSaturation),
            SimulationOutcome::ControlSaturation
        );
    }

    #[test]
    fn test_convergence_summary_serialization() {
        let summary = ConvergenceSummary {
            iterations: 42,
            final_residual: 1e-8,
            converged: true,
            status: SimulationOutcome::Converged,
        };
        let json = serde_json::to_string_pretty(&summary).unwrap();
        let deserialized: ConvergenceSummary = serde_json::from_str(&json).unwrap();
        assert_eq!(summary, deserialized);
        assert!(json.contains("\"iterations\": 42"));
        assert!(json.contains("\"converged\": true"));
    }

    #[test]
    fn test_port_state_serialization() {
        let ps = PortState {
            pressure_pa: 200000.0,
            enthalpy_j_kg: 400000.0,
            mass_flow_kg_s: Some(0.1),
        };
        let json = serde_json::to_string(&ps).unwrap();
        let de: PortState = serde_json::from_str(&json).unwrap();
        assert_eq!(ps, de);
        assert!(json.contains("\"pressurePa\":"));
        assert!(json.contains("\"enthalpyJKg\":"));
    }

    #[test]
    fn test_energy_result_serialization() {
        let er = EnergyResult {
            heat_transfer_w: 5000.0,
            work_w: 1500.0,
        };
        let json = serde_json::to_string(&er).unwrap();
        let de: EnergyResult = serde_json::from_str(&json).unwrap();
        assert_eq!(er, de);
    }

    #[test]
    fn test_system_summary_default() {
        let summary = SystemSummary::default();
        assert!(summary.total_cooling_capacity_w.is_none());
        assert!(summary.total_heating_capacity_w.is_none());
        assert!(summary.total_compressor_power_w.is_none());
        assert!(summary.total_pump_power_w.is_none());
        assert!(summary.cop_cooling.is_none());
        assert!(summary.cop_heating.is_none());
    }

    #[test]
    fn test_system_summary_cop_calculation() {
        let summary = SystemSummary {
            total_cooling_capacity_w: Some(10000.0),
            total_heating_capacity_w: Some(12000.0),
            total_compressor_power_w: Some(3000.0),
            total_pump_power_w: Some(200.0),
            cop_cooling: Some(10000.0 / 3000.0),
            cop_heating: Some(12000.0 / 3000.0),
        };
        assert_relative_eq!(summary.cop_cooling.unwrap(), 10.0 / 3.0, epsilon = 1e-10);
        assert_relative_eq!(summary.cop_heating.unwrap(), 4.0, epsilon = 1e-10);
    }

    #[test]
    fn test_edge_result_serialization() {
        let er = EdgeResult {
            edge_id: 3,
            pressure_pa: 500000.0,
            enthalpy_j_kg: 280000.0,
            source: Some("compressor".to_string()),
            target: Some("condenser".to_string()),
        };
        let json = serde_json::to_string(&er).unwrap();
        let de: EdgeResult = serde_json::from_str(&json).unwrap();
        assert_eq!(er, de);
    }

    #[test]
    fn test_component_result_serialization() {
        let cr = ComponentResult {
            name: "evaporator".to_string(),
            component_type: "Evaporator(UA=5.0kW/K)".to_string(),
            circuit: 0,
            inlet: Some(PortState {
                pressure_pa: 300000.0,
                enthalpy_j_kg: 250000.0,
                mass_flow_kg_s: Some(0.05),
            }),
            outlet: Some(PortState {
                pressure_pa: 290000.0,
                enthalpy_j_kg: 400000.0,
                mass_flow_kg_s: Some(0.05),
            }),
            energy: Some(EnergyResult {
                heat_transfer_w: 7500.0,
                work_w: 0.0,
            }),
        };
        let json = serde_json::to_string(&cr).unwrap();
        let de: ComponentResult = serde_json::from_str(&json).unwrap();
        assert_eq!(cr, de);
    }

    #[test]
    fn test_simulation_result_to_json() {
        let result = SimulationResult {
            status: SimulationOutcome::Converged,
            convergence: ConvergenceSummary {
                iterations: 10,
                final_residual: 1e-9,
                converged: true,
                status: SimulationOutcome::Converged,
            },
            metadata: entropyk_solver::SimulationMetadata::new("test_hash".to_string()),
            components: vec![],
            edges: vec![],
            summary: SystemSummary::default(),
            solved_variables: vec![],
        };
        let json = result.to_json().unwrap();
        assert!(json.contains("\"status\": \"converged\""));
        assert!(json.contains("\"iterations\": 10"));
        assert!(json.contains("\"components\": []"));
        assert!(json.contains("\"edges\": []"));

        // Round-trip (compare non-float fields with assert_eq, floats with relative_eq)
        let de: SimulationResult = serde_json::from_str(&json).unwrap();
        assert_eq!(result.status, de.status);
        assert_eq!(result.convergence.iterations, de.convergence.iterations);
        assert_relative_eq!(
            result.convergence.final_residual,
            de.convergence.final_residual,
            epsilon = 1e-15
        );
        assert_eq!(result.convergence.converged, de.convergence.converged);
        assert_eq!(result.convergence.status, de.convergence.status);
        assert_eq!(result.metadata.input_hash, de.metadata.input_hash);
        assert_eq!(result.components, de.components);
        assert_eq!(result.edges, de.edges);
        assert_eq!(result.summary, de.summary);
    }

    #[test]
    fn test_solved_variable_serialization() {
        let sv = SolvedVariable {
            id: "exv__opening".to_string(),
            component: Some("exv".to_string()),
            variable: "opening".to_string(),
            value: 0.62,
            min: 0.02,
            max: 1.0,
        };
        let json = serde_json::to_string(&sv).unwrap();
        let de: SolvedVariable = serde_json::from_str(&json).unwrap();
        assert_eq!(sv, de);
        assert!(json.contains("\"component\":\"exv\""));
        assert!(json.contains("\"variable\":\"opening\""));
    }

    #[test]
    fn test_solved_variables_skipped_when_empty() {
        // Empty solved_variables should be omitted from serialized JSON
        // (skip_serializing_if = "Vec::is_empty") for backward compatibility.
        let result = SimulationResult {
            status: SimulationOutcome::Converged,
            convergence: ConvergenceSummary {
                iterations: 1,
                final_residual: 0.0,
                converged: true,
                status: SimulationOutcome::Converged,
            },
            metadata: entropyk_solver::SimulationMetadata::new("h".to_string()),
            components: vec![],
            edges: vec![],
            summary: SystemSummary::default(),
            solved_variables: vec![],
        };
        let json = result.to_json().unwrap();
        assert!(!json.contains("solvedVariables"));
    }

    #[test]
    fn test_derive_solved_variable_label() {
        // Component known → strip "{component}__" prefix.
        assert_eq!(
            derive_solved_variable_label("exv__opening", Some("exv")),
            "opening"
        );
        assert_eq!(
            derive_solved_variable_label("cond__z_ua", Some("cond")),
            "z_ua"
        );
        // Solver-internal "actuator" suffix remapped to user-facing "opening".
        assert_eq!(
            derive_solved_variable_label("exv__actuator", Some("exv")),
            "opening"
        );
        // Component unknown → fall back to last "__"-separated segment.
        assert_eq!(derive_solved_variable_label("glob__z_dp", None), "z_dp");
        // No separator and no component → id itself.
        assert_eq!(
            derive_solved_variable_label("global_var", None),
            "global_var"
        );
    }
}
