//! System-level regression test for the MSH tube-ΔP + fixed-opening EXV
//! solver-robustness fix (Epic-0 follow-up).
//!
//! Builds the exact user system that exhibited the Newton stall (4-component
//! emergent-pressure R134a chiller, `dp_model=msh` on both heat exchangers,
//! `fix_opening=true, opening=0.9`) at the exact CLI staged seed, and guards:
//!
//! 1. the cold-start residual signature (evaporator tube-ΔP row dominant),
//! 2. the **momentum-row Jacobians** (condenser + evaporator tube ΔP) against
//!    central finite differences — the NFR9 guard for the exact analytic
//!    `tube_dp` composition wired into both heat exchangers,
//! 3. the **totality / C¹** of the tube-ΔP residual when Newton iterates leave
//!    the saturation domain (no silent model switch, no error, smooth values).
//!
//! Run: cargo test -p entropyk-solver --features coolprop --test msh_tube_dp_robustness
#![cfg(feature = "coolprop")]

use std::sync::Arc;

use entropyk_components::heat_exchanger::two_phase_dp::{
    TubeChannelGeometry, TwoPhaseDpCorrelation,
};
use entropyk_components::{BrineSink, BrineSource, Condenser, Evaporator};
use entropyk_components::{ConnectedPort, FluidId as ComponentFluidId};
use entropyk_components::{IsenthalpicExpansionValve, IsentropicCompressor, JacobianBuilder, Port};
use entropyk_core::{Concentration, Enthalpy, Pressure, Temperature};
use entropyk_fluids::{CoolPropBackend, FluidBackend, FluidId, FluidState, Property};
use entropyk_solver::scaling::{equilibrate, unscale_dx};
use entropyk_solver::system::System;

fn water_h(backend: &Arc<dyn FluidBackend>, t_c: f64) -> f64 {
    backend
        .property(
            FluidId::new("Water"),
            Property::Enthalpy,
            FluidState::from_pt(Pressure::from_bar(2.0), Temperature::from_celsius(t_c)),
        )
        .expect("water h(P,T)")
}

fn water_port() -> ConnectedPort {
    let fluid = ComponentFluidId::new("Water");
    let a = Port::new(
        fluid.clone(),
        Pressure::from_bar(2.0),
        Enthalpy::from_joules_per_kg(100_000.0),
    );
    let b = Port::new(
        fluid,
        Pressure::from_bar(2.0),
        Enthalpy::from_joules_per_kg(100_000.0),
    );
    a.connect(b).expect("port connect").0
}

/// Builds the exact hang system (msh + fixed-opening EXV at `opening`) and
/// returns it with the exact CLI staged seed and per-index variable names.
fn build_hang_system_with_opening(opening: f64) -> (System, Vec<f64>, Vec<String>) {
    let backend: Arc<dyn FluidBackend> = Arc::new(CoolPropBackend::new());
    let fluid = "R134a";

    let geom = TubeChannelGeometry {
        length_m: 6.0,
        diameter_m: 0.0095,
        n_parallel: 2.0,
    };

    // comp: emergent metered-flow (energy-only) — EXV fixed orifice meters ṁ.
    let comp = Box::new(
        IsentropicCompressor::new(0.70, 318.15, 278.15, 5.0)
            .with_refrigerant(fluid)
            .with_fluid_backend(backend.clone())
            .with_emergent_metered_flow(),
    );

    // cond: UA=1500, msh tube ΔP, water 4-port, emergent with 5 K subcooling.
    let mut cond = Condenser::new(1500.0)
        .with_refrigerant(fluid)
        .with_fluid_backend(backend.clone())
        .with_emergent_pressure(5.0);
    cond.set_secondary_fluid("Water");
    cond.set_tube_pressure_drop(TwoPhaseDpCorrelation::MullerSteinhagenHeck1986, geom);
    cond.set_secondary_pressure_drop_coeff(5000.0);
    let cond = Box::new(cond);

    // exv: emergent + fixed orifice kv=2e-6.
    let exv = Box::new(
        IsenthalpicExpansionValve::new(278.15)
            .with_refrigerant(fluid)
            .with_fluid_backend(backend.clone())
            .with_emergent_pressure()
            .with_orifice_fixed(2e-6, opening),
    );

    // evap: UA=2500, msh tube ΔP, water 4-port, emergent (5 K superheat).
    let mut evap = Evaporator::new(2500.0)
        .with_refrigerant(fluid)
        .with_fluid_backend(backend.clone())
        .with_emergent_pressure();
    evap.set_secondary_fluid("Water");
    evap.set_tube_pressure_drop(TwoPhaseDpCorrelation::MullerSteinhagenHeck1986, geom);
    evap.set_secondary_pressure_drop_coeff(5000.0);
    let evap = Box::new(evap);

    // Water boundaries.
    let cwin = Box::new(
        BrineSource::new(
            "Water",
            Pressure::from_bar(2.0),
            Temperature::from_celsius(30.0),
            Concentration::from_percent(0.0),
            backend.clone(),
            water_port(),
        )
        .expect("BrineSource cond")
        .with_imposed_mass_flow(0.3583)
        .expect("imposed m"),
    );
    let cwout = Box::new(
        BrineSink::new(
            "Water",
            Pressure::from_bar(2.0),
            None,
            None,
            backend.clone(),
            water_port(),
        )
        .expect("BrineSink cond"),
    );
    let ewin = Box::new(
        BrineSource::new(
            "Water",
            Pressure::from_bar(2.0),
            Temperature::from_celsius(12.0),
            Concentration::from_percent(0.0),
            backend.clone(),
            water_port(),
        )
        .expect("BrineSource evap")
        .with_imposed_mass_flow(0.4778)
        .expect("imposed m"),
    );
    let ewout = Box::new(
        BrineSink::new(
            "Water",
            Pressure::from_bar(2.0),
            None,
            None,
            backend.clone(),
            water_port(),
        )
        .expect("BrineSink evap"),
    );

    let mut system = System::new();
    let n_comp = system.add_component(comp);
    let n_cond = system.add_component(cond);
    let n_exv = system.add_component(exv);
    let n_evap = system.add_component(evap);
    let n_cwin = system.add_component(cwin);
    let n_cwout = system.add_component(cwout);
    let n_ewin = system.add_component(ewin);
    let n_ewout = system.add_component(ewout);

    // Refrigerant loop (ports: inlet=0, outlet=1; HX secondary 2/3).
    system.add_edge_with_ports(n_comp, 1, n_cond, 0).unwrap(); // E0
    system.add_edge_with_ports(n_cond, 1, n_exv, 0).unwrap(); // E1
    system.add_edge_with_ports(n_exv, 1, n_evap, 0).unwrap(); // E2
    system.add_edge_with_ports(n_evap, 1, n_comp, 0).unwrap(); // E3
                                                               // Water loops.
    system.add_edge_with_ports(n_cwin, 1, n_cond, 2).unwrap(); // W0
    system.add_edge_with_ports(n_cond, 3, n_cwout, 0).unwrap(); // W1
    system.add_edge_with_ports(n_ewin, 1, n_evap, 2).unwrap(); // W2
    system.add_edge_with_ports(n_evap, 3, n_ewout, 0).unwrap(); // W3

    system.finalize().unwrap();

    let n_state = system.full_state_vector_len();
    assert_eq!(n_state, 19, "hang system must have 19 unknowns");

    // ── Exact CLI staged seed (validated against the production run: the
    // cold-start residual breakdown matches row-by-row) ─────────────────────
    let h_w30 = water_h(&backend, 30.0);
    let h_w12 = water_h(&backend, 12.0);
    let h_w20 = water_h(&backend, 20.0);
    let seed_refrig = [
        (1.159_924e6, 4.465_191e5), // E0 comp→cond
        (1.159_924e6, 2.589_429e5), // E1 cond→exv
        (3.496_586e5, 2.639_429e5), // E2 exv→evap
        (3.496_586e5, 4.060_707e5), // E3 evap→comp
    ];
    // Water loop ṁ slots are seeded at the generic 0.05 kg/s default (the
    // sink boundary seed overwrites the source's imposed flow).
    let seed_water = [
        (0.05, h_w30), // W0 source→cond
        (0.05, h_w20), // W1 cond→sink
        (0.05, h_w12), // W2 source→evap
        (0.05, h_w20), // W3 evap→sink
    ];

    let mut state = vec![0.0; n_state];
    let mut names: Vec<Option<String>> = vec![None; n_state];
    let edge_names = ["E0", "E1", "E2", "E3", "W0", "W1", "W2", "W3"];
    for (i, e) in system.edge_indices().enumerate() {
        let (mi, pi, hi) = system.edge_state_indices_full(e);
        if i < 4 {
            state[mi] = 0.05;
            state[pi] = seed_refrig[i].0;
            state[hi] = seed_refrig[i].1;
        } else {
            let (mw, hw) = seed_water[i - 4];
            state[mi] = mw;
            state[pi] = 2.0e5;
            state[hi] = hw;
        }
        let en = edge_names[i];
        for (idx, tag) in [(mi, "m"), (pi, "P"), (hi, "h")] {
            if names[idx].is_none() {
                names[idx] = Some(format!("{tag}({en})"));
            }
        }
    }
    let names: Vec<String> = names
        .into_iter()
        .enumerate()
        .map(|(i, n)| n.unwrap_or_else(|| format!("x[{i}]")))
        .collect();
    (system, state, names)
}

/// Row indices: node order comp(0) | cond(1..5) | exv(6..7) | evap(8..12) |
/// boundaries(13..18). Row 1 = condenser refrigerant momentum,
/// row 8 = evaporator refrigerant momentum.
const COND_MOMENTUM_ROW: usize = 1;
const EVAP_MOMENTUM_ROW: usize = 8;
const N_EQ: usize = 19;

/// The exact cold-start signature of the production stall (residual breakdown
/// matches the CLI run row-by-row): the evaporator tube-ΔP momentum row
/// dominates the cold residual.
#[test]
fn cold_start_residual_signature_matches_production() {
    let (system, state, _names) = build_hang_system_with_opening(0.9);
    let mut r = vec![0.0; N_EQ];
    system.compute_residuals(&state, &mut r).unwrap();
    let norm: f64 = r.iter().map(|v| v * v).sum::<f64>().sqrt();
    assert!(
        (norm - 44457.554).abs() < 0.01,
        "cold-start residual norm must match the production signature, got {norm}"
    );
    // Evaporator momentum row: the full tube ΔP is unbalanced at the seed
    // (uniform low-side pressure), ≈ 42 kPa.
    assert!(
        (r[EVAP_MOMENTUM_ROW] - 41_991.24).abs() < 1.0,
        "evap momentum residual: {}",
        r[EVAP_MOMENTUM_ROW]
    );
    // Condenser momentum row ≈ 7.7 kPa.
    assert!(
        (r[COND_MOMENTUM_ROW] - 7_673.32).abs() < 1.0,
        "cond momentum residual: {}",
        r[COND_MOMENTUM_ROW]
    );
}

/// NFR9 guard: the momentum-row Jacobian of the tube ΔP (now fully analytic
/// through `heat_exchanger::tube_dp`) must agree with central finite
/// differences of the residual at the cold seed, on every column.
#[test]
fn tube_dp_momentum_jacobian_matches_fd_at_cold_seed() {
    let (system, state, names) = build_hang_system_with_opening(0.9);
    let n_state = state.len();

    let mut jb = JacobianBuilder::new();
    system.assemble_jacobian(&state, &mut jb).unwrap();
    let mut analytic = vec![vec![0.0_f64; n_state]; N_EQ];
    for &(row, col, v) in jb.entries() {
        analytic[row][col] += v;
    }

    for row in [COND_MOMENTUM_ROW, EVAP_MOMENTUM_ROW] {
        for col in 0..n_state {
            let eps = (state[col].abs() * 1e-6).max(1e-7);
            let (mut sp, mut sm) = (state.clone(), state.clone());
            sp[col] += eps;
            sm[col] -= eps;
            let (mut rp, mut rm) = (vec![0.0; N_EQ], vec![0.0; N_EQ]);
            system.compute_residuals(&sp, &mut rp).unwrap();
            system.compute_residuals(&sm, &mut rm).unwrap();
            let fd = (rp[row] - rm[row]) / (2.0 * eps);
            let a = analytic[row][col];
            if a == 0.0 && fd == 0.0 {
                continue;
            }
            let tol = (1e-4 * fd.abs().max(a.abs())).max(1e-9);
            assert!(
                (a - fd).abs() <= tol,
                "momentum J[{row}][{}]: analytic={a} vs fd={fd}",
                names[col]
            );
        }
    }
}

/// Totality + C¹ guard: pushing the refrigerant pressures far outside the
/// saturation domain (as Newton iterates do when a step overshoots) must keep
/// the residual defined — no error, no panic, no silent ΔP-model switch — and
/// the evaporator momentum row must vary smoothly across the domain bound.
#[test]
fn tube_dp_residual_is_total_and_smooth_outside_sat_domain() {
    let (system, state, _names) = build_hang_system_with_opening(0.9);
    // Find the E2 (exv→evap) pressure index.
    let e2 = system.edge_indices().nth(2).unwrap();
    let (_, p_e2, _) = system.edge_state_indices_full(e2);

    let r8_at = |p: f64| -> f64 {
        let mut s = state.clone();
        s[p_e2] = p;
        let mut r = vec![0.0; N_EQ];
        system
            .compute_residuals(&s, &mut r)
            .expect("residual must stay defined outside the saturation domain");
        r[EVAP_MOMENTUM_ROW]
    };

    // Deep outside the R134a saturation domain in both directions: the tube
    // ΔP saturates to the bound's value (constant continuation), and the
    // residual stays finite and equal across the far exterior.
    let p_nominal = state[p_e2];
    let r_nominal = r8_at(p_nominal);
    assert!(r_nominal.is_finite());
    for p_extreme in [1.0, 10.0, 1.0e9, 1.0e12] {
        let r = r8_at(p_extreme);
        assert!(r.is_finite(), "residual not finite at P={p_extreme}");
    }
    // Constant continuation far outside: two far-exterior points give the
    // same clamped ΔP (isolate it from the row: P_out − P_in + ΔP_sat).
    let d1 = r8_at(1.0e9) + 1.0e9;
    let d2 = r8_at(1.0e10) + 1.0e10;
    assert!(
        (d1 - d2).abs() < 1e-6 * d1.abs().max(1.0),
        "clamped ΔP must be constant far outside the domain: {d1} vs {d2}"
    );
    // C¹ across the upper domain bound: FD slope just inside vs just outside
    // the saturation-domain top must not jump (smooth clamp, Story 0.2).
    let (p_min, p_max) = {
        let backend: Arc<dyn FluidBackend> = Arc::new(CoolPropBackend::new());
        entropyk_components::heat_exchanger::sat_domain::saturation_pressure_domain(
            &backend, "R134a",
        )
        .expect("R134a domain")
    };
    let _ = p_min;
    let h = p_max * 1e-5;
    let slope_in = (r8_at(p_max - h) - r8_at(p_max - 2.0 * h)) / h;
    let slope_out = (r8_at(p_max + 2.0 * h) - r8_at(p_max + h)) / h;
    let denom = slope_in.abs().max(1.0); // the explicit −P_in term dominates
    assert!(
        (slope_in - slope_out).abs() / denom < 0.2,
        "C¹ slope across domain bound: in={slope_in} out={slope_out}"
    );
}

/// Newton-step structure at the cold seed (documentation of the stall
/// mechanism): the first full Newton step must be finite and the scaled
/// Jacobian must be non-singular — the production stall is a *damping/seed
/// distance* problem, not a singular or misassembled Jacobian.
#[test]
fn cold_seed_jacobian_is_nonsingular_and_step_finite() {
    let (system, state, _names) = build_hang_system_with_opening(0.9);
    let n_state = state.len();
    let mut r = vec![0.0; N_EQ];
    system.compute_residuals(&state, &mut r).unwrap();

    let mut jb = JacobianBuilder::new();
    system.assemble_jacobian(&state, &mut jb).unwrap();
    let mut jm = nalgebra::DMatrix::<f64>::zeros(N_EQ, n_state);
    for &(row, col, v) in jb.entries() {
        jm[(row, col)] += v;
    }
    let (d_r, d_c) = equilibrate(&jm);
    let mut js = jm.clone();
    for i in 0..N_EQ {
        for j in 0..n_state {
            js[(i, j)] *= d_r[i] * d_c[j];
        }
    }
    let b: nalgebra::DVector<f64> =
        nalgebra::DVector::from_iterator(N_EQ, (0..N_EQ).map(|i| -d_r[i] * r[i]));
    let y = js
        .clone()
        .lu()
        .solve(&b)
        .expect("scaled Jacobian must solve");
    let delta = unscale_dx(y.as_slice(), &d_c);
    assert!(
        delta.iter().all(|v| v.is_finite()),
        "Newton step must be finite"
    );
    // The step is huge (stiff emergent-pressure mode) but the scaled Jacobian
    // is invertible — σ_min > 0.
    let sigma_min = js
        .svd(false, false)
        .singular_values
        .iter()
        .copied()
        .fold(f64::INFINITY, f64::min);
    assert!(sigma_min > 0.0, "scaled Jacobian must be non-singular");
}
