//! FreeCoolingExchanger component for water-side economizer simulation
//!
//! This component models a water-to-water heat exchanger used for free cooling,
//! allowing the use of outdoor air as a cooling source without operating the compressor.
//! Uses ε-NTU method for counter-flow heat exchanger calculation.

use entropyk_core::{CalibIndices, Enthalpy, Power, Temperature};
use entropyk_fluids::FluidBackend;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use crate::{
    CircuitId, Component, ComponentError, ComponentParams, ConnectedPort, JacobianBuilder,
    OperationalState, ResidualVector,
};

/// Default specific heat for water (J/kg/K)
const CP_WATER: f64 = 4186.0;

/// Operating mode of the FreeCoolingExchanger
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum FreeCoolingMode {
    /// Free cooling active (direct heat exchange)
    Active,
    /// Full bypass (no heat exchange)
    Bypass,
    /// Mixed mode (partial bypass)
    Mixed {
        /// Fraction of flow that bypasses the heat exchanger
        bypass_fraction: f64,
    },
}

/// Configuration for the free cooling heat exchanger
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FreeCoolingConfig {
    /// Effectiveness of the heat exchanger (0.0 - 1.0)
    pub effectiveness: f64,
    /// Bypass fraction (0.0 - 1.0)
    pub bypass_fraction: f64,
    /// Minimum outdoor temperature for free cooling (K)
    pub min_outdoor_temp: f64,
    /// Hysteresis to prevent rapid cycling (K)
    pub hysteresis: f64,
    /// Control mode
    pub control_mode: FreeCoolingControlMode,
    /// UA value (W/K) — overall heat transfer coefficient × area
    pub ua: f64,
    /// Cold-side mass flow rate (kg/s)
    pub cold_mass_flow: f64,
    /// Hot-side mass flow rate (kg/s)
    pub hot_mass_flow: f64,
    /// Cold-side specific heat capacity (J/kg/K)
    pub cold_cp: f64,
    /// Hot-side specific heat capacity (J/kg/K)
    pub hot_cp: f64,
    /// Nominal pressure drop on cold side (Pa)
    pub cold_dp_nominal: f64,
    /// Nominal pressure drop on hot side (Pa)
    pub hot_dp_nominal: f64,
}

/// Control mode for free cooling
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum FreeCoolingControlMode {
    /// Manual control (fixed mode)
    Manual,
    /// Automatic control based on outdoor temperature
    AutoTemperature,
    /// Optimized control (minimizes energy consumption)
    Optimized,
}

/// FreeCoolingExchanger component
pub struct FreeCoolingExchanger {
    /// Unique identifier
    id: String,
    /// Circuit ID
    circuit_id: CircuitId,
    /// Configuration
    config: FreeCoolingConfig,
    /// Current mode
    mode: FreeCoolingMode,
    /// Ports (4 ports: cold water in/out, hot water in/out)
    ports: [ConnectedPort; 4],
    /// Outdoor temperature (for auto mode)
    outdoor_temp: Option<Temperature>,
    /// Calculated after convergence
    heat_transfer_rate: Option<Power>,
    /// Current effectiveness (can vary with flow rates)
    current_effectiveness: f64,
    /// Fluid backend for property calculations
    fluid_backend: Option<Arc<dyn FluidBackend>>,
    /// Calibration factor for UA scaling (default 1.0)
    z_ua: f64,
    /// Calibration factor for pressure drop scaling (default 1.0)
    z_dp: f64,
    /// Calibration indices for inverse calibration
    calib_indices: CalibIndices,
}

impl std::fmt::Debug for FreeCoolingExchanger {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("FreeCoolingExchanger")
            .field("id", &self.id)
            .field("circuit_id", &self.circuit_id)
            .field("config", &self.config)
            .field("mode", &self.mode)
            .field("outdoor_temp", &self.outdoor_temp)
            .field("heat_transfer_rate", &self.heat_transfer_rate)
            .field("current_effectiveness", &self.current_effectiveness)
            .field("f_ua", &self.z_ua)
            .field("f_dp", &self.z_dp)
            .field("fluid_backend", &"<FluidBackend>")
            .finish()
    }
}

/// Port index constants
const COLD_INLET: usize = 0;
const COLD_OUTLET: usize = 1;
const HOT_INLET: usize = 2;
const HOT_OUTLET: usize = 3;

impl FreeCoolingExchanger {
    /// Creates a new free cooling heat exchanger
    pub fn new(
        id: &str,
        circuit_id: CircuitId,
        config: FreeCoolingConfig,
        port_cold_inlet: ConnectedPort,
        port_cold_outlet: ConnectedPort,
        port_hot_inlet: ConnectedPort,
        port_hot_outlet: ConnectedPort,
    ) -> Result<Self, ComponentError> {
        if config.effectiveness < 0.0 || config.effectiveness > 1.0 {
            return Err(ComponentError::InvalidState(
                "Effectiveness must be between 0.0 and 1.0".to_string(),
            ));
        }
        if config.bypass_fraction < 0.0 || config.bypass_fraction > 1.0 {
            return Err(ComponentError::InvalidState(
                "Bypass fraction must be between 0.0 and 1.0".to_string(),
            ));
        }

        let current_effectiveness = config.effectiveness;
        Ok(Self {
            id: id.to_string(),
            circuit_id,
            config,
            mode: FreeCoolingMode::Bypass,
            ports: [
                port_cold_inlet,
                port_cold_outlet,
                port_hot_inlet,
                port_hot_outlet,
            ],
            outdoor_temp: None,
            heat_transfer_rate: None,
            current_effectiveness,
            fluid_backend: None,
            z_ua: 1.0,
            z_dp: 1.0,
            calib_indices: CalibIndices::default(),
        })
    }

    /// Sets the fluid backend for property calculations
    pub fn set_fluid_backend(&mut self, backend: Arc<dyn FluidBackend>) {
        self.fluid_backend = Some(backend);
    }

    /// Computes ε-NTU effectiveness for a counter-flow heat exchanger.
    ///
    /// ε = (1 - exp(-NTU × (1 - C_r))) / (1 - C_r × exp(-NTU × (1 - C_r)))
    /// For C_r ≈ 1: ε = NTU / (1 + NTU)
    fn compute_effectiveness(&self, ua: f64, c_cold: f64, c_hot: f64) -> f64 {
        let c_min = c_cold.min(c_hot);
        let c_max = c_cold.max(c_hot);
        let c_r = if c_max > 0.0 { c_min / c_max } else { 0.0 };

        if c_min <= 0.0 || ua <= 0.0 {
            return 0.0;
        }

        let ntu = ua / c_min;

        if (c_r - 1.0).abs() < 1e-6 {
            // Balanced counter-flow: ε = NTU / (1 + NTU)
            ntu / (1.0 + ntu)
        } else {
            let denom = 1.0 - c_r * (-ntu * (1.0 - c_r)).exp();
            if denom.abs() < 1e-12 {
                return 0.0;
            }
            (1.0 - (-ntu * (1.0 - c_r)).exp()) / denom
        }
    }

    /// Reads port enthalpy as raw f64 (J/kg) from the ConnectedPort.
    fn port_enthalpy_raw(&self, idx: usize) -> f64 {
        self.ports[idx].enthalpy().to_joules_per_kg()
    }

    /// Reads port pressure as raw f64 (Pa) from the ConnectedPort.
    fn port_pressure_raw(&self, idx: usize) -> f64 {
        self.ports[idx].pressure().to_pascals()
    }

    /// Estimates temperature from enthalpy using Cp (incompressible fluid).
    fn temperature_from_enthalpy(&self, h: f64, cp: f64) -> f64 {
        // T = h / Cp (simplified for incompressible fluids where h_ref = 0 at T_ref = 0)
        // More accurately: T = T_ref + (h - h_ref) / Cp
        // Using h/Cp as approximation consistent with incompressible assumption
        h / cp
    }

    /// Updates the mode based on conditions
    pub fn update_mode(&mut self, outdoor_temp: Option<Temperature>) -> Result<(), ComponentError> {
        if let Some(t_outdoor) = outdoor_temp {
            self.outdoor_temp = Some(t_outdoor);

            match self.config.control_mode {
                FreeCoolingControlMode::AutoTemperature => {
                    let h_cold_in = self.port_enthalpy_raw(COLD_INLET);
                    let t_cold_in = self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp);

                    match self.mode {
                        FreeCoolingMode::Bypass => {
                            if t_outdoor.0 < (t_cold_in - self.config.min_outdoor_temp) {
                                self.mode = FreeCoolingMode::Active;
                            }
                        }
                        _ => {
                            if t_outdoor.0
                                > (t_cold_in - self.config.min_outdoor_temp
                                    + self.config.hysteresis)
                            {
                                self.mode = FreeCoolingMode::Bypass;
                            }
                        }
                    }
                }
                FreeCoolingControlMode::Optimized => {
                    self.mode = FreeCoolingMode::Active;
                }
                FreeCoolingControlMode::Manual => {}
            }
        }
        Ok(())
    }

    /// Sets the z_ua calibration factor (BOLT `Z_UA`).
    pub fn set_z_ua(&mut self, z_ua: f64) {
        self.z_ua = z_ua;
    }

    /// Returns the z_ua calibration factor.
    pub fn z_ua(&self) -> f64 {
        self.z_ua
    }

    /// Sets the z_dp calibration factor (BOLT `Z_dpc`).
    pub fn set_z_dp(&mut self, z_dp: f64) {
        self.z_dp = z_dp;
    }

    /// Returns the z_dp calibration factor.
    pub fn z_dp(&self) -> f64 {
        self.z_dp
    }

    /// Returns the current operational state
    pub fn operational_state(&self) -> OperationalState {
        match self.mode {
            FreeCoolingMode::Bypass => OperationalState::Bypass,
            _ => OperationalState::On,
        }
    }

    /// Sets the operational state
    pub fn set_operational_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
        match state {
            OperationalState::On => self.mode = FreeCoolingMode::Active,
            OperationalState::Off | OperationalState::Bypass => {
                self.mode = FreeCoolingMode::Bypass;
            }
        }
        Ok(())
    }

    /// Returns the current heat transfer rate
    pub fn heat_transfer_rate(&self) -> Option<Power> {
        self.heat_transfer_rate
    }

    /// Returns the current mode
    pub fn current_mode(&self) -> FreeCoolingMode {
        self.mode
    }

    /// Returns estimated energy savings (in %)
    pub fn energy_savings_percent(&self) -> f64 {
        match self.mode {
            FreeCoolingMode::Active => self.current_effectiveness * 100.0,
            FreeCoolingMode::Bypass => 0.0,
            FreeCoolingMode::Mixed { bypass_fraction } => {
                self.current_effectiveness * bypass_fraction * 100.0
            }
        }
    }

    /// Returns outdoor temperature
    pub fn outdoor_temperature(&self) -> Option<Temperature> {
        self.outdoor_temp
    }

    /// Updates configuration
    pub fn update_config(&mut self, config: FreeCoolingConfig) -> Result<(), ComponentError> {
        if config.effectiveness < 0.0 || config.effectiveness > 1.0 {
            return Err(ComponentError::InvalidState(
                "Effectiveness must be between 0.0 and 1.0".to_string(),
            ));
        }
        self.config = config;
        Ok(())
    }

    /// Returns true if free cooling is active
    pub fn is_free_cooling_active(&self) -> bool {
        self.mode != FreeCoolingMode::Bypass
    }

    /// Calculates effective COP (very high in free cooling)
    pub fn effective_cop(&self) -> f64 {
        match self.mode {
            FreeCoolingMode::Active => 20.0 + self.current_effectiveness * 10.0,
            FreeCoolingMode::Bypass => 1.0,
            FreeCoolingMode::Mixed { bypass_fraction } => {
                let cop_fc = 20.0 + self.current_effectiveness * 10.0;
                bypass_fraction * cop_fc + (1.0 - bypass_fraction) * 1.0
            }
        }
    }

    /// Returns the unique identifier
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Returns the circuit ID
    pub fn circuit_id(&self) -> CircuitId {
        self.circuit_id
    }

    /// Returns a reference to the config
    pub fn config(&self) -> &FreeCoolingConfig {
        &self.config
    }
}

// ---------------------------------------------------------------------------
// Component trait implementation
// ---------------------------------------------------------------------------

/// Equation layout (4 equations total):
///   r[0]: cold-side energy balance: ṁ_cold × (h_cold_out − h_cold_in) − Q = 0
///   r[1]: hot-side energy balance:  ṁ_hot  × (h_hot_out  − h_hot_in)  + Q = 0
///   r[2]: energy conservation:      ṁ_cold × Δh_cold + ṁ_hot × Δh_hot = 0
///   r[3]: pressure continuity:      P_cold_in − P_cold_out − f_dp × ΔP_nominal = 0
///
/// In Bypass mode: r[0..3] = pressure/enthalpy continuity (adiabatic)
const N_EQUATIONS: usize = 4;

impl Component for FreeCoolingExchanger {
    fn n_equations(&self) -> usize {
        N_EQUATIONS
    }

    fn compute_residuals(
        &self,
        _state: &[f64],
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        if residuals.len() < N_EQUATIONS {
            return Err(ComponentError::InvalidResidualDimensions {
                expected: N_EQUATIONS,
                actual: residuals.len(),
            });
        }

        // Read port values
        let h_cold_in = self.port_enthalpy_raw(COLD_INLET);
        let h_cold_out = self.port_enthalpy_raw(COLD_OUTLET);
        let h_hot_in = self.port_enthalpy_raw(HOT_INLET);
        let h_hot_out = self.port_enthalpy_raw(HOT_OUTLET);
        let p_cold_in = self.port_pressure_raw(COLD_INLET);
        let p_cold_out = self.port_pressure_raw(COLD_OUTLET);
        let p_hot_in = self.port_pressure_raw(HOT_INLET);
        let p_hot_out = self.port_pressure_raw(HOT_OUTLET);

        match self.mode {
            FreeCoolingMode::Bypass => {
                // Adiabatic: P and h continuity on both sides
                residuals[0] = p_cold_in - p_cold_out;
                residuals[1] = h_cold_in - h_cold_out;
                residuals[2] = p_hot_in - p_hot_out;
                residuals[3] = h_hot_in - h_hot_out;
            }
            FreeCoolingMode::Active | FreeCoolingMode::Mixed { .. } => {
                let m_cold = self.config.cold_mass_flow;
                let m_hot = self.config.hot_mass_flow;
                let cp_cold = self.config.cold_cp;
                let cp_hot = self.config.hot_cp;

                // Capacity rates (W/K)
                let c_cold = m_cold * cp_cold;
                let c_hot = m_hot * cp_hot;
                let c_min = c_cold.min(c_hot);

                // UA with calibration scaling
                let ua_eff = self.z_ua * self.config.ua;

                // ε-NTU effectiveness
                let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot);

                // Scale by (1 - bypass_fraction) for mixed mode
                let eps_eff = match self.mode {
                    FreeCoolingMode::Mixed { bypass_fraction } => eps * (1.0 - bypass_fraction),
                    _ => eps,
                };

                // Inlet temperatures from enthalpy (incompressible: T = h / Cp)
                let t_cold_in = self.temperature_from_enthalpy(h_cold_in, cp_cold);
                let t_hot_in = self.temperature_from_enthalpy(h_hot_in, cp_hot);

                // Heat transfer: Q = ε × C_min × (T_hot_in − T_cold_in)
                let q = eps_eff * c_min * (t_hot_in - t_cold_in);

                // Store for reporting
                // (heat_transfer_rate is updated after convergence externally)

                // Residuals
                let dh_cold = h_cold_out - h_cold_in;
                let dh_hot = h_hot_out - h_hot_in;

                residuals[0] = m_cold * dh_cold - q;
                residuals[1] = m_hot * dh_hot + q;
                residuals[2] = m_cold * dh_cold + m_hot * dh_hot;
                residuals[3] = (p_cold_in - p_cold_out) - self.z_dp * self.config.cold_dp_nominal;
            }
        }

        Ok(())
    }

    fn jacobian_entries(
        &self,
        _state: &[f64],
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        // Jacobian entries for calibration variable sensitivities
        if let Some(z_ua_idx) = self.calib_indices.z_ua {
            // ∂r[0]/∂z_ua: cold-side energy balance sensitivity
            // r[0] = m_cold * (h_cold_out - h_cold_in) - Q(f_ua)
            // ∂r[0]/∂f_ua = -∂Q/∂f_ua = -ε × C_min × (T_hot_in - T_cold_in) × UA_nominal
            let m_cold = self.config.cold_mass_flow;
            let m_hot = self.config.hot_mass_flow;
            let c_cold = m_cold * self.config.cold_cp;
            let c_hot = m_hot * self.config.hot_cp;
            let c_min = c_cold.min(c_hot);

            let h_cold_in = self.port_enthalpy_raw(COLD_INLET);
            let h_hot_in = self.port_enthalpy_raw(HOT_INLET);
            let t_cold_in = self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp);
            let t_hot_in = self.temperature_from_enthalpy(h_hot_in, self.config.hot_cp);

            let dt = t_hot_in - t_cold_in;
            // Approximate ∂Q/∂f_ua ≈ C_min × dt × (∂ε/∂f_ua) × UA_nominal
            // For small variations: ∂Q/∂f_ua ≈ Q / f_ua when linearized
            let ua_eff = self.z_ua * self.config.ua;
            let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot);
            let q_per_f_ua = eps * c_min * dt; // Q / f_ua at current operating point

            jacobian.add_entry(0, z_ua_idx, -q_per_f_ua);
            jacobian.add_entry(1, z_ua_idx, q_per_f_ua);
            // r[2] = r[0] + r[1], so ∂r[2]/∂f_ua = ∂r[0]/∂f_ua + ∂r[1]/∂f_ua = 0
            jacobian.add_entry(2, z_ua_idx, 0.0);
        }

        if let Some(z_dp_idx) = self.calib_indices.z_dp {
            // r[3] = (P_cold_in - P_cold_out) - f_dp × ΔP_nominal
            // ∂r[3]/∂f_dp = -ΔP_nominal
            jacobian.add_entry(3, z_dp_idx, -self.config.cold_dp_nominal);
        }

        Ok(())
    }

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

    fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
        if self.fluid_backend.is_none() {
            self.fluid_backend = Some(backend);
        }
    }

    fn set_calib_indices(&mut self, indices: CalibIndices) {
        self.calib_indices = indices;
    }

    fn energy_transfers(&self, _state: &[f64]) -> Option<(Power, Power)> {
        // Internal heat exchange between two water streams — adiabatic to external environment
        Some((Power::from_watts(0.0), Power::from_watts(0.0)))
    }

    fn port_enthalpies(&self, _state: &[f64]) -> Result<Vec<Enthalpy>, ComponentError> {
        Ok(vec![
            Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_INLET)),
            Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_OUTLET)),
            Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(HOT_INLET)),
            Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(HOT_OUTLET)),
        ])
    }

    fn signature(&self) -> String {
        format!(
            "FreeCoolingExchanger(id={},eff={},ua={},mode={:?},f_ua={},f_dp={})",
            self.id, self.config.effectiveness, self.config.ua, self.mode, self.z_ua, self.z_dp
        )
    }

    fn to_params(&self) -> ComponentParams {
        ComponentParams::new("FreeCoolingExchanger")
            .with_param("id", self.id.as_str())
            .with_param("circuitId", self.circuit_id.0)
            .with_param("effectiveness", self.config.effectiveness)
            .with_param("ua", self.config.ua)
            .with_param("coldMassFlow", self.config.cold_mass_flow)
            .with_param("hotMassFlow", self.config.hot_mass_flow)
            .with_param("coldCp", self.config.cold_cp)
            .with_param("hotCp", self.config.hot_cp)
            .with_param("bypassFraction", self.config.bypass_fraction)
            .with_param("f_ua", self.z_ua)
            .with_param("f_dp", self.z_dp)
            .with_param("mode", format!("{:?}", self.mode))
    }

    fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
        match factor {
            "f_ua" => {
                self.z_ua = value;
                true
            }
            "f_dp" => {
                self.z_dp = value;
                true
            }
            _ => false,
        }
    }
}

impl Default for FreeCoolingConfig {
    fn default() -> Self {
        Self {
            effectiveness: 0.85,
            bypass_fraction: 0.2,
            min_outdoor_temp: 285.15,
            hysteresis: 2.0,
            control_mode: FreeCoolingControlMode::AutoTemperature,
            ua: 10_000.0, // 10 kW/K typical for plate HX
            cold_mass_flow: 0.5,
            hot_mass_flow: 0.5,
            cold_cp: CP_WATER,
            hot_cp: CP_WATER,
            cold_dp_nominal: 0.0,
            hot_dp_nominal: 0.0,
        }
    }
}

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

    fn make_connected_ports() -> (ConnectedPort, ConnectedPort) {
        let fluid = FluidId::new("Water");
        let p = Pressure::from_pascals(3e5);
        let h = Enthalpy::from_joules_per_kg(63_000.0);
        let a = Port::new(fluid, p, h);
        let b = Port::new(FluidId::new("Water"), p, h);
        a.connect(b).unwrap()
    }

    fn make_connected_ports_with(
        p: Pressure,
        h_cold: f64,
        h_hot: f64,
    ) -> (ConnectedPort, ConnectedPort, ConnectedPort, ConnectedPort) {
        let h_c = Enthalpy::from_joules_per_kg(h_cold);
        let h_h = Enthalpy::from_joules_per_kg(h_hot);

        let ci = Port::new(FluidId::new("Water"), p, h_c);
        let co = Port::new(FluidId::new("Water"), p, h_c);
        let (ci, co) = ci.connect(co).unwrap();

        let hi = Port::new(FluidId::new("Water"), p, h_h);
        let ho = Port::new(FluidId::new("Water"), p, h_h);
        let (hi, ho) = hi.connect(ho).unwrap();

        (ci, co, hi, ho)
    }

    fn make_exchanger_active() -> FreeCoolingExchanger {
        let (ci, co, hi, ho) = make_connected_ports_with(
            Pressure::from_pascals(3e5),
            50_000.0,  // ~12°C cold (h/Cp)
            105_000.0, // ~25°C hot (h/Cp)
        );
        let mut fc = FreeCoolingExchanger::new(
            "fc_test",
            CircuitId(0),
            FreeCoolingConfig::default(),
            ci,
            co,
            hi,
            ho,
        )
        .unwrap();
        fc.mode = FreeCoolingMode::Active;
        fc
    }

    #[test]
    fn test_free_cooling_exchanger_creation() {
        let config = FreeCoolingConfig::default();
        let (cold_in, cold_out) = make_connected_ports();
        let (hot_in, hot_out) = make_connected_ports();

        let exchanger = FreeCoolingExchanger::new(
            "fc_1",
            CircuitId(0),
            config,
            cold_in,
            cold_out,
            hot_in,
            hot_out,
        );

        assert!(exchanger.is_ok());
        let exchanger = exchanger.unwrap();
        assert_eq!(exchanger.current_mode(), FreeCoolingMode::Bypass);
        assert!(!exchanger.is_free_cooling_active());
    }

    #[test]
    fn test_invalid_effectiveness() {
        let config = FreeCoolingConfig {
            effectiveness: 1.5,
            ..Default::default()
        };
        let (cold_in, cold_out) = make_connected_ports();
        let (hot_in, hot_out) = make_connected_ports();

        let exchanger = FreeCoolingExchanger::new(
            "fc_1",
            CircuitId(0),
            config,
            cold_in,
            cold_out,
            hot_in,
            hot_out,
        );

        assert!(exchanger.is_err());
    }

    #[test]
    fn test_energy_savings_calculation() {
        let (cold_in, cold_out) = make_connected_ports();
        let (hot_in, hot_out) = make_connected_ports();

        let mut exchanger = FreeCoolingExchanger::new(
            "fc_1",
            CircuitId(0),
            FreeCoolingConfig {
                effectiveness: 0.85,
                ..Default::default()
            },
            cold_in,
            cold_out,
            hot_in,
            hot_out,
        )
        .unwrap();

        assert_eq!(exchanger.energy_savings_percent(), 0.0);

        exchanger.mode = FreeCoolingMode::Active;
        assert_eq!(exchanger.energy_savings_percent(), 85.0);

        exchanger.mode = FreeCoolingMode::Mixed {
            bypass_fraction: 0.3,
        };
        let expected = 85.0 * 0.3;
        assert!((exchanger.energy_savings_percent() - expected).abs() < 1e-10);
    }

    #[test]
    fn test_effective_cop() {
        let (cold_in, cold_out) = make_connected_ports();
        let (hot_in, hot_out) = make_connected_ports();

        let mut exchanger = FreeCoolingExchanger::new(
            "fc_1",
            CircuitId(0),
            FreeCoolingConfig::default(),
            cold_in,
            cold_out,
            hot_in,
            hot_out,
        )
        .unwrap();

        exchanger.mode = FreeCoolingMode::Active;
        assert!(exchanger.effective_cop() > 20.0);

        exchanger.mode = FreeCoolingMode::Bypass;
        assert_eq!(exchanger.effective_cop(), 1.0);
    }

    #[test]
    fn test_residuals_active_mode() {
        let fc = make_exchanger_active();
        let mut residuals = vec![0.0; N_EQUATIONS];
        fc.compute_residuals(&[], &mut residuals).unwrap();

        // In active mode with different temperatures, Q > 0, residuals should be non-zero
        // (residuals won't be zero because port enthalpies don't match the Q computed)
        let has_nonzero = residuals.iter().any(|r| r.abs() > 1e-10);
        assert!(has_nonzero, "Active mode residuals should be non-zero");
    }

    #[test]
    fn test_residuals_bypass_mode() {
        let (ci, co, hi, ho) =
            make_connected_ports_with(Pressure::from_pascals(3e5), 50_000.0, 105_000.0);
        let fc = FreeCoolingExchanger::new(
            "fc_test",
            CircuitId(0),
            FreeCoolingConfig::default(),
            ci,
            co,
            hi,
            ho,
        )
        .unwrap();
        // Starts in Bypass mode

        let mut residuals = vec![0.0; N_EQUATIONS];
        fc.compute_residuals(&[], &mut residuals).unwrap();

        // With identical connected port pairs, P and h are equal → residuals near zero
        for r in &residuals {
            assert!(
                r.abs() < 1e-6,
                "Bypass mode with equal ports should have near-zero residuals"
            );
        }
    }

    #[test]
    fn test_jacobian_entries_active_mode() {
        let fc = make_exchanger_active();

        // Without calib indices, jacobian should have no entries
        let mut jb = JacobianBuilder::new();
        fc.jacobian_entries(&[], &mut jb).unwrap();
        assert_eq!(jb.entries().len(), 0);

        // With f_ua calib index
        let mut fc = fc;
        fc.calib_indices.z_ua = Some(100);
        let mut jb = JacobianBuilder::new();
        fc.jacobian_entries(&[], &mut jb).unwrap();
        assert!(!jb.entries().is_empty(), "Should have f_ua entries");

        // Check that r[0] entry is negative (Q increases with f_ua, so residual decreases)
        let (row0, _, val0) = jb.entries().iter().find(|(r, _, _)| *r == 0).unwrap();
        assert_eq!(*row0, 0);
        assert!(
            *val0 <= 0.0,
            "∂r[0]/∂f_ua should be <= 0 (Q increases with f_ua)"
        );
    }

    #[test]
    fn test_jacobian_with_f_dp() {
        let mut fc = make_exchanger_active();
        fc.calib_indices.z_dp = Some(200);
        fc.config.cold_dp_nominal = 5000.0;

        let mut jb = JacobianBuilder::new();
        fc.jacobian_entries(&[], &mut jb).unwrap();

        let f_dp_entries: Vec<_> = jb.entries().iter().filter(|(r, _, _)| *r == 3).collect();
        assert!(!f_dp_entries.is_empty());
        assert_eq!(f_dp_entries[0].2, -5000.0);
    }

    #[test]
    fn test_energy_transfers() {
        let fc = make_exchanger_active();
        let result = fc.energy_transfers(&[]);
        assert!(result.is_some());
        let (heat, work) = result.unwrap();
        assert_eq!(heat.to_watts(), 0.0);
        assert_eq!(work.to_watts(), 0.0);
    }

    #[test]
    fn test_port_enthalpies() {
        let fc = make_exchanger_active();
        let enthalpies = fc.port_enthalpies(&[]).unwrap();
        assert_eq!(enthalpies.len(), 4);
    }

    #[test]
    fn test_calibration_scaling() {
        let fc1 = make_exchanger_active();
        let mut fc2 = make_exchanger_active();
        fc2.z_ua = 1.5; // 50% higher UA

        let mut r1 = vec![0.0; N_EQUATIONS];
        let mut r2 = vec![0.0; N_EQUATIONS];
        fc1.compute_residuals(&[], &mut r1).unwrap();
        fc2.compute_residuals(&[], &mut r2).unwrap();

        // With higher UA, ε changes → Q changes → residuals change
        assert!(
            (r1[0] - r2[0]).abs() > 1e-6,
            "f_ua scaling should change residuals"
        );
    }

    #[test]
    fn test_signature_and_to_params() {
        let fc = make_exchanger_active();
        let sig = fc.signature();
        assert!(sig.contains("FreeCoolingExchanger"));
        assert!(sig.contains("fc_test"));
        assert!(sig.contains(&format!("{}", fc.config.effectiveness)));

        let params = fc.to_params();
        let json = serde_json::to_string(&params).unwrap();
        assert!(json.contains("FreeCoolingExchanger"));
        assert!(json.contains("fc_test"));
    }

    #[test]
    fn test_set_calib_indices() {
        let mut fc = make_exchanger_active();
        let indices = CalibIndices {
            z_ua: Some(10),
            z_dp: Some(20),
            ..Default::default()
        };
        fc.set_calib_indices(indices);
        assert_eq!(fc.calib_indices.z_ua, Some(10));
        assert_eq!(fc.calib_indices.z_dp, Some(20));
    }

    #[test]
    fn test_effectiveness_counter_flow() {
        let fc = make_exchanger_active();
        // Balanced flow (Cr ≈ 1): ε = NTU / (1 + NTU)
        let c = 0.5 * CP_WATER; // 2093 W/K
        let ua = 10_000.0;
        let eps = fc.compute_effectiveness(ua, c, c);
        let expected_ntu = ua / c;
        let expected_eps = expected_ntu / (1.0 + expected_ntu);
        assert!((eps - expected_eps).abs() < 1e-10);

        // UA = 0 → ε = 0
        assert_eq!(fc.compute_effectiveness(0.0, c, c), 0.0);

        // C_min = 0 → ε = 0
        assert_eq!(fc.compute_effectiveness(ua, 0.0, c), 0.0);
    }

    #[test]
    fn test_n_equations() {
        let fc = make_exchanger_active();
        assert_eq!(fc.n_equations(), 4);
    }

    #[test]
    fn test_get_ports() {
        let fc = make_exchanger_active();
        let ports = fc.get_ports();
        assert_eq!(ports.len(), 4);
    }

    #[test]
    fn test_residual_dimensions_validation() {
        let fc = make_exchanger_active();
        let mut residuals = vec![0.0; 2]; // Too small
        let result = fc.compute_residuals(&[], &mut residuals);
        assert!(result.is_err());
    }

    #[test]
    fn test_operational_state_mapping() {
        let mut fc = make_exchanger_active();
        assert_eq!(fc.operational_state(), OperationalState::On);

        fc.set_operational_state(OperationalState::Bypass).unwrap();
        assert_eq!(fc.operational_state(), OperationalState::Bypass);
        assert_eq!(fc.current_mode(), FreeCoolingMode::Bypass);

        fc.set_operational_state(OperationalState::On).unwrap();
        assert_eq!(fc.current_mode(), FreeCoolingMode::Active);
    }
}
