//! BPHX Heat Transfer Correlations
//!
//! Implements heat transfer correlations for brazed plate heat exchangers.
//!
//! ## Supported Correlations
//!
//! | Correlation | Year | Application | Geometry |
//! |-------------|------|-------------|----------|
//! | Longo | 2004 | BPHX evaporation/condensation | Plates |
//! | Shah | 1979 | Tubes condensation | Tubes |
//! | Shah | 2009 | Tubes condensation (regimes) | Tubes |
//! | Shah | 2021 | Plates condensation (recent) | Plates |
//! | Cavallini | 2006 | Tubes condensation | Tubes |
//! | Kandlikar | 1990 | Tubes evaporation | Tubes |
//! | Gungor-Winterton | 1986 | Tubes evaporation | Tubes |
//! | Gnielinski | 1976 | Single-phase turbulent (accurate) | Tubes |
//! | Dittus-Boelter | 1930 | Single-phase turbulent (simple) | Tubes |
//! | Ko | 2021 | Low-GWP refrigerants | Plates |

use std::fmt;

use super::correlation_registry::{
    assess_candidate, correlation_metadata, select_correlation, BoundedQuantityKind,
    CandidateAssessment, CorrelationId, CorrelationMetadata, CorrelationPurpose,
    CorrelationSelectionError, DomainInputError, DomainStatus, OperatingPoint, SelectionContext,
    SelectionOutcome,
};
pub use super::correlation_registry::{ExchangerGeometryType, FlowRegime};

/// Validity status for correlation results
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidityStatus {
    /// Within valid range
    Valid,
    /// Outside validity range (extrapolation)
    Extrapolation,
    /// Warning: near boundary
    NearBoundary,
}

/// Result from a heat transfer correlation calculation
#[derive(Debug, Clone)]
pub struct CorrelationResult {
    /// Heat transfer coefficient (W/(m²·K))
    pub h: f64,
    /// Reynolds number
    pub re: f64,
    /// Prandtl number
    pub pr: f64,
    /// Nusselt number
    pub nu: f64,
    /// Validity status
    pub validity: ValidityStatus,
    /// Optional warning message
    pub warning: Option<String>,
}

impl Default for CorrelationResult {
    fn default() -> Self {
        Self {
            h: 0.0,
            re: 0.0,
            pr: 0.0,
            nu: 0.0,
            validity: ValidityStatus::Valid,
            warning: None,
        }
    }
}

impl CorrelationResult {
    /// Returns true if the result is within valid range
    pub fn is_valid(&self) -> bool {
        matches!(
            self.validity,
            ValidityStatus::Valid | ValidityStatus::NearBoundary
        )
    }

    /// Creates a new result with a warning message
    pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
        self.warning = Some(warning.into());
        self
    }
}

/// Parameters for heat transfer correlation calculations
#[derive(Debug, Clone)]
pub struct CorrelationParams {
    /// Mass flux G (kg/(m²·s))
    pub mass_flux: f64,
    /// Vapor quality x (0-1 for two-phase)
    pub quality: f64,
    /// Hydraulic diameter (m)
    pub dh: f64,
    /// Liquid density (kg/m³)
    pub rho_l: f64,
    /// Vapor density (kg/m³)
    pub rho_v: f64,
    /// Liquid dynamic viscosity (Pa·s)
    pub mu_l: f64,
    /// Vapor dynamic viscosity (Pa·s)
    pub mu_v: f64,
    /// Liquid thermal conductivity (W/(m·K))
    pub k_l: f64,
    /// Liquid Prandtl number
    pub pr_l: f64,
    /// Saturation temperature (K)
    pub t_sat: f64,
    /// Wall temperature (K)
    pub t_wall: f64,
    /// Flow regime
    pub regime: FlowRegime,
    /// Chevron angle (degrees)
    pub chevron_angle: f64,
    /// Reduced pressure `p_sat/p_crit` for Shah/Cavallini (default 0.2 if unknown).
    pub p_reduced: f64,
}

impl Default for CorrelationParams {
    fn default() -> Self {
        Self {
            mass_flux: 30.0,
            quality: 0.5,
            dh: 0.003,
            rho_l: 1100.0,
            rho_v: 30.0,
            mu_l: 0.0002,
            mu_v: 0.000012,
            k_l: 0.1,
            pr_l: 3.5,
            t_sat: 280.0,
            t_wall: 285.0,
            regime: FlowRegime::Evaporation,
            chevron_angle: 60.0,
            p_reduced: 0.2,
        }
    }
}

impl CorrelationParams {
    /// Validates physical inputs used by high-level assessment and selection.
    pub fn validate(&self) -> Result<(), DomainInputError> {
        for (field, value) in [
            ("mass_flux", self.mass_flux),
            ("hydraulic_diameter", self.dh),
            ("liquid_density", self.rho_l),
            ("vapor_density", self.rho_v),
            ("liquid_viscosity", self.mu_l),
            ("vapor_viscosity", self.mu_v),
            ("liquid_thermal_conductivity", self.k_l),
            ("liquid_prandtl", self.pr_l),
            ("saturation_temperature", self.t_sat),
            ("wall_temperature", self.t_wall),
            ("chevron_angle", self.chevron_angle),
        ] {
            if !value.is_finite() || value <= 0.0 {
                return Err(DomainInputError::InvalidPositive { field, value });
            }
        }
        if !self.quality.is_finite() || !(0.0..=1.0).contains(&self.quality) {
            return Err(DomainInputError::InvalidQuality {
                value: self.quality,
            });
        }
        Ok(())
    }

    fn selection_context(
        &self,
        geometry: ExchangerGeometryType,
        refrigerant: Option<&str>,
    ) -> Result<SelectionContext, DomainInputError> {
        self.validate()?;
        let reynolds = self.mass_flux * self.dh / self.mu_l;
        let equivalent_two_phase_reynolds = matches!(
            self.regime,
            FlowRegime::Evaporation | FlowRegime::Condensation
        )
        .then(|| self.longo_equivalent_two_phase_reynolds());
        Ok(SelectionContext {
            purpose: CorrelationPurpose::HeatTransfer,
            geometry,
            regime: self.regime,
            refrigerant: refrigerant.map(str::trim).map(str::to_owned),
            operating_point: OperatingPoint {
                reynolds: Some(reynolds),
                equivalent_two_phase_reynolds,
                mass_flux: Some(self.mass_flux),
                quality: Some(self.quality),
                prandtl: Some(self.pr_l),
                length_diameter_ratio: None,
            },
        })
    }

    fn longo_equivalent_two_phase_reynolds(&self) -> f64 {
        let liquid_reynolds = if self.mu_l < 1e-15 {
            0.0
        } else {
            self.mass_flux * self.dh / self.mu_l
        };
        let density_ratio = (self.rho_l / self.rho_v).sqrt();
        liquid_reynolds * (1.0 - self.quality + self.quality * density_ratio)
    }
}

/// Trait for heat transfer correlations
///
/// This trait defines the interface for heat transfer correlation implementations.
/// Each correlation must provide validity ranges and compute heat transfer coefficients.
pub trait HeatTransferCorrelation: Send + Sync + fmt::Debug {
    /// Returns the correlation name
    fn name(&self) -> &str;

    /// Computes the heat transfer coefficient
    fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult;

    /// Returns the valid range for Reynolds number
    fn re_range(&self) -> (f64, f64);

    /// Returns the valid range for mass flux (kg/(m²·s))
    fn mass_flux_range(&self) -> (f64, f64);

    /// Returns the valid range for vapor quality
    fn quality_range(&self) -> (f64, f64);

    /// Checks if parameters are within validity range
    fn check_validity(&self, params: &CorrelationParams) -> ValidityStatus {
        let re = self.compute_reynolds(params);
        let (re_min, re_max) = self.re_range();
        let (g_min, g_max) = self.mass_flux_range();
        let (x_min, x_max) = self.quality_range();

        let re_ok = re >= re_min && re <= re_max;
        let g_ok = params.mass_flux >= g_min && params.mass_flux <= g_max;
        let x_ok = params.quality >= x_min && params.quality <= x_max;

        if re_ok && g_ok && x_ok {
            let re_near = (re - re_min).abs() < re_min * 0.1 || (re_max - re).abs() < re_max * 0.1;
            let g_near = (params.mass_flux - g_min).abs() < g_min * 0.1
                || (g_max - params.mass_flux).abs() < g_max * 0.1;
            if re_near || g_near {
                ValidityStatus::NearBoundary
            } else {
                ValidityStatus::Valid
            }
        } else {
            ValidityStatus::Extrapolation
        }
    }

    /// Computes Reynolds number from parameters
    fn compute_reynolds(&self, params: &CorrelationParams) -> f64 {
        if params.mu_l < 1e-15 {
            return 0.0;
        }
        params.mass_flux * params.dh / params.mu_l
    }
}

impl HeatTransferCorrelation for BphxCorrelation {
    fn name(&self) -> &str {
        BphxCorrelation::name(self)
    }

    fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult {
        BphxCorrelation::compute_htc(self, params)
    }

    fn re_range(&self) -> (f64, f64) {
        BphxCorrelation::re_range(self)
    }

    fn mass_flux_range(&self) -> (f64, f64) {
        BphxCorrelation::mass_flux_range(self)
    }

    fn quality_range(&self) -> (f64, f64) {
        BphxCorrelation::quality_range(self)
    }

    fn check_validity(&self, params: &CorrelationParams) -> ValidityStatus {
        self.check_validity_custom(params)
    }
}

/// Available heat transfer correlations for BPHX
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BphxCorrelation {
    /// Longo (2004) - Default for BPHX evaporation/condensation
    #[default]
    Longo2004,
    /// Shah (1979) - Tubes condensation
    Shah1979,
    /// Shah (2009) - Tubes condensation with regimes I/II/III
    Shah2009,
    /// Shah (2021) - Plates condensation (recent)
    Shah2021,
    /// Cavallini (2006) - Tubes condensation (halogenated refrigerants)
    Cavallini2006,
    /// Kandlikar (1990) - Tubes evaporation
    Kandlikar1990,
    /// Gungor-Winterton (1986) - Tubes evaporation
    GungorWinterton1986,
    /// Gnielinski (1976) - Single-phase turbulent (accurate)
    Gnielinski1976,
    /// Dittus-Boelter (1930) - Single-phase turbulent (simple)
    DittusBoelter1930,
    /// Ko (2021) - Low-GWP refrigerants in plates
    Ko2021,
}

impl BphxCorrelation {
    /// Returns the stable registry identifier.
    pub const fn id(self) -> CorrelationId {
        match self {
            Self::Longo2004 => CorrelationId::Longo2004,
            Self::Shah1979 => CorrelationId::Shah1979,
            Self::Shah2009 => CorrelationId::Shah2009,
            Self::Shah2021 => CorrelationId::Shah2021,
            Self::Cavallini2006 => CorrelationId::Cavallini2006,
            Self::Kandlikar1990 => CorrelationId::Kandlikar1990,
            Self::GungorWinterton1986 => CorrelationId::GungorWinterton1986,
            Self::Gnielinski1976 => CorrelationId::Gnielinski1976,
            Self::DittusBoelter1930 => CorrelationId::DittusBoelter1930,
            Self::Ko2021 => CorrelationId::Ko2021,
        }
    }

    /// Resolves a BPHX formula from its registry identifier.
    pub const fn from_id(id: CorrelationId) -> Option<Self> {
        match id {
            CorrelationId::Longo2004 => Some(Self::Longo2004),
            CorrelationId::Shah1979 => Some(Self::Shah1979),
            CorrelationId::Shah2009 => Some(Self::Shah2009),
            CorrelationId::Shah2021 => Some(Self::Shah2021),
            CorrelationId::Cavallini2006 => Some(Self::Cavallini2006),
            CorrelationId::Kandlikar1990 => Some(Self::Kandlikar1990),
            CorrelationId::GungorWinterton1986 => Some(Self::GungorWinterton1986),
            CorrelationId::Gnielinski1976 => Some(Self::Gnielinski1976),
            CorrelationId::DittusBoelter1930 => Some(Self::DittusBoelter1930),
            CorrelationId::Ko2021 => Some(Self::Ko2021),
            CorrelationId::Cooper1984
            | CorrelationId::Mostinski1963
            | CorrelationId::Friedel1979
            | CorrelationId::MullerSteinhagenHeck1986
            | CorrelationId::SimplifiedChannel
            | CorrelationId::Martin1996
            | CorrelationId::AmalfiThome2016
            | CorrelationId::TaoInfanteFerreiraSurvey => None,
        }
    }

    /// Returns the central applicability and evidence metadata.
    pub fn metadata(self) -> CorrelationMetadata {
        correlation_metadata(self.id())
    }

    /// Assesses this named formula without evaluating it.
    pub fn assess(
        self,
        params: &CorrelationParams,
        geometry: ExchangerGeometryType,
        refrigerant: Option<&str>,
    ) -> Result<CandidateAssessment, DomainInputError> {
        let context = params.selection_context(geometry, refrigerant)?;
        assess_candidate(&self.metadata(), &context)
    }

    /// Computes the heat transfer coefficient for the selected correlation
    pub fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult {
        match self {
            Self::Longo2004 => longo_2004(params),
            Self::Shah1979 => shah_1979(params),
            Self::Shah2009 => shah_2009(params),
            Self::Shah2021 => shah_2021(params),
            Self::Cavallini2006 => cavallini_2006(params),
            Self::Kandlikar1990 => kandlikar_1990(params),
            Self::GungorWinterton1986 => gungor_winterton_1986(params),
            Self::Gnielinski1976 => gnielinski_1976(params),
            Self::DittusBoelter1930 => dittus_boelter_1930(params),
            Self::Ko2021 => ko_2021(params),
        }
    }

    /// Returns the correlation name
    pub fn name(&self) -> &'static str {
        match self {
            Self::Longo2004 => "Longo (2004)",
            Self::Shah1979 => "Shah (1979)",
            Self::Shah2009 => "Shah (2009)",
            Self::Shah2021 => "Shah (2021)",
            Self::Cavallini2006 => "Cavallini (2006)",
            Self::Kandlikar1990 => "Kandlikar (1990)",
            Self::GungorWinterton1986 => "Gungor-Winterton (1986)",
            Self::Gnielinski1976 => "Gnielinski (1976)",
            Self::DittusBoelter1930 => "Dittus-Boelter (1930)",
            Self::Ko2021 => "Ko (2021)",
        }
    }

    /// Returns the publication year
    pub const fn year(&self) -> u16 {
        match self {
            Self::Longo2004 => 2004,
            Self::Shah1979 => 1979,
            Self::Shah2009 => 2009,
            Self::Shah2021 => 2021,
            Self::Cavallini2006 => 2006,
            Self::Kandlikar1990 => 1990,
            Self::GungorWinterton1986 => 1986,
            Self::Gnielinski1976 => 1976,
            Self::DittusBoelter1930 => 1930,
            Self::Ko2021 => 2021,
        }
    }

    /// Returns the bibliographic reference
    pub fn reference(&self) -> &'static str {
        self.metadata().evidence.reference
    }

    /// Returns supported geometry types
    pub fn supported_geometries(&self) -> Vec<ExchangerGeometryType> {
        self.metadata().domain.geometries.to_vec()
    }

    /// Returns the valid Reynolds number range
    pub fn re_range(&self) -> (f64, f64) {
        self.range(BoundedQuantityKind::Reynolds)
            .unwrap_or((0.0, f64::INFINITY))
    }

    /// Returns the valid mass flux range (kg/(m²·s))
    pub fn mass_flux_range(&self) -> (f64, f64) {
        self.range(BoundedQuantityKind::MassFlux)
            .unwrap_or((0.0, f64::INFINITY))
    }

    /// Returns the valid quality range
    pub fn quality_range(&self) -> (f64, f64) {
        self.range(BoundedQuantityKind::Quality)
            .unwrap_or((0.0, 0.0))
    }

    fn range(&self, kind: BoundedQuantityKind) -> Option<(f64, f64)> {
        self.metadata()
            .domain
            .bounds
            .iter()
            .find(|bound| bound.kind == kind)
            .map(|bound| (bound.min, bound.max))
    }

    /// Returns description with validity range
    pub fn description(&self) -> &'static str {
        match self {
            Self::Longo2004 => {
                "BPHX evaporation/condensation. Re: 100-6000, G: 15-50 kg/(m²·s), x: 0.05-0.95"
            }
            Self::Shah1979 => "Tube condensation. Re: 350-63000, G: 10-500 kg/(m²·s)",
            Self::Shah2009 => {
                "Tube condensation regimes I/II/III. Re: 350-100000, G: 10-800 kg/(m²·s)"
            }
            Self::Shah2021 => "Plate condensation (recent). Re: 200-10000, G: 20-300 kg/(m²·s)",
            Self::Cavallini2006 => {
                "Tube condensation (halocarbon). Re: 500-100000, G: 50-800 kg/(m²·s)"
            }
            Self::Kandlikar1990 => "Tube evaporation. Re: 300-10000, G: 50-500 kg/(m²·s)",
            Self::GungorWinterton1986 => "Tube evaporation. Re: 300-50000, G: 50-500 kg/(m²·s)",
            Self::Gnielinski1976 => "Single-phase turbulent (accurate). Re: 2300-5000000",
            Self::DittusBoelter1930 => {
                "Single-phase turbulent (simple). Re: 10000-120000, Pr: 0.7-160, L/D>10"
            }
            Self::Ko2021 => {
                "Low-GWP refrigerants in plates. Re: 200-10000, G: 20-300 kg/(m²·s), x: 0-1"
            }
        }
    }

    /// Checks if this correlation supports the given geometry type
    pub fn supports_geometry(&self, geometry: ExchangerGeometryType) -> bool {
        self.supported_geometries().contains(&geometry)
    }

    /// Custom validity check for correlation
    fn check_validity_custom(&self, params: &CorrelationParams) -> ValidityStatus {
        let geometry = self
            .metadata()
            .domain
            .geometries
            .first()
            .copied()
            .unwrap_or_default();
        match self.assess(params, geometry, None) {
            Ok(assessment) => assessment
                .domain_status
                .map(ValidityStatus::from)
                .unwrap_or(ValidityStatus::Extrapolation),
            Err(_) => ValidityStatus::Extrapolation,
        }
    }
}

impl From<DomainStatus> for ValidityStatus {
    fn from(status: DomainStatus) -> Self {
        match status {
            DomainStatus::InDomain => Self::Valid,
            DomainStatus::NearBoundary => Self::NearBoundary,
            DomainStatus::Extrapolated => Self::Extrapolation,
        }
    }
}

/// Longo (2004) correlation for BPHX
///
/// ## Evaporation (two-phase)
///
/// $$Nu_{tp} = 0.05 \cdot Re_{eq}^{0.8} \cdot Pr_l^{0.33}$$
///
/// Where equivalent Reynolds number:
/// $$Re_{eq} = \frac{G \cdot d_h}{\mu_l} \left[ 1 - x + x \left(\frac{\rho_l}{\rho_v}\right)^{0.5} \right]$$
///
/// ## Condensation (two-phase)
///
/// $$Nu_{tp} = 1.875 \cdot Re_{eq}^{0.35} \cdot Pr_l^{0.33} \cdot \left(\frac{\rho_l}{\rho_v}\right)^{-0.1}$$
///
/// ## Single-phase
///
/// $$Nu = 0.27 \cdot Re^{0.7} \cdot Pr^{0.33}$$
///
/// ## Validity Ranges
///
/// - Re: 100 - 6000 (two-phase)
/// - Mass flux: 15 - 50 kg/(m²·s)
/// - Quality: 0.05 - 0.95
fn longo_2004(params: &CorrelationParams) -> CorrelationResult {
    let re_l = if params.mu_l < 1e-15 {
        0.0
    } else {
        params.mass_flux * params.dh / params.mu_l
    };

    let nu = match params.regime {
        FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => {
            0.27 * re_l.powf(0.7) * params.pr_l.powf(0.33)
        }
        FlowRegime::Evaporation | FlowRegime::Condensation => {
            let re_eq = params.longo_equivalent_two_phase_reynolds();

            if params.regime == FlowRegime::Evaporation {
                0.05 * re_eq.powf(0.8) * params.pr_l.powf(0.33)
            } else {
                let density_factor = (params.rho_l / params.rho_v).powf(-0.1);
                1.875 * re_eq.powf(0.35) * params.pr_l.powf(0.33) * density_factor
            }
        }
    };

    let h = nu * params.k_l / params.dh;
    let validity = BphxCorrelation::Longo2004.check_validity_custom(params);

    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Liquid-only Dittus–Boelter HTC used by Shah / Cavallini [W/(m²·K)].
fn h_lo_dittus(params: &CorrelationParams) -> (f64, f64) {
    let re_l = if params.mu_l < 1e-15 {
        0.0
    } else {
        params.mass_flux * params.dh / params.mu_l
    };
    let nu = 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4);
    (nu * params.k_l / params.dh.max(1e-12), re_l)
}

/// Shah (1979) correlation for tube condensation
fn shah_1979(params: &CorrelationParams) -> CorrelationResult {
    let (h_lo, re_l) = h_lo_dittus(params);

    let h = match params.regime {
        FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => h_lo,
        FlowRegime::Evaporation | FlowRegime::Condensation => {
            let x = params.quality.clamp(0.0, 1.0);
            let p_r = params.p_reduced.clamp(1e-4, 0.99);
            let z = ((1.0 / x.max(1e-6) - 1.0).powf(0.8)) * p_r.powf(0.4);
            h_lo * (1.0 + 3.8 / z.max(1e-6).powf(0.95))
        }
    };
    let nu = h * params.dh / params.k_l.max(1e-12);
    let validity = BphxCorrelation::Shah1979.check_validity_custom(params);

    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Shah (2009) tube condensation with three regimes (simplified industrial form).
///
/// Regime selection uses the dimensionless gas velocity `J_g` and Shah's `Z`.
/// Regime I (turbulent annular): `h = h_I`; Regime III (stratified): `h = h_III`;
/// Regime II: blend.
fn shah_2009(params: &CorrelationParams) -> CorrelationResult {
    let (h_lo, re_l) = h_lo_dittus(params);
    let x = params.quality.clamp(0.0, 1.0);
    let p_r = params.p_reduced.clamp(1e-4, 0.99);
    let g = params.mass_flux.abs();
    let rho_g = params.rho_v.max(1e-9);
    let j_g = x * g / (g_accel() * params.dh.max(1e-9) * rho_g).sqrt();
    let z = ((1.0 / x.max(1e-6) - 1.0).powf(0.8)) * p_r.powf(0.4);

    let h_i = h_lo * (1.0 + 3.8 / z.max(1e-6).powf(0.95));
    // Stratified / laminar film contribution (Nusselt-like lower bound).
    let h_iii = h_lo
        * ((1.0 - x).powf(0.8) + 3.8 * x.powf(0.76) * (1.0 - x).powf(0.04) / p_r.powf(0.38))
            .max(0.1);

    let h = if j_g >= 0.98 * (z + 0.263).powf(-0.62) {
        h_i
    } else if j_g <= 0.95 * (1.254 + 2.27 * z.powf(1.249)).powf(-1.0) {
        h_iii
    } else {
        0.5 * (h_i + h_iii)
    };

    let nu = h * params.dh / params.k_l.max(1e-12);
    let validity = BphxCorrelation::Shah2009.check_validity_custom(params);
    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

#[inline]
fn g_accel() -> f64 {
    9.80665
}

/// Cavallini et al. (2006) condensation inside smooth tubes (simplified).
///
/// Uses liquid-only HTC enhanced by Martinelli parameter and dimensionless
/// gas velocity for halogenated refrigerants.
fn cavallini_2006(params: &CorrelationParams) -> CorrelationResult {
    let (h_lo, re_l) = h_lo_dittus(params);
    let x = params.quality.clamp(1e-6, 1.0 - 1e-6);
    let rho_l = params.rho_l.max(1e-9);
    let rho_g = params.rho_v.max(1e-9);
    let mu_l = params.mu_l.max(1e-12);
    let mu_g = params.mu_v.max(1e-12);
    let x_tt = ((1.0 - x) / x).powf(0.9) * (rho_g / rho_l).powf(0.5) * (mu_l / mu_g).powf(0.1);
    let j_g = x * params.mass_flux.abs() / (g_accel() * params.dh.max(1e-9) * rho_g).sqrt();

    // Annular (ΔT-independent) branch when J_g is high.
    let h_ann = h_lo
        * (1.0
            + 1.128
                * x.powf(0.817)
                * (rho_l / rho_g).powf(0.3685)
                * (mu_l / mu_g).powf(0.2363)
                * (1.0 - mu_g / mu_l).powf(2.144)
                * params.pr_l.powf(-0.1));
    // Stratified / ΔT-dependent branch (blended with annular).
    let h_strat = h_lo * (1.0 + 1.2 / x_tt.max(0.05).powf(0.7));
    let h = if j_g > 2.5 {
        h_ann
    } else if j_g < 0.5 {
        h_strat
    } else {
        let w = (j_g - 0.5) / 2.0;
        (1.0 - w) * h_strat + w * h_ann
    };

    let nu = h * params.dh / params.k_l.max(1e-12);
    let validity = BphxCorrelation::Cavallini2006.check_validity_custom(params);
    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Shah (2021) correlation for plate condensation
fn shah_2021(params: &CorrelationParams) -> CorrelationResult {
    let re_l = if params.mu_l < 1e-15 {
        0.0
    } else {
        params.mass_flux * params.dh / params.mu_l
    };

    let nu = match params.regime {
        FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => {
            0.205 * re_l.powf(0.7) * params.pr_l.powf(0.33)
        }
        FlowRegime::Evaporation | FlowRegime::Condensation => {
            0.205 * re_l.powf(0.7) * params.pr_l.powf(0.33) * 1.15
        }
    };

    let h = nu * params.k_l / params.dh;

    let validity = BphxCorrelation::Shah2021.check_validity_custom(params);

    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Kandlikar (1990) correlation for tube evaporation
fn kandlikar_1990(params: &CorrelationParams) -> CorrelationResult {
    let re_l = if params.mu_l < 1e-15 {
        0.0
    } else {
        params.mass_flux * params.dh / params.mu_l
    };

    let bo = params.rho_v / params.rho_l;

    let nu = match params.regime {
        FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => {
            0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4)
        }
        FlowRegime::Evaporation | FlowRegime::Condensation => {
            let co = 1.0 + 0.7 * bo;
            0.6683 * co * re_l.powf(0.5) * params.pr_l.powf(0.4)
        }
    };

    let h = nu * params.k_l / params.dh;

    let validity = BphxCorrelation::Kandlikar1990.check_validity_custom(params);

    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Gungor-Winterton (1986) correlation for tube evaporation
fn gungor_winterton_1986(params: &CorrelationParams) -> CorrelationResult {
    let re_l = if params.mu_l < 1e-15 {
        0.0
    } else {
        params.mass_flux * params.dh / params.mu_l
    };

    let nu_sp = 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4);

    let nu = match params.regime {
        FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => nu_sp,
        FlowRegime::Evaporation | FlowRegime::Condensation => {
            let bo = params.rho_v / params.rho_l;
            let e = 1.0 + 1.8 / bo;
            let s = 1.0 + 0.1 / bo.powf(0.7);
            let quality_safe = params.quality.max(0.01);
            let h_ratio = e * (1.0 / quality_safe).powf(0.3) * s;
            nu_sp * h_ratio
        }
    };

    let h = nu * params.k_l / params.dh;

    let validity = BphxCorrelation::GungorWinterton1986.check_validity_custom(params);

    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Gnielinski (1976) correlation for single-phase turbulent flow
fn gnielinski_1976(params: &CorrelationParams) -> CorrelationResult {
    use entropyk_core::smoothing::{cubic_blend, smooth_max};

    let re_l = if params.mu_l < 1e-15 {
        0.0
    } else {
        params.mass_flux * params.dh / params.mu_l
    };

    // Laminar→turbulent transition band. Below RE_LAMINAR the laminar branch is
    // used as-is; above RE_TURBULENT the turbulent branch is used; in between the
    // two are joined with a C¹ cubic blend so the Nusselt number (and therefore
    // the analytic UA Jacobian) has no slope discontinuity at Re = 2300. The
    // turbulent Nusselt expression is only evaluated for Re ≥ RE_LAMINAR, where
    // its log argument stays positive (otherwise `powf(-2.0)` of a negative base
    // would yield NaN). The friction factor is not returned here — pressure drop
    // is handled separately in `bphx_exchanger::compute_pressure_drop`.
    const RE_LAMINAR: f64 = 2300.0;
    const RE_TURBULENT: f64 = 4000.0;
    // Physical conduction floor for fully-developed laminar flow (Nu ≈ 3.66 for a
    // constant wall temperature). The bare low-Re branch `0.023·Re^0.8·Pr^0.4`
    // collapses to 0 as Re → 0, which is non-physical and lets the UA vanish when
    // the solver transiently visits near-stagnant flow during iteration. A C^∞
    // `smooth_max` clamps the Nusselt number from below without introducing a
    // slope discontinuity; it only engages at very low Re (≈ Re < 250) and leaves
    // the validated turbulent/transition range untouched (overshoot ≤ k/2).
    const NU_LAMINAR_FLOOR: f64 = 3.66;
    const NU_FLOOR_SMOOTHING: f64 = 0.5;

    let nu_laminar = 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4);

    let nu_raw = if re_l <= RE_LAMINAR {
        nu_laminar
    } else {
        let f_turbulent = (1.82 * re_l.log10() - 1.64).powf(-2.0);
        let nu_turbulent = (f_turbulent / 8.0) * (re_l - 1000.0) * params.pr_l
            / (1.0 + 12.7 * (f_turbulent / 8.0).sqrt() * (params.pr_l.powf(0.6667) - 1.0));

        if re_l >= RE_TURBULENT {
            nu_turbulent
        } else {
            cubic_blend(nu_laminar, nu_turbulent, re_l, RE_LAMINAR, RE_TURBULENT)
        }
    };

    let nu = smooth_max(nu_raw, NU_LAMINAR_FLOOR, NU_FLOOR_SMOOTHING);

    let h = nu * params.k_l / params.dh;

    let validity = BphxCorrelation::Gnielinski1976.check_validity_custom(params);

    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Dittus-Boelter (1930) correlation for single-phase turbulent flow
///
/// $$Nu = 0.023 \cdot Re^{0.8} \cdot Pr^n$$
///
/// Where:
/// - n = 0.4 for heating (fluid being heated)
/// - n = 0.3 for cooling (fluid being cooled)
///
/// ## Validity Ranges
///
/// - Re: 10,000 - 120,000
/// - Pr: 0.7 - 160
/// - L/D > 10
fn dittus_boelter_1930(params: &CorrelationParams) -> CorrelationResult {
    let re_l = if params.mu_l < 1e-15 {
        0.0
    } else {
        params.mass_flux * params.dh / params.mu_l
    };

    // Determine exponent based on regime (heating vs cooling)
    let n = match params.regime {
        FlowRegime::SinglePhaseVapor => 0.4, // Heating (vapor being heated)
        FlowRegime::SinglePhaseLiquid => 0.3, // Cooling (liquid being cooled)
        _ => 0.4,
    };

    let nu = 0.023 * re_l.powf(0.8) * params.pr_l.powf(n);
    let h = nu * params.k_l / params.dh;

    let validity = BphxCorrelation::DittusBoelter1930.check_validity_custom(params);

    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Ko (2021) correlation for low-GWP refrigerants in plate heat exchangers
///
/// $$Nu = 0.205 \cdot Re^{0.7} \cdot Pr^{0.33} \cdot F_{corr}$$
///
/// Where:
/// - F_corr = 1.15 for evaporation/condensation
/// - F_corr = 1.0 for single-phase
///
/// ## Validity Ranges
///
/// - Re: 200 - 10,000
/// - Mass flux: 20 - 300 kg/(m²·s)
/// - Quality: 0 - 1
fn ko_2021(params: &CorrelationParams) -> CorrelationResult {
    let re_l = if params.mu_l < 1e-15 {
        0.0
    } else {
        params.mass_flux * params.dh / params.mu_l
    };

    // Correlation factor based on regime
    let f_corr = match params.regime {
        FlowRegime::Evaporation | FlowRegime::Condensation => 1.15,
        _ => 1.0,
    };

    let nu = 0.205 * re_l.powf(0.7) * params.pr_l.powf(0.33) * f_corr;
    let h = nu * params.k_l / params.dh;

    let validity = BphxCorrelation::Ko2021.check_validity_custom(params);

    CorrelationResult {
        h,
        re: re_l,
        pr: params.pr_l,
        nu,
        validity,
        warning: None,
    }
}

/// Formula result paired with the complete automatic-selection report.
#[derive(Debug, Clone)]
pub struct CorrelationEvaluation {
    /// Evaluated heat-transfer result.
    pub result: CorrelationResult,
    /// Ranked and explainable candidate report.
    pub selection: SelectionOutcome,
}

impl std::ops::Deref for CorrelationEvaluation {
    type Target = CorrelationResult;

    fn deref(&self) -> &Self::Target {
        &self.result
    }
}

/// Selector for choosing the appropriate correlation.
#[derive(Debug, Clone)]
pub struct CorrelationSelector {
    /// Named correlation used when automatic selection is disabled.
    pub correlation: BphxCorrelation,
    /// Whether high-level evaluation ranks every registered candidate.
    pub automatic: bool,
    /// Whether to attach warnings for extrapolation.
    pub warn_on_extrapolation: bool,
}

impl Default for CorrelationSelector {
    fn default() -> Self {
        Self {
            correlation: BphxCorrelation::Longo2004,
            automatic: true,
            warn_on_extrapolation: true,
        }
    }
}

impl CorrelationSelector {
    /// Creates an automatic selector.
    pub fn new() -> Self {
        Self::default()
    }

    /// Disables automatic selection and requests one named correlation.
    pub fn with_correlation(mut self, correlation: BphxCorrelation) -> Self {
        self.correlation = correlation;
        self.automatic = false;
        self
    }

    /// Sets whether to warn on extrapolation
    pub fn with_warn_on_extrapolation(mut self, warn: bool) -> Self {
        self.warn_on_extrapolation = warn;
        self
    }

    /// Returns a list of all available correlations
    pub fn available_correlations() -> Vec<BphxCorrelation> {
        vec![
            BphxCorrelation::Longo2004,
            BphxCorrelation::Shah1979,
            BphxCorrelation::Shah2009,
            BphxCorrelation::Shah2021,
            BphxCorrelation::Cavallini2006,
            BphxCorrelation::Kandlikar1990,
            BphxCorrelation::GungorWinterton1986,
            BphxCorrelation::Gnielinski1976,
            BphxCorrelation::DittusBoelter1930,
            BphxCorrelation::Ko2021,
        ]
    }

    /// Returns registry metadata for every available heat-transfer formula.
    pub fn available_metadata() -> Vec<CorrelationMetadata> {
        Self::available_correlations()
            .into_iter()
            .map(BphxCorrelation::metadata)
            .collect()
    }

    /// Returns metadata for a specific correlation
    pub fn get_correlation_info(&self, correlation: BphxCorrelation) -> CorrelationInfo {
        let (re_min, re_max) = correlation.re_range();
        let (g_min, g_max) = correlation.mass_flux_range();
        let (x_min, x_max) = correlation.quality_range();

        CorrelationInfo {
            name: correlation.name().to_string(),
            year: correlation.year(),
            reference: correlation.reference().to_string(),
            validity: format!(
                "Re: {:.0} - {:.0}, Mass flux: {:.1} - {:.1} kg/(m²·s), Quality: {:.2} - {:.2}",
                re_min, re_max, g_min, g_max, x_min, x_max
            ),
        }
    }

    /// Returns the legacy geometry-only suggestion.
    ///
    /// Geometry alone is insufficient for scientific selection. New code should
    /// use [`Self::select_and_compute`] with a complete operating context.
    pub fn recommend_for_geometry(&self, geometry: ExchangerGeometryType) -> BphxCorrelation {
        match geometry {
            ExchangerGeometryType::BrazedPlate | ExchangerGeometryType::GasketedPlate => {
                BphxCorrelation::Ko2021
            }
            ExchangerGeometryType::SmoothTube | ExchangerGeometryType::ShellAndTube => {
                BphxCorrelation::Gnielinski1976
            }
            ExchangerGeometryType::FinnedTube => BphxCorrelation::Kandlikar1990,
        }
    }

    /// Computes one explicitly configured formula.
    ///
    /// This compatibility API does not perform structural selection. Prefer
    /// [`Self::select_and_compute`] for checked, explainable automatic selection.
    pub fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult {
        let mut result = self.correlation.compute_htc(params);

        let validity = self.correlation.check_validity_custom(params);
        result.validity = validity;

        if self.warn_on_extrapolation && validity == ValidityStatus::Extrapolation {
            append_warning_message(
                &mut result.warning,
                format!(
                    "Parameters outside validity range for {} correlation (re={:.0}, G={:.1}, x={:.2})",
                    self.correlation.name(),
                    result.re,
                    params.mass_flux,
                    params.quality
                ),
            );
        }

        result
    }

    /// Selects a structurally compatible candidate, then evaluates only that formula.
    pub fn select_and_compute(
        &self,
        params: &CorrelationParams,
        geometry: ExchangerGeometryType,
        refrigerant: Option<&str>,
    ) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
        let context = params.selection_context(geometry, refrigerant)?;
        let metadata = if self.automatic {
            Self::available_metadata()
        } else {
            vec![self.correlation.metadata()]
        };
        let selection = select_correlation(&metadata, &context)?;
        let correlation = BphxCorrelation::from_id(selection.selected).ok_or(
            CorrelationSelectionError::MissingEvaluator {
                id: selection.selected,
            },
        )?;
        let mut result = correlation.compute_htc(params);
        validate_finite_result(correlation.id(), &result)?;
        let selected =
            selection
                .selected_assessment()
                .ok_or(CorrelationSelectionError::MissingEvaluator {
                    id: selection.selected,
                })?;
        let status = selected.domain_status.unwrap_or(DomainStatus::Extrapolated);
        result.validity = status.into();
        let selection_warning = match status {
            DomainStatus::InDomain => None,
            DomainStatus::NearBoundary => Some(format!(
                "{} selected near {} declared validity boundary/boundaries",
                correlation.name(),
                selected.near_boundaries.len()
            )),
            DomainStatus::Extrapolated => Some(format!(
                "{} selected only as an extrapolated last resort; {} limit(s) exceeded",
                correlation.name(),
                selected.violations.len()
            )),
        };
        if let Some(warning) = selection_warning {
            append_warning_message(&mut result.warning, warning);
        }
        Ok(CorrelationEvaluation { result, selection })
    }
}

fn append_warning_message(warning: &mut Option<String>, message: String) {
    match warning {
        Some(existing) => {
            existing.push_str("; ");
            existing.push_str(&message);
        }
        None => *warning = Some(message),
    }
}

fn validate_finite_result(
    id: CorrelationId,
    result: &CorrelationResult,
) -> Result<(), CorrelationSelectionError> {
    for (field, value) in [
        ("h", result.h),
        ("nu", result.nu),
        ("re", result.re),
        ("pr", result.pr),
    ] {
        if !value.is_finite() {
            return Err(CorrelationSelectionError::NonFiniteResult { id, field, value });
        }
    }
    Ok(())
}

/// Metadata for a correlation
#[derive(Debug, Clone)]
pub struct CorrelationInfo {
    /// Correlation name
    pub name: String,
    /// Publication year
    pub year: u16,
    /// Bibliographic reference
    pub reference: String,
    /// Validity range description
    pub validity: String,
}

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

    fn test_params() -> CorrelationParams {
        CorrelationParams::default()
    }

    #[test]
    fn test_longo_2004_evaporation() {
        let params = test_params();
        let result = BphxCorrelation::Longo2004.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.re > 0.0);
        assert!(result.nu > 0.0);
    }

    #[test]
    fn test_longo_2004_condensation() {
        let mut params = test_params();
        params.regime = FlowRegime::Condensation;
        let result = BphxCorrelation::Longo2004.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.nu > 0.0);
    }

    #[test]
    fn test_longo_2004_single_phase() {
        let mut params = test_params();
        params.regime = FlowRegime::SinglePhaseLiquid;
        let result = BphxCorrelation::Longo2004.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.nu > 0.0);
    }

    #[test]
    fn test_dittus_boelter_1930_heating() {
        let mut params = test_params();
        params.regime = FlowRegime::SinglePhaseVapor;
        params.t_wall = 310.0;
        let result = BphxCorrelation::DittusBoelter1930.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.nu > 0.0);
        let re_l = params.mass_flux * params.dh / params.mu_l;
        let expected_nu = 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4);
        assert!((result.nu - expected_nu).abs() < 1e-6);
    }

    #[test]
    fn test_dittus_boelter_1930_cooling() {
        let mut params = test_params();
        params.regime = FlowRegime::SinglePhaseLiquid;
        params.t_wall = 300.0;
        let result = BphxCorrelation::DittusBoelter1930.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.nu > 0.0);
        let re_l = params.mass_flux * params.dh / params.mu_l;
        let expected_nu = 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.3);
        assert!((result.nu - expected_nu).abs() < 1e-6);
    }

    #[test]
    fn test_dittus_boelter_1930_extrapolation() {
        let mut params = test_params();
        params.mass_flux = 500.0;
        params.regime = FlowRegime::SinglePhaseLiquid;
        let result = BphxCorrelation::DittusBoelter1930.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.nu > 0.0);
        assert_eq!(result.validity, ValidityStatus::Extrapolation);
    }

    #[test]
    fn test_ko_2021_evaporation() {
        let params = test_params();
        let result = BphxCorrelation::Ko2021.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.nu > 0.0);
        assert_eq!(result.validity, ValidityStatus::NearBoundary);
    }

    #[test]
    fn test_ko_2021_condensation() {
        let mut params = test_params();
        params.regime = FlowRegime::Condensation;
        let result = BphxCorrelation::Ko2021.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.nu > 0.0);
        assert_eq!(result.validity, ValidityStatus::NearBoundary);
    }

    #[test]
    fn test_ko_2021_single_phase() {
        let mut params = test_params();
        params.regime = FlowRegime::SinglePhaseLiquid;
        let result = BphxCorrelation::Ko2021.compute_htc(&params);

        assert!(result.h > 0.0);
        assert!(result.nu > 0.0);
        assert_eq!(result.validity, ValidityStatus::NearBoundary);
    }

    #[test]
    fn test_correlation_selector_available_correlations() {
        let correlations = CorrelationSelector::available_correlations();
        assert_eq!(correlations.len(), 10);
    }

    #[test]
    fn test_correlation_selector_get_info() {
        let info = CorrelationSelector::new().get_correlation_info(BphxCorrelation::Longo2004);

        assert_eq!(info.name, "Longo (2004)");
        assert_eq!(info.year, 2004);
        assert!(info.reference.contains("2004"));
    }

    #[test]
    fn test_correlation_selector_recommend_for_geometry() {
        let selector = CorrelationSelector::new();

        let rec = selector.recommend_for_geometry(ExchangerGeometryType::BrazedPlate);
        assert_eq!(rec, BphxCorrelation::Ko2021);

        let rec = selector.recommend_for_geometry(ExchangerGeometryType::SmoothTube);
        assert_eq!(rec, BphxCorrelation::Gnielinski1976);

        let rec = selector.recommend_for_geometry(ExchangerGeometryType::FinnedTube);
        assert_eq!(rec, BphxCorrelation::Kandlikar1990);

        let rec = selector.recommend_for_geometry(ExchangerGeometryType::ShellAndTube);
        assert_eq!(rec, BphxCorrelation::Gnielinski1976);

        let rec = selector.recommend_for_geometry(ExchangerGeometryType::GasketedPlate);
        assert_eq!(rec, BphxCorrelation::Ko2021);
    }

    #[test]
    fn test_correlation_result_is_valid() {
        let result = CorrelationResult {
            h: 5000.0,
            re: 30000.0,
            pr: 3.5,
            nu: 100.0,
            validity: ValidityStatus::Valid,
            warning: None,
        };

        assert!(result.is_valid());
    }

    #[test]
    fn test_correlation_result_with_warning() {
        let result = CorrelationResult {
            h: 5000.0,
            re: 30000.0,
            pr: 3.5,
            nu: 100.0,
            validity: ValidityStatus::Extrapolation,
            warning: None,
        }
        .with_warning("Test warning");

        assert_eq!(result.warning, Some("Test warning".to_string()));
    }

    #[test]
    fn test_correlation_result_default() {
        let result = CorrelationResult::default();
        assert_eq!(result.h, 0.0);
        assert_eq!(result.validity, ValidityStatus::Valid);
    }

    #[test]
    fn test_bphx_correlation_name() {
        assert_eq!(BphxCorrelation::Longo2004.name(), "Longo (2004)");
        assert_eq!(BphxCorrelation::Shah1979.name(), "Shah (1979)");
    }

    #[test]
    fn test_bphx_correlation_re_range() {
        let (re_min, re_max) = BphxCorrelation::Longo2004.re_range();
        assert_eq!(re_min, 100.0);
        assert_eq!(re_max, 6000.0);

        let (re_min, re_max) = BphxCorrelation::Gnielinski1976.re_range();
        assert_eq!(re_min, 2300.0);
        assert_eq!(re_max, 5_000_000.0);
    }

    #[test]
    fn test_bphx_correlation_mass_flux_range() {
        let (g_min, g_max) = BphxCorrelation::Longo2004.mass_flux_range();
        assert_eq!(g_min, 15.0);
        assert_eq!(g_max, 50.0);
    }

    #[test]
    fn test_correlation_selector_default() {
        let selector = CorrelationSelector::default();
        assert_eq!(selector.correlation, BphxCorrelation::Longo2004);
    }

    #[test]
    fn test_correlation_selector_with_correlation() {
        let selector = CorrelationSelector::new().with_correlation(BphxCorrelation::Shah1979);
        assert_eq!(selector.correlation, BphxCorrelation::Shah1979);
    }

    #[test]
    fn test_validity_check_valid() {
        let params = test_params();
        let validity = BphxCorrelation::Longo2004.check_validity_custom(&params);
        assert_eq!(validity, ValidityStatus::Valid);
    }

    #[test]
    fn test_validity_check_extrapolation() {
        let mut params = test_params();
        params.mass_flux = 100.0;
        let validity = BphxCorrelation::Longo2004.check_validity_custom(&params);
        assert_eq!(validity, ValidityStatus::Extrapolation);
    }

    #[test]
    fn test_shah_1979() {
        let params = test_params();
        let result = BphxCorrelation::Shah1979.compute_htc(&params);
        assert!(result.h > 0.0);
    }

    #[test]
    fn test_shah_2021() {
        let params = test_params();
        let result = BphxCorrelation::Shah2021.compute_htc(&params);
        assert!(result.h > 0.0);
    }

    #[test]
    fn test_shah_2009() {
        let mut params = test_params();
        params.regime = FlowRegime::Condensation;
        params.mass_flux = 200.0;
        params.p_reduced = 0.25;
        let result = BphxCorrelation::Shah2009.compute_htc(&params);
        assert!(result.h.is_finite() && result.h > 0.0);
    }

    #[test]
    fn test_cavallini_2006() {
        let mut params = test_params();
        params.regime = FlowRegime::Condensation;
        params.mass_flux = 200.0;
        let result = BphxCorrelation::Cavallini2006.compute_htc(&params);
        assert!(result.h.is_finite() && result.h > 0.0);
    }

    #[test]
    fn test_kandlikar_1990() {
        let params = test_params();
        let result = BphxCorrelation::Kandlikar1990.compute_htc(&params);
        assert!(result.h > 0.0);
    }

    #[test]
    fn test_gungor_winterton_1986() {
        let params = test_params();
        let result = BphxCorrelation::GungorWinterton1986.compute_htc(&params);
        assert!(result.h > 0.0);
    }

    #[test]
    fn test_gungor_winterton_1986_zero_quality() {
        let mut params = test_params();
        params.quality = 0.0;
        params.regime = FlowRegime::Evaporation;
        let result = BphxCorrelation::GungorWinterton1986.compute_htc(&params);
        assert!(result.h.is_finite());
        assert!(result.h > 0.0);
    }

    #[test]
    fn test_gnielinski_1976() {
        let params = test_params();
        let result = BphxCorrelation::Gnielinski1976.compute_htc(&params);
        assert!(result.h > 0.0);
    }

    #[test]
    fn test_longo_evaporation_nu_formula() {
        let params = test_params();
        let result = BphxCorrelation::Longo2004.compute_htc(&params);

        let re_l = params.mass_flux * params.dh / params.mu_l;
        let density_ratio = (params.rho_l / params.rho_v).sqrt();
        let eq_factor = 1.0 - params.quality + params.quality * density_ratio;
        let re_eq = re_l * eq_factor;
        let expected_nu = 0.05 * re_eq.powf(0.8) * params.pr_l.powf(0.33);

        assert!((result.nu - expected_nu).abs() < 1e-6);
    }

    #[test]
    fn longo_two_phase_applicability_uses_equivalent_reynolds() {
        let mut params = test_params();
        params.rho_l = 1200.0;
        params.rho_v = 0.75;

        let assessment = BphxCorrelation::Longo2004
            .assess(&params, ExchangerGeometryType::BrazedPlate, Some("R-test"))
            .unwrap();
        assert_eq!(assessment.domain_status, Some(DomainStatus::Extrapolated));
        assert!(assessment.violations.iter().any(|violation| {
            violation.quantity == BoundedQuantityKind::EquivalentTwoPhaseReynolds
        }));

        let result = BphxCorrelation::Longo2004.compute_htc(&params);
        assert_eq!(result.re, params.mass_flux * params.dh / params.mu_l);
    }

    #[test]
    fn longo_single_phase_does_not_apply_quality_bounds() {
        let mut params = test_params();
        params.regime = FlowRegime::SinglePhaseLiquid;
        params.quality = 0.0;

        let assessment = BphxCorrelation::Longo2004
            .assess(&params, ExchangerGeometryType::BrazedPlate, Some("R-test"))
            .unwrap();
        assert!(assessment.accepted);
        assert!(assessment
            .violations
            .iter()
            .all(|violation| violation.quantity != BoundedQuantityKind::Quality));
    }

    #[test]
    fn test_correlation_selector_warn_on_extrapolation() {
        let selector = CorrelationSelector::new()
            .with_correlation(BphxCorrelation::Longo2004)
            .with_warn_on_extrapolation(true);

        let mut params = test_params();
        params.mass_flux = 100.0;

        let result = selector.compute_htc(&params);
        assert_eq!(result.validity, ValidityStatus::Extrapolation);
        assert!(result.warning.is_some());
    }

    #[test]
    fn test_near_boundary_validity() {
        let mut params = test_params();
        params.mass_flux = 16.0;

        let validity = BphxCorrelation::Longo2004.check_validity_custom(&params);
        assert_eq!(validity, ValidityStatus::NearBoundary);
    }

    #[test]
    fn test_supports_geometry() {
        assert!(BphxCorrelation::Longo2004.supports_geometry(ExchangerGeometryType::BrazedPlate));
        assert!(!BphxCorrelation::Longo2004.supports_geometry(ExchangerGeometryType::SmoothTube));

        assert!(
            BphxCorrelation::Gnielinski1976.supports_geometry(ExchangerGeometryType::SmoothTube)
        );
        assert!(
            !BphxCorrelation::Gnielinski1976.supports_geometry(ExchangerGeometryType::BrazedPlate)
        );
    }

    #[test]
    fn automatic_selection_reports_every_candidate() {
        let selector = CorrelationSelector::new();
        let evaluation = selector
            .select_and_compute(
                &test_params(),
                ExchangerGeometryType::BrazedPlate,
                Some("R-test"),
            )
            .unwrap();

        assert_eq!(evaluation.selection.assessments.len(), 10);
        assert_eq!(evaluation.selection.selected, CorrelationId::Longo2004);
        assert!(evaluation.selection.assessments.iter().any(|assessment| {
            assessment.id == CorrelationId::Shah1979
                && assessment.rejections.iter().any(|reason| {
                    matches!(
                        reason,
                        crate::heat_exchanger::CandidateRejection::WrongGeometry { .. }
                    )
                })
        }));
    }

    #[test]
    fn automatic_selection_signals_last_resort_extrapolation() {
        let mut params = test_params();
        params.mass_flux = 1000.0;
        let evaluation = CorrelationSelector::new()
            .select_and_compute(&params, ExchangerGeometryType::BrazedPlate, Some("R-test"))
            .unwrap();

        assert_eq!(evaluation.validity, ValidityStatus::Extrapolation);
        assert_eq!(
            evaluation
                .selection
                .selected_assessment()
                .unwrap()
                .domain_status,
            Some(DomainStatus::Extrapolated)
        );
        assert!(evaluation
            .warning
            .as_deref()
            .unwrap()
            .contains("last resort"));
    }

    #[test]
    fn checked_selection_warns_on_extrapolation_even_when_legacy_warning_is_disabled() {
        let mut params = test_params();
        params.mass_flux = 100.0;
        let evaluation = CorrelationSelector::new()
            .with_correlation(BphxCorrelation::Longo2004)
            .with_warn_on_extrapolation(false)
            .select_and_compute(&params, ExchangerGeometryType::BrazedPlate, Some("R-test"))
            .unwrap();

        assert_eq!(evaluation.validity, ValidityStatus::Extrapolation);
        assert!(evaluation
            .warning
            .as_deref()
            .unwrap()
            .contains("last resort"));
    }

    #[test]
    fn selection_warning_preserves_existing_evaluator_warning() {
        let mut warning = Some("evaluator warning".to_string());
        append_warning_message(&mut warning, "selection warning".to_string());
        assert_eq!(
            warning.as_deref(),
            Some("evaluator warning; selection warning")
        );
    }

    #[test]
    fn checked_selection_rejects_non_finite_formula_results() {
        let mut params = test_params();
        params.k_l = f64::MAX;
        let error = CorrelationSelector::new()
            .with_correlation(BphxCorrelation::Longo2004)
            .select_and_compute(&params, ExchangerGeometryType::BrazedPlate, Some("R-test"))
            .unwrap_err();

        assert!(matches!(
            error,
            CorrelationSelectionError::NonFiniteResult {
                id: CorrelationId::Longo2004,
                field: "h",
                ..
            }
        ));
    }

    #[test]
    fn named_selection_rejects_wrong_geometry() {
        let error = CorrelationSelector::new()
            .with_correlation(BphxCorrelation::Shah1979)
            .select_and_compute(
                &test_params(),
                ExchangerGeometryType::BrazedPlate,
                Some("R-test"),
            )
            .unwrap_err();
        assert!(matches!(
            error,
            CorrelationSelectionError::NoCompatibleCandidates { .. }
        ));
    }

    #[test]
    fn checked_selection_rejects_invalid_physical_input() {
        let mut params = test_params();
        params.mu_l = 0.0;
        let error = CorrelationSelector::new()
            .select_and_compute(&params, ExchangerGeometryType::BrazedPlate, Some("R-test"))
            .unwrap_err();
        assert!(matches!(
            error,
            CorrelationSelectionError::InvalidInput(DomainInputError::InvalidPositive {
                field: "liquid_viscosity",
                ..
            })
        ));
    }

    /// Builds Gnielinski params with a prescribed Reynolds number.
    /// Re = mass_flux · dh / mu_l, so mass_flux = Re · mu_l / dh.
    fn gnielinski_params_at_re(re: f64) -> CorrelationParams {
        let mut p = CorrelationParams::default();
        p.mass_flux = re * p.mu_l / p.dh;
        p
    }

    #[test]
    fn test_gnielinski_transition_is_c1() {
        // The laminar→turbulent blend over [2300, 4000] must keep both the HTC
        // value and its slope continuous at the band edges (no kink that would
        // confuse the analytic Newton Jacobian — the PR#563 failure mode).
        let eps = 1.0; // Re step for the finite-difference slope
        for &re_edge in &[2300.0_f64, 4000.0_f64] {
            let h_below = gnielinski_1976(&gnielinski_params_at_re(re_edge - eps)).h;
            let h_at = gnielinski_1976(&gnielinski_params_at_re(re_edge)).h;
            let h_above = gnielinski_1976(&gnielinski_params_at_re(re_edge + eps)).h;

            // Value continuity (< 1% jump across the edge).
            let rel_jump = (h_above - h_below).abs() / h_at.abs();
            assert!(
                rel_jump < 0.01,
                "HTC jump {:.4} at Re = {} (h_below={:.3}, h_above={:.3})",
                rel_jump,
                re_edge,
                h_below,
                h_above
            );

            // Slope continuity: one-sided finite-difference derivatives match.
            let slope_below = (h_at - h_below) / eps;
            let slope_above = (h_above - h_at) / eps;
            let denom = slope_below.abs().max(1e-9);
            assert!(
                (slope_above - slope_below).abs() / denom < 0.5,
                "HTC slope discontinuity at Re = {} ({:.5} vs {:.5})",
                re_edge,
                slope_below,
                slope_above
            );
        }
    }

    #[test]
    fn test_gnielinski_laminar_nusselt_floor() {
        // At near-stagnant flow the bare low-Re branch (0.023·Re^0.8·Pr^0.4)
        // collapses to Nu → 0. The physical conduction floor (Nu ≈ 3.66) must
        // keep the Nusselt number - and therefore the UA - bounded away from
        // zero so the solver stays conditioned when it transiently visits low
        // flow during iteration.
        for &re in &[0.0_f64, 1.0, 50.0] {
            let p = gnielinski_params_at_re(re);
            let nu = gnielinski_1976(&p).h * p.dh / p.k_l;
            assert!(nu.is_finite(), "Nu must be finite at Re = {re}");
            assert!(nu >= 3.66, "Nu {nu:.3} below conduction floor at Re = {re}");
        }
        // The floor must be negligible in the validated turbulent range.
        let p_hi = gnielinski_params_at_re(1.0e5);
        let nu_hi = gnielinski_1976(&p_hi).h * p_hi.dh / p_hi.k_l;
        assert!(nu_hi > 100.0, "turbulent Nu unexpectedly low: {nu_hi:.1}");
    }
}
