//! Scientific applicability metadata and deterministic correlation selection.
//!
//! This module deliberately contains no correlation formulas. It assesses declared
//! domains and ranks candidates; formula evaluation remains in the owning modules.

use std::cmp::Ordering;

use thiserror::Error;

/// Stable identifier for a registered correlation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CorrelationId {
    /// Longo (2004) heat-transfer correlation.
    Longo2004,
    /// Shah (1979) condensation correlation.
    Shah1979,
    /// Shah (2009) tube-condensation correlation (regime I/II/III).
    Shah2009,
    /// Shah (2021) plate-condensation correlation.
    Shah2021,
    /// Cavallini (2006) tube-condensation correlation.
    Cavallini2006,
    /// Kandlikar (1990) evaporation correlation.
    Kandlikar1990,
    /// Gungor-Winterton (1986) evaporation correlation.
    GungorWinterton1986,
    /// Gnielinski (1976) single-phase correlation.
    Gnielinski1976,
    /// Dittus-Boelter (1930) single-phase correlation.
    DittusBoelter1930,
    /// Ko (2021) plate heat-transfer correlation.
    Ko2021,
    /// Cooper (1984) nucleate pool-boiling correlation.
    Cooper1984,
    /// Mostinski (1963) nucleate pool-boiling correlation.
    Mostinski1963,
    /// Friedel (1979) two-phase pressure-drop correlation.
    Friedel1979,
    /// Müller-Steinhagen-Heck (1986) two-phase pressure-drop correlation.
    MullerSteinhagenHeck1986,
    /// BPHX SimplifiedChannel single-phase channel friction (default ΔP).
    SimplifiedChannel,
    /// Martin (1996/1999) chevron-plate single-phase friction.
    Martin1996,
    /// Amalfi–Thome (2016) boiling plate ΔP — metadata only (not implemented).
    AmalfiThome2016,
    /// Tao & Infante Ferreira condensation-in-BPHE DP survey — metadata only.
    TaoInfanteFerreiraSurvey,
}

impl CorrelationId {
    /// Returns the stable, serialization-friendly identifier.
    pub const fn as_str(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",
            Self::Cooper1984 => "cooper-1984",
            Self::Mostinski1963 => "mostinski-1963",
            Self::Friedel1979 => "friedel-1979",
            Self::MullerSteinhagenHeck1986 => "muller-steinhagen-heck-1986",
            Self::SimplifiedChannel => "simplified-channel",
            Self::Martin1996 => "martin-1996",
            Self::AmalfiThome2016 => "amalfi-thome-2016",
            Self::TaoInfanteFerreiraSurvey => "tao-infante-ferreira-survey",
        }
    }
}

/// Physical purpose of a correlation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CorrelationPurpose {
    /// Heat-transfer coefficient evaluation.
    HeatTransfer,
    /// Frictional pressure-drop evaluation.
    PressureDrop,
}

/// Flow regime used for applicability assessment.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FlowRegime {
    /// Single-phase liquid.
    SinglePhaseLiquid,
    /// Single-phase vapor.
    SinglePhaseVapor,
    /// Two-phase evaporation.
    #[default]
    Evaporation,
    /// Two-phase condensation.
    Condensation,
}

/// Heat-exchanger geometry used for structural compatibility.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExchangerGeometryType {
    /// Smooth circular or equivalent tube.
    #[default]
    SmoothTube,
    /// Finned tube.
    FinnedTube,
    /// Brazed plate heat exchanger.
    BrazedPlate,
    /// Gasketed plate heat exchanger.
    GasketedPlate,
    /// Shell-and-tube exchanger.
    ShellAndTube,
}

/// Quantity that may be bounded by a published applicability domain.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoundedQuantityKind {
    /// Reynolds number.
    Reynolds,
    /// Equivalent two-phase Reynolds number used by a correlation formula.
    EquivalentTwoPhaseReynolds,
    /// Mass flux in kg/(m²·s).
    MassFlux,
    /// Vapor quality.
    Quality,
    /// Prandtl number.
    Prandtl,
    /// Channel length divided by hydraulic diameter.
    LengthDiameterRatio,
}

/// Inclusive validity interval for one operating quantity.
#[derive(Debug, Clone, Copy)]
pub struct BoundedQuantity {
    /// Bounded quantity.
    pub kind: BoundedQuantityKind,
    /// Inclusive lower limit.
    pub min: f64,
    /// Inclusive upper limit.
    pub max: f64,
    /// Regimes in which this limit applies.
    pub regimes: &'static [FlowRegime],
}

/// Declared refrigerant applicability.
#[derive(Debug, Clone, Copy)]
pub enum RefrigerantApplicability {
    /// Existing evidence explicitly verifies broad fluid applicability.
    FluidAgnostic,
    /// The source declares an explicit, verified list of identifiers.
    VerifiedList(&'static [&'static str]),
    /// Fluid-family applicability is mentioned, but exact coverage is not verified.
    EvidenceIncomplete,
}

/// Bibliographic and validation evidence attached to a correlation.
#[derive(Debug, Clone, Copy)]
pub struct EvidenceMetadata {
    /// Existing bibliographic reference.
    pub reference: &'static str,
    /// Published validation evidence, only when explicitly available.
    pub validation: Option<ValidationEvidence>,
}

/// Explicitly published validation evidence.
#[derive(Debug, Clone, Copy)]
pub struct ValidationEvidence {
    /// Human-readable metric exactly as reported by the source.
    pub metric: &'static str,
}

/// Complete declared domain for a correlation.
#[derive(Debug, Clone, Copy)]
pub struct ApplicabilityDomain {
    /// Supported purpose.
    pub purpose: CorrelationPurpose,
    /// Structurally supported geometries.
    pub geometries: &'static [ExchangerGeometryType],
    /// Structurally supported flow regimes.
    pub regimes: &'static [FlowRegime],
    /// Published operating limits.
    pub bounds: &'static [BoundedQuantity],
    /// Refrigerant applicability evidence.
    pub refrigerants: RefrigerantApplicability,
}

/// Static registry entry.
#[derive(Debug, Clone, Copy)]
pub struct CorrelationMetadata {
    /// Stable identifier.
    pub id: CorrelationId,
    /// Display name.
    pub name: &'static str,
    /// Scientific applicability domain.
    pub domain: ApplicabilityDomain,
    /// Evidence metadata.
    pub evidence: EvidenceMetadata,
}

const ALL_REGIMES: &[FlowRegime] = &[
    FlowRegime::SinglePhaseLiquid,
    FlowRegime::SinglePhaseVapor,
    FlowRegime::Evaporation,
    FlowRegime::Condensation,
];
const TWO_PHASE_REGIMES: &[FlowRegime] = &[FlowRegime::Evaporation, FlowRegime::Condensation];
const SINGLE_PHASE_REGIMES: &[FlowRegime] =
    &[FlowRegime::SinglePhaseLiquid, FlowRegime::SinglePhaseVapor];
const CONDENSATION: &[FlowRegime] = &[FlowRegime::Condensation];
const EVAPORATION: &[FlowRegime] = &[FlowRegime::Evaporation];
const PLATES: &[ExchangerGeometryType] = &[
    ExchangerGeometryType::BrazedPlate,
    ExchangerGeometryType::GasketedPlate,
];
const SMOOTH_TUBES: &[ExchangerGeometryType] = &[
    ExchangerGeometryType::SmoothTube,
    ExchangerGeometryType::ShellAndTube,
];
const EVAPORATION_TUBES: &[ExchangerGeometryType] = &[
    ExchangerGeometryType::SmoothTube,
    ExchangerGeometryType::FinnedTube,
];
const FRIEDEL_GEOMETRIES: &[ExchangerGeometryType] = &[ExchangerGeometryType::SmoothTube];

const LONGO_BOUNDS: &[BoundedQuantity] = &[
    bound(
        BoundedQuantityKind::Reynolds,
        100.0,
        6000.0,
        SINGLE_PHASE_REGIMES,
    ),
    bound(
        BoundedQuantityKind::EquivalentTwoPhaseReynolds,
        100.0,
        6000.0,
        TWO_PHASE_REGIMES,
    ),
    bound(BoundedQuantityKind::MassFlux, 15.0, 50.0, ALL_REGIMES),
    bound(BoundedQuantityKind::Quality, 0.05, 0.95, TWO_PHASE_REGIMES),
];
const SHAH_1979_BOUNDS: &[BoundedQuantity] = &[
    bound(BoundedQuantityKind::Reynolds, 350.0, 63_000.0, CONDENSATION),
    bound(BoundedQuantityKind::MassFlux, 10.0, 500.0, CONDENSATION),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];
const SHAH_2009_BOUNDS: &[BoundedQuantity] = &[
    bound(
        BoundedQuantityKind::Reynolds,
        350.0,
        100_000.0,
        CONDENSATION,
    ),
    bound(BoundedQuantityKind::MassFlux, 10.0, 800.0, CONDENSATION),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];
const SHAH_2021_BOUNDS: &[BoundedQuantity] = &[
    bound(BoundedQuantityKind::Reynolds, 200.0, 10_000.0, CONDENSATION),
    bound(BoundedQuantityKind::MassFlux, 20.0, 300.0, CONDENSATION),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];
const CAVALLINI_BOUNDS: &[BoundedQuantity] = &[
    bound(
        BoundedQuantityKind::Reynolds,
        500.0,
        100_000.0,
        CONDENSATION,
    ),
    bound(BoundedQuantityKind::MassFlux, 50.0, 800.0, CONDENSATION),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];
const KANDLIKAR_BOUNDS: &[BoundedQuantity] = &[
    bound(BoundedQuantityKind::Reynolds, 300.0, 10_000.0, EVAPORATION),
    bound(BoundedQuantityKind::MassFlux, 50.0, 500.0, EVAPORATION),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION),
];
const GUNGOR_WINTERTON_BOUNDS: &[BoundedQuantity] = &[
    bound(BoundedQuantityKind::Reynolds, 300.0, 50_000.0, EVAPORATION),
    bound(BoundedQuantityKind::MassFlux, 50.0, 500.0, EVAPORATION),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION),
];
const GNIELINSKI_BOUNDS: &[BoundedQuantity] = &[
    bound(
        BoundedQuantityKind::Reynolds,
        2300.0,
        5_000_000.0,
        SINGLE_PHASE_REGIMES,
    ),
    bound(
        BoundedQuantityKind::MassFlux,
        1.0,
        1000.0,
        SINGLE_PHASE_REGIMES,
    ),
];
const DITTUS_BOELTER_BOUNDS: &[BoundedQuantity] = &[
    bound(
        BoundedQuantityKind::Reynolds,
        10_000.0,
        120_000.0,
        SINGLE_PHASE_REGIMES,
    ),
    bound(
        BoundedQuantityKind::MassFlux,
        100.0,
        1000.0,
        SINGLE_PHASE_REGIMES,
    ),
    bound(
        BoundedQuantityKind::Prandtl,
        0.7,
        160.0,
        SINGLE_PHASE_REGIMES,
    ),
    bound(
        BoundedQuantityKind::LengthDiameterRatio,
        10.0,
        f64::INFINITY,
        SINGLE_PHASE_REGIMES,
    ),
];
const KO_BOUNDS: &[BoundedQuantity] = &[
    bound(BoundedQuantityKind::Reynolds, 200.0, 10_000.0, ALL_REGIMES),
    bound(BoundedQuantityKind::MassFlux, 20.0, 300.0, ALL_REGIMES),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, TWO_PHASE_REGIMES),
];
const COOPER_BOUNDS: &[BoundedQuantity] =
    &[bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION)];
const MOSTINSKI_BOUNDS: &[BoundedQuantity] =
    &[bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION)];
const FRIEDEL_BOUNDS: &[BoundedQuantity] = &[bound(
    BoundedQuantityKind::Quality,
    0.0,
    1.0,
    TWO_PHASE_REGIMES,
)];
const MSH_BOUNDS: &[BoundedQuantity] = &[
    bound(
        BoundedQuantityKind::MassFlux,
        10.0,
        2000.0,
        TWO_PHASE_REGIMES,
    ),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, TWO_PHASE_REGIMES),
];
const POOL_BOILING_GEOMETRIES: &[ExchangerGeometryType] = &[
    ExchangerGeometryType::SmoothTube,
    ExchangerGeometryType::ShellAndTube,
    ExchangerGeometryType::FinnedTube,
];
const DP_TUBE_GEOMETRIES: &[ExchangerGeometryType] = &[ExchangerGeometryType::SmoothTube];
const BPHX_DP_BOUNDS: &[BoundedQuantity] = &[
    bound(
        BoundedQuantityKind::Reynolds,
        1.0,
        100_000.0,
        SINGLE_PHASE_REGIMES,
    ),
    bound(
        BoundedQuantityKind::MassFlux,
        1.0,
        2000.0,
        SINGLE_PHASE_REGIMES,
    ),
];
const AMALFI_BOUNDS: &[BoundedQuantity] = &[
    bound(BoundedQuantityKind::MassFlux, 5.0, 600.0, EVAPORATION),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION),
];
const TAO_BOUNDS: &[BoundedQuantity] = &[
    bound(BoundedQuantityKind::MassFlux, 5.0, 600.0, CONDENSATION),
    bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];

const fn bound(
    kind: BoundedQuantityKind,
    min: f64,
    max: f64,
    regimes: &'static [FlowRegime],
) -> BoundedQuantity {
    BoundedQuantity {
        kind,
        min,
        max,
        regimes,
    }
}

/// Returns the central metadata entry for a stable correlation identifier.
pub fn correlation_metadata(id: CorrelationId) -> CorrelationMetadata {
    let (name, purpose, geometries, regimes, bounds, refrigerants, reference) = match id {
        CorrelationId::Longo2004 => (
            "Longo (2004)",
            CorrelationPurpose::HeatTransfer,
            PLATES,
            ALL_REGIMES,
            LONGO_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Longo, G.A. et al. (2004). Int. J. Heat Mass Transfer 47, 1039-1047",
        ),
        CorrelationId::Shah1979 => (
            "Shah (1979)",
            CorrelationPurpose::HeatTransfer,
            SMOOTH_TUBES,
            CONDENSATION,
            SHAH_1979_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Shah, M.M. (1979). Int. J. Heat Mass Transfer 22, 547-556",
        ),
        CorrelationId::Shah2009 => (
            "Shah (2009)",
            CorrelationPurpose::HeatTransfer,
            SMOOTH_TUBES,
            CONDENSATION,
            SHAH_2009_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Shah, M.M. (2009). HVAC&R Research 15(5), 889-913",
        ),
        CorrelationId::Shah2021 => (
            "Shah (2021)",
            CorrelationPurpose::HeatTransfer,
            PLATES,
            CONDENSATION,
            SHAH_2021_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Shah, M.M. (2021). Int. J. Heat Mass Transfer 176, 1-16",
        ),
        CorrelationId::Cavallini2006 => (
            "Cavallini (2006)",
            CorrelationPurpose::HeatTransfer,
            SMOOTH_TUBES,
            CONDENSATION,
            CAVALLINI_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Cavallini, A. et al. (2006). Int. J. Heat Fluid Flow 27, 115-126",
        ),
        CorrelationId::Kandlikar1990 => (
            "Kandlikar (1990)",
            CorrelationPurpose::HeatTransfer,
            EVAPORATION_TUBES,
            EVAPORATION,
            KANDLIKAR_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Kandlikar, S.G. (1990). J. Heat Transfer 112, 219-227",
        ),
        CorrelationId::GungorWinterton1986 => (
            "Gungor-Winterton (1986)",
            CorrelationPurpose::HeatTransfer,
            EVAPORATION_TUBES,
            EVAPORATION,
            GUNGOR_WINTERTON_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Gungor, K.E., Winterton, H.S. (1986). Int. J. Heat Mass Transfer 29, 2715-2722",
        ),
        CorrelationId::Gnielinski1976 => (
            "Gnielinski (1976)",
            CorrelationPurpose::HeatTransfer,
            SMOOTH_TUBES,
            SINGLE_PHASE_REGIMES,
            GNIELINSKI_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Gnielinski, V. (1976). Int. Chemical Engineering 16(2), 359-368",
        ),
        CorrelationId::DittusBoelter1930 => (
            "Dittus-Boelter (1930)",
            CorrelationPurpose::HeatTransfer,
            SMOOTH_TUBES,
            SINGLE_PHASE_REGIMES,
            DITTUS_BOELTER_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Dittus, F.W., Boelter, L.M.K. (1930). Univ. California Publ. Eng. 2, 443-461",
        ),
        CorrelationId::Ko2021 => (
            "Ko (2021)",
            CorrelationPurpose::HeatTransfer,
            PLATES,
            ALL_REGIMES,
            KO_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Ko, J. et al. (2021). Int. J. Heat Mass Transfer 181, 1-12",
        ),
        CorrelationId::Cooper1984 => (
            "Cooper (1984)",
            CorrelationPurpose::HeatTransfer,
            POOL_BOILING_GEOMETRIES,
            EVAPORATION,
            COOPER_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Cooper, M.G. (1984). IChemE Symp. Ser. 86, 785-793",
        ),
        CorrelationId::Mostinski1963 => (
            "Mostinski (1963)",
            CorrelationPurpose::HeatTransfer,
            POOL_BOILING_GEOMETRIES,
            EVAPORATION,
            MOSTINSKI_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Mostinski, I.L. (1963). Teploenergetika 4, 66",
        ),
        CorrelationId::Friedel1979 => (
            "Friedel (1979)",
            CorrelationPurpose::PressureDrop,
            FRIEDEL_GEOMETRIES,
            TWO_PHASE_REGIMES,
            FRIEDEL_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Friedel (1979)",
        ),
        CorrelationId::MullerSteinhagenHeck1986 => (
            "Muller-Steinhagen-Heck (1986)",
            CorrelationPurpose::PressureDrop,
            DP_TUBE_GEOMETRIES,
            TWO_PHASE_REGIMES,
            MSH_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Muller-Steinhagen, H., Heck, K. (1986). Chem. Eng. Process. 20, 297-308",
        ),
        CorrelationId::SimplifiedChannel => (
            "BPHX SimplifiedChannel (default)",
            CorrelationPurpose::PressureDrop,
            PLATES,
            SINGLE_PHASE_REGIMES,
            BPHX_DP_BOUNDS,
            RefrigerantApplicability::FluidAgnostic,
            "Entropyk channel Blasius/laminar C¹ blend; ΔP = z_dp·2 f L G²/(ρ dh)",
        ),
        CorrelationId::Martin1996 => (
            "Martin (1996/1999) chevron plate friction",
            CorrelationPurpose::PressureDrop,
            PLATES,
            SINGLE_PHASE_REGIMES,
            BPHX_DP_BOUNDS,
            RefrigerantApplicability::FluidAgnostic,
            "Martin, H. (1996). Chem. Eng. Process. 35:301–310; Martin 1999 / Shah appendix form",
        ),
        CorrelationId::AmalfiThome2016 => (
            "Amalfi–Thome (2016) boiling plate ΔP [not implemented]",
            CorrelationPurpose::PressureDrop,
            PLATES,
            EVAPORATION,
            AMALFI_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Amalfi, Vakili-Farahani, Thome (2016). Int. J. Refrigeration — Parts 1–2; deferred",
        ),
        CorrelationId::TaoInfanteFerreiraSurvey => (
            "Tao & Infante Ferreira condensation BPHE DP survey [not implemented]",
            CorrelationPurpose::PressureDrop,
            PLATES,
            CONDENSATION,
            TAO_BOUNDS,
            RefrigerantApplicability::EvidenceIncomplete,
            "Tao & Infante Ferreira — condensation-in-BPHE pressure-drop survey; deferred",
        ),
    };
    CorrelationMetadata {
        id,
        name,
        domain: ApplicabilityDomain {
            purpose,
            geometries,
            regimes,
            bounds,
            refrigerants,
        },
        evidence: EvidenceMetadata {
            reference,
            validation: None,
        },
    }
}

/// Returns metadata for every correlation currently registered.
pub fn registered_correlations() -> Vec<CorrelationMetadata> {
    [
        CorrelationId::Longo2004,
        CorrelationId::Shah1979,
        CorrelationId::Shah2009,
        CorrelationId::Shah2021,
        CorrelationId::Cavallini2006,
        CorrelationId::Kandlikar1990,
        CorrelationId::GungorWinterton1986,
        CorrelationId::Gnielinski1976,
        CorrelationId::DittusBoelter1930,
        CorrelationId::Ko2021,
        CorrelationId::Cooper1984,
        CorrelationId::Mostinski1963,
        CorrelationId::Friedel1979,
        CorrelationId::MullerSteinhagenHeck1986,
        CorrelationId::SimplifiedChannel,
        CorrelationId::Martin1996,
        CorrelationId::AmalfiThome2016,
        CorrelationId::TaoInfanteFerreiraSurvey,
    ]
    .into_iter()
    .map(correlation_metadata)
    .collect()
}

/// Operating quantities used for domain assessment.
#[derive(Debug, Clone, Default)]
pub struct OperatingPoint {
    /// Reynolds number.
    pub reynolds: Option<f64>,
    /// Equivalent two-phase Reynolds number, when required by a formula.
    pub equivalent_two_phase_reynolds: Option<f64>,
    /// Mass flux in kg/(m²·s).
    pub mass_flux: Option<f64>,
    /// Vapor quality.
    pub quality: Option<f64>,
    /// Prandtl number.
    pub prandtl: Option<f64>,
    /// Channel length divided by hydraulic diameter.
    pub length_diameter_ratio: Option<f64>,
}

impl OperatingPoint {
    fn get(&self, kind: BoundedQuantityKind) -> Option<f64> {
        match kind {
            BoundedQuantityKind::Reynolds => self.reynolds,
            BoundedQuantityKind::EquivalentTwoPhaseReynolds => self.equivalent_two_phase_reynolds,
            BoundedQuantityKind::MassFlux => self.mass_flux,
            BoundedQuantityKind::Quality => self.quality,
            BoundedQuantityKind::Prandtl => self.prandtl,
            BoundedQuantityKind::LengthDiameterRatio => self.length_diameter_ratio,
        }
    }

    fn validate(&self) -> Result<(), DomainInputError> {
        validate_optional_positive("reynolds", self.reynolds)?;
        validate_optional_positive(
            "equivalent_two_phase_reynolds",
            self.equivalent_two_phase_reynolds,
        )?;
        validate_optional_positive("mass_flux", self.mass_flux)?;
        validate_optional_positive("prandtl", self.prandtl)?;
        validate_optional_positive("length_diameter_ratio", self.length_diameter_ratio)?;
        if let Some(quality) = self.quality {
            if !quality.is_finite() || !(0.0..=1.0).contains(&quality) {
                return Err(DomainInputError::InvalidQuality { value: quality });
            }
        }
        Ok(())
    }
}

fn validate_optional_positive(
    field: &'static str,
    value: Option<f64>,
) -> Result<(), DomainInputError> {
    if let Some(value) = value {
        if !value.is_finite() || value <= 0.0 {
            return Err(DomainInputError::InvalidPositive { field, value });
        }
    }
    Ok(())
}

/// Complete structural and operating context for selection.
#[derive(Debug, Clone)]
pub struct SelectionContext {
    /// Requested correlation purpose.
    pub purpose: CorrelationPurpose,
    /// Actual exchanger geometry.
    pub geometry: ExchangerGeometryType,
    /// Actual flow regime.
    pub regime: FlowRegime,
    /// Refrigerant identifier, when known.
    pub refrigerant: Option<String>,
    /// Operating quantities.
    pub operating_point: OperatingPoint,
}

/// Explicit invalid-input error raised before ranking or formula evaluation.
#[derive(Debug, Clone, PartialEq, Error)]
pub enum DomainInputError {
    /// A required positive quantity was non-finite or non-positive.
    #[error("{field} must be finite and positive, got {value}")]
    InvalidPositive {
        /// Field name.
        field: &'static str,
        /// Rejected value.
        value: f64,
    },
    /// Vapor quality was outside its physical interval.
    #[error("quality must be finite and within [0, 1], got {value}")]
    InvalidQuality {
        /// Rejected quality.
        value: f64,
    },
}

/// Why a candidate was excluded before numerical ranking.
#[derive(Debug, Clone, PartialEq)]
pub enum CandidateRejection {
    /// Correlation purpose does not match.
    WrongPurpose {
        /// Requested purpose.
        requested: CorrelationPurpose,
        /// Candidate purpose.
        supported: CorrelationPurpose,
    },
    /// Exchanger geometry is unsupported.
    WrongGeometry {
        /// Actual geometry.
        actual: ExchangerGeometryType,
    },
    /// Flow regime is unsupported.
    WrongRegime {
        /// Actual regime.
        actual: FlowRegime,
    },
    /// Exact verified refrigerant coverage excludes the requested fluid.
    UnsupportedRefrigerant {
        /// Requested refrigerant.
        refrigerant: String,
    },
    /// A declared limit could not be assessed from the supplied point.
    MissingQuantity {
        /// Missing quantity.
        quantity: BoundedQuantityKind,
    },
    /// A verified refrigerant list cannot be assessed without an identifier.
    MissingRefrigerantIdentifier,
}

/// Refrigerant-evidence result retained in diagnostics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefrigerantEvidenceStatus {
    /// Requested fluid appears in an explicit verified list.
    Verified,
    /// Existing evidence explicitly verifies broad fluid applicability.
    FluidAgnostic,
    /// Exact fluid-family coverage has not been verified.
    EvidenceIncomplete,
    /// A verified coverage list excludes the requested refrigerant.
    VerifiedExcluded,
}

impl RefrigerantEvidenceStatus {
    fn rank(self) -> u8 {
        match self {
            Self::Verified => 2,
            Self::FluidAgnostic => 1,
            Self::EvidenceIncomplete | Self::VerifiedExcluded => 0,
        }
    }
}

/// Applicability status for a structurally compatible candidate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DomainStatus {
    /// All declared limits are satisfied away from their boundaries.
    InDomain,
    /// All declared limits are satisfied, with at least one close to a boundary.
    NearBoundary,
    /// At least one declared limit is exceeded.
    Extrapolated,
}

impl DomainStatus {
    fn rank(self) -> u8 {
        match self {
            Self::InDomain => 2,
            Self::NearBoundary => 1,
            Self::Extrapolated => 0,
        }
    }

    fn is_in_domain(self) -> bool {
        matches!(self, Self::InDomain | Self::NearBoundary)
    }
}

/// A violated published limit.
#[derive(Debug, Clone, PartialEq)]
pub struct LimitViolation {
    /// Violated quantity.
    pub quantity: BoundedQuantityKind,
    /// Actual value.
    pub value: f64,
    /// Inclusive lower limit.
    pub min: f64,
    /// Inclusive upper limit.
    pub max: f64,
    /// Exceedance normalized by interval span.
    pub normalized_exceedance: f64,
}

/// A satisfied limit close to a boundary.
#[derive(Debug, Clone, PartialEq)]
pub struct BoundaryProximity {
    /// Quantity close to its limit.
    pub quantity: BoundedQuantityKind,
    /// Actual value.
    pub value: f64,
    /// Inclusive lower limit.
    pub min: f64,
    /// Inclusive upper limit.
    pub max: f64,
    /// Distance to the nearest limit normalized by interval span.
    pub normalized_distance: f64,
}

/// Full assessment of one registered candidate.
#[derive(Debug, Clone)]
pub struct CandidateAssessment {
    /// Stable candidate identifier.
    pub id: CorrelationId,
    /// Candidate display name.
    pub name: &'static str,
    /// Whether structural and refrigerant checks permit numerical ranking.
    pub accepted: bool,
    /// Refrigerant evidence result.
    pub refrigerant_evidence: RefrigerantEvidenceStatus,
    /// Domain status, absent for rejected candidates.
    pub domain_status: Option<DomainStatus>,
    /// Every violated limit.
    pub violations: Vec<LimitViolation>,
    /// Every close boundary.
    pub near_boundaries: Vec<BoundaryProximity>,
    /// Structural or evidence-based rejection reasons.
    pub rejections: Vec<CandidateRejection>,
    /// Explainable rank summary.
    pub rank_explanation: String,
    boundary_score: f64,
    validation_rank: u8,
}

impl CandidateAssessment {
    /// Returns true when this candidate is usable without extrapolation.
    pub fn is_in_domain(&self) -> bool {
        self.accepted && self.domain_status.is_some_and(DomainStatus::is_in_domain)
    }
}

/// Ranked selection report.
#[derive(Debug, Clone)]
pub struct SelectionOutcome {
    /// Selected candidate.
    pub selected: CorrelationId,
    /// Assessments sorted from strongest candidate to weakest/rejected candidate.
    pub assessments: Vec<CandidateAssessment>,
    /// Decisive selection reasons.
    pub decision_reasons: Vec<String>,
}

impl SelectionOutcome {
    /// Returns the selected candidate assessment.
    pub fn selected_assessment(&self) -> Option<&CandidateAssessment> {
        self.assessments
            .iter()
            .find(|assessment| assessment.id == self.selected)
    }
}

/// Selection failure.
#[derive(Debug, Clone, Error)]
pub enum CorrelationSelectionError {
    /// Invalid physical or domain input.
    #[error(transparent)]
    InvalidInput(#[from] DomainInputError),
    /// Every candidate was structurally or evidentially incompatible.
    #[error("no structurally compatible correlation candidate")]
    NoCompatibleCandidates {
        /// Rejected candidate assessments.
        assessments: Vec<CandidateAssessment>,
    },
    /// Selected registry identifier cannot be evaluated by the requested formula family.
    #[error("selected correlation {id:?} has no evaluator in this formula family")]
    MissingEvaluator {
        /// Selected identifier.
        id: CorrelationId,
    },
    /// A checked formula evaluation produced a non-finite reported quantity.
    #[error("correlation {id:?} produced non-finite {field}: {value}")]
    NonFiniteResult {
        /// Evaluated correlation.
        id: CorrelationId,
        /// Non-finite result field.
        field: &'static str,
        /// Rejected value.
        value: f64,
    },
}

impl CorrelationSelectionError {
    /// Returns candidate rejection diagnostics when selection found no compatible candidate.
    pub fn assessments(&self) -> Option<&[CandidateAssessment]> {
        match self {
            Self::NoCompatibleCandidates { assessments } => Some(assessments),
            _ => None,
        }
    }
}

/// Assesses one candidate without evaluating its formula.
pub fn assess_candidate(
    metadata: &CorrelationMetadata,
    context: &SelectionContext,
) -> Result<CandidateAssessment, DomainInputError> {
    context.operating_point.validate()?;

    let mut rejections = Vec::new();
    if metadata.domain.purpose != context.purpose {
        rejections.push(CandidateRejection::WrongPurpose {
            requested: context.purpose,
            supported: metadata.domain.purpose,
        });
    }
    if !metadata.domain.geometries.contains(&context.geometry) {
        rejections.push(CandidateRejection::WrongGeometry {
            actual: context.geometry,
        });
    }
    if !metadata.domain.regimes.contains(&context.regime) {
        rejections.push(CandidateRejection::WrongRegime {
            actual: context.regime,
        });
    }

    let refrigerant_evidence = match metadata.domain.refrigerants {
        RefrigerantApplicability::FluidAgnostic => RefrigerantEvidenceStatus::FluidAgnostic,
        RefrigerantApplicability::EvidenceIncomplete => {
            RefrigerantEvidenceStatus::EvidenceIncomplete
        }
        RefrigerantApplicability::VerifiedList(verified) => {
            if let Some(refrigerant) = context
                .refrigerant
                .as_deref()
                .map(str::trim)
                .filter(|refrigerant| !refrigerant.is_empty())
            {
                if verified
                    .iter()
                    .any(|candidate| candidate.trim().eq_ignore_ascii_case(refrigerant))
                {
                    RefrigerantEvidenceStatus::Verified
                } else {
                    rejections.push(CandidateRejection::UnsupportedRefrigerant {
                        refrigerant: refrigerant.to_owned(),
                    });
                    RefrigerantEvidenceStatus::VerifiedExcluded
                }
            } else {
                rejections.push(CandidateRejection::MissingRefrigerantIdentifier);
                RefrigerantEvidenceStatus::EvidenceIncomplete
            }
        }
    };

    let structurally_compatible = rejections.is_empty();
    let mut violations = Vec::new();
    let mut near_boundaries = Vec::new();
    let mut minimum_margin = f64::INFINITY;

    if structurally_compatible {
        for bound in metadata
            .domain
            .bounds
            .iter()
            .filter(|bound| bound.regimes.contains(&context.regime))
        {
            let Some(value) = context.operating_point.get(bound.kind) else {
                rejections.push(CandidateRejection::MissingQuantity {
                    quantity: bound.kind,
                });
                continue;
            };
            let span = if bound.max.is_finite() {
                (bound.max - bound.min).abs().max(f64::EPSILON)
            } else {
                bound.min.abs().max(1.0)
            };
            if value < bound.min || value > bound.max {
                let exceedance = if value < bound.min {
                    bound.min - value
                } else {
                    value - bound.max
                };
                violations.push(LimitViolation {
                    quantity: bound.kind,
                    value,
                    min: bound.min,
                    max: bound.max,
                    normalized_exceedance: exceedance / span,
                });
            } else {
                let span_distance = if bound.max.is_finite() {
                    ((value - bound.min).min(bound.max - value)) / span
                } else {
                    (value - bound.min) / span
                };
                minimum_margin = minimum_margin.min(span_distance);
                if span_distance <= 0.1 {
                    near_boundaries.push(BoundaryProximity {
                        quantity: bound.kind,
                        value,
                        min: bound.min,
                        max: bound.max,
                        normalized_distance: span_distance,
                    });
                }
            }
        }
    }

    let accepted = rejections.is_empty();
    let domain_status = accepted.then_some({
        if !violations.is_empty() {
            DomainStatus::Extrapolated
        } else if !near_boundaries.is_empty() {
            DomainStatus::NearBoundary
        } else {
            DomainStatus::InDomain
        }
    });
    let boundary_score = if violations.is_empty() {
        if minimum_margin.is_finite() {
            minimum_margin
        } else {
            1.0
        }
    } else {
        -violations
            .iter()
            .map(|violation| violation.normalized_exceedance)
            .sum::<f64>()
    };
    let rank_explanation = if !accepted {
        format!(
            "rejected by {} structural/evidence criterion(a)",
            rejections.len()
        )
    } else {
        let status = domain_status.unwrap_or(DomainStatus::Extrapolated);
        format!(
            "{:?}; refrigerant evidence {:?}; boundary score {:.6}; validation evidence {}",
            status,
            refrigerant_evidence,
            boundary_score,
            if metadata.evidence.validation.is_some() {
                "published"
            } else {
                "not declared"
            }
        )
    };

    Ok(CandidateAssessment {
        id: metadata.id,
        name: metadata.name,
        accepted,
        refrigerant_evidence,
        domain_status,
        violations,
        near_boundaries,
        rejections,
        rank_explanation,
        boundary_score,
        validation_rank: u8::from(metadata.evidence.validation.is_some()),
    })
}

/// Selects and ranks candidates deterministically.
///
/// Extrapolated candidates are considered only when no accepted in-domain or
/// near-boundary candidate exists.
pub fn select_correlation(
    candidates: &[CorrelationMetadata],
    context: &SelectionContext,
) -> Result<SelectionOutcome, CorrelationSelectionError> {
    context.operating_point.validate()?;
    let mut assessments = candidates
        .iter()
        .map(|metadata| assess_candidate(metadata, context))
        .collect::<Result<Vec<_>, _>>()?;

    assessments.sort_by(compare_assessments);
    let selected = assessments
        .iter()
        .find(|assessment| assessment.accepted)
        .map(|assessment| assessment.id)
        .ok_or_else(|| CorrelationSelectionError::NoCompatibleCandidates {
            assessments: assessments.clone(),
        })?;
    let selected_assessment = assessments
        .iter()
        .find(|assessment| assessment.id == selected)
        .ok_or(CorrelationSelectionError::MissingEvaluator { id: selected })?;
    let scientifically_tied = assessments.iter().any(|assessment| {
        assessment.id != selected
            && assessment.accepted
            && compare_scientific_assessments(selected_assessment, assessment) == Ordering::Equal
    });
    let mut decision_reasons = vec![if scientifically_tied {
        format!(
            "{} selected by stable CorrelationId ordering as a deterministic, non-scientific tie-break among scientifically tied candidates",
            selected.as_str()
        )
    } else {
        format!(
            "{} selected as the strongest structurally compatible candidate",
            selected.as_str()
        )
    }];
    if selected_assessment
        .domain_status
        .is_some_and(|status| status == DomainStatus::Extrapolated)
    {
        decision_reasons.push(
            "no accepted in-domain candidate exists; extrapolation is the last resort".to_string(),
        );
    } else {
        decision_reasons.push(
            "an in-domain candidate exists, so every extrapolated candidate ranks lower"
                .to_string(),
        );
    }
    decision_reasons.push(selected_assessment.rank_explanation.clone());

    Ok(SelectionOutcome {
        selected,
        assessments,
        decision_reasons,
    })
}

fn compare_assessments(left: &CandidateAssessment, right: &CandidateAssessment) -> Ordering {
    compare_scientific_assessments(left, right).then_with(|| left.id.cmp(&right.id))
}

fn compare_scientific_assessments(
    left: &CandidateAssessment,
    right: &CandidateAssessment,
) -> Ordering {
    right
        .accepted
        .cmp(&left.accepted)
        .then_with(|| {
            let left_in_domain = left.domain_status.is_some_and(DomainStatus::is_in_domain);
            let right_in_domain = right.domain_status.is_some_and(DomainStatus::is_in_domain);
            right_in_domain.cmp(&left_in_domain)
        })
        .then_with(|| {
            right
                .refrigerant_evidence
                .rank()
                .cmp(&left.refrigerant_evidence.rank())
        })
        .then_with(|| {
            right
                .domain_status
                .map(DomainStatus::rank)
                .unwrap_or(0)
                .cmp(&left.domain_status.map(DomainStatus::rank).unwrap_or(0))
        })
        .then_with(|| {
            right
                .boundary_score
                .partial_cmp(&left.boundary_score)
                .unwrap_or(Ordering::Equal)
        })
        .then_with(|| right.validation_rank.cmp(&left.validation_rank))
}

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

    const ALL_REGIMES: &[FlowRegime] = &[
        FlowRegime::SinglePhaseLiquid,
        FlowRegime::SinglePhaseVapor,
        FlowRegime::Evaporation,
        FlowRegime::Condensation,
    ];
    const PLATE: &[ExchangerGeometryType] = &[ExchangerGeometryType::BrazedPlate];
    const RE_WIDE: &[BoundedQuantity] = &[BoundedQuantity {
        kind: BoundedQuantityKind::Reynolds,
        min: 100.0,
        max: 1000.0,
        regimes: ALL_REGIMES,
    }];
    const RE_NARROW: &[BoundedQuantity] = &[BoundedQuantity {
        kind: BoundedQuantityKind::Reynolds,
        min: 200.0,
        max: 800.0,
        regimes: ALL_REGIMES,
    }];

    fn metadata(
        id: CorrelationId,
        bounds: &'static [BoundedQuantity],
        geometry: &'static [ExchangerGeometryType],
    ) -> CorrelationMetadata {
        metadata_with_refrigerant(
            id,
            bounds,
            geometry,
            RefrigerantApplicability::FluidAgnostic,
        )
    }

    fn metadata_with_refrigerant(
        id: CorrelationId,
        bounds: &'static [BoundedQuantity],
        geometry: &'static [ExchangerGeometryType],
        refrigerants: RefrigerantApplicability,
    ) -> CorrelationMetadata {
        CorrelationMetadata {
            id,
            name: id.as_str(),
            domain: ApplicabilityDomain {
                purpose: CorrelationPurpose::HeatTransfer,
                geometries: geometry,
                regimes: ALL_REGIMES,
                bounds,
                refrigerants,
            },
            evidence: EvidenceMetadata {
                reference: "existing test metadata",
                validation: None,
            },
        }
    }

    fn context(reynolds: f64) -> SelectionContext {
        SelectionContext {
            purpose: CorrelationPurpose::HeatTransfer,
            geometry: ExchangerGeometryType::BrazedPlate,
            regime: FlowRegime::Evaporation,
            refrigerant: Some("R-test".to_string()),
            operating_point: OperatingPoint {
                reynolds: Some(reynolds),
                ..OperatingPoint::default()
            },
        }
    }

    #[test]
    fn in_domain_candidate_always_beats_extrapolation() {
        let candidates = [
            metadata(CorrelationId::Longo2004, RE_WIDE, PLATE),
            metadata(CorrelationId::Ko2021, RE_NARROW, PLATE),
        ];
        let outcome = select_correlation(&candidates, &context(900.0)).unwrap();
        assert_eq!(outcome.selected, CorrelationId::Longo2004);
        assert_eq!(
            outcome.assessments[1].domain_status,
            Some(DomainStatus::Extrapolated)
        );
    }

    #[test]
    fn extrapolation_is_explicit_and_lists_every_violation() {
        const TWO_BOUNDS: &[BoundedQuantity] = &[
            BoundedQuantity {
                kind: BoundedQuantityKind::Reynolds,
                min: 100.0,
                max: 1000.0,
                regimes: ALL_REGIMES,
            },
            BoundedQuantity {
                kind: BoundedQuantityKind::MassFlux,
                min: 10.0,
                max: 50.0,
                regimes: ALL_REGIMES,
            },
        ];
        let candidate = metadata(CorrelationId::Longo2004, TWO_BOUNDS, PLATE);
        let mut ctx = context(2000.0);
        ctx.operating_point.mass_flux = Some(100.0);
        let outcome = select_correlation(&[candidate], &ctx).unwrap();
        let selected = outcome.selected_assessment().unwrap();
        assert_eq!(selected.domain_status, Some(DomainStatus::Extrapolated));
        assert_eq!(selected.violations.len(), 2);
    }

    #[test]
    fn least_extrapolated_equivalent_candidate_is_selected() {
        let candidates = [
            metadata(CorrelationId::Longo2004, RE_NARROW, PLATE),
            metadata(CorrelationId::Shah2021, RE_WIDE, PLATE),
        ];
        let outcome = select_correlation(&candidates, &context(1100.0)).unwrap();
        assert_eq!(outcome.selected, CorrelationId::Shah2021);
        assert!(outcome
            .selected_assessment()
            .unwrap()
            .rank_explanation
            .contains("Extrapolated"));
    }

    #[test]
    fn wrong_geometry_is_rejected_before_numeric_ranking() {
        const TUBE: &[ExchangerGeometryType] = &[ExchangerGeometryType::SmoothTube];
        let result = select_correlation(
            &[metadata(CorrelationId::Shah1979, RE_WIDE, TUBE)],
            &context(500.0),
        );
        let CorrelationSelectionError::NoCompatibleCandidates { assessments } = result.unwrap_err()
        else {
            panic!("expected structural rejection");
        };
        assert!(matches!(
            assessments[0].rejections.as_slice(),
            [CandidateRejection::WrongGeometry { .. }]
        ));
        assert!(assessments[0].domain_status.is_none());
    }

    #[test]
    fn ranking_is_deterministic() {
        let candidates = [
            metadata(CorrelationId::Ko2021, RE_WIDE, PLATE),
            metadata(CorrelationId::Longo2004, RE_WIDE, PLATE),
        ];
        let first = select_correlation(&candidates, &context(500.0)).unwrap();
        let second = select_correlation(&candidates, &context(500.0)).unwrap();
        assert_eq!(first.selected, second.selected);
        assert_eq!(
            first
                .assessments
                .iter()
                .map(|assessment| assessment.id)
                .collect::<Vec<_>>(),
            second
                .assessments
                .iter()
                .map(|assessment| assessment.id)
                .collect::<Vec<_>>()
        );
        assert!(first.decision_reasons[0].contains("deterministic, non-scientific tie-break"));
        assert!(!first.decision_reasons[0].contains("scientifically strongest"));
    }

    #[test]
    fn invalid_physical_input_is_an_error() {
        let candidate = metadata(CorrelationId::Longo2004, RE_WIDE, PLATE);
        let error = select_correlation(&[candidate], &context(f64::NAN)).unwrap_err();
        assert!(matches!(
            error,
            CorrelationSelectionError::InvalidInput(DomainInputError::InvalidPositive {
                field: "reynolds",
                ..
            })
        ));
    }

    #[test]
    fn boundary_operation_is_explicit() {
        let candidate = metadata(CorrelationId::Longo2004, RE_WIDE, PLATE);
        let outcome = select_correlation(&[candidate], &context(105.0)).unwrap();
        let selected = outcome.selected_assessment().unwrap();
        assert_eq!(selected.domain_status, Some(DomainStatus::NearBoundary));
        assert_eq!(selected.near_boundaries.len(), 1);
    }

    #[test]
    fn zero_lower_bound_uses_interval_span_for_boundary_detection() {
        const QUALITY: &[BoundedQuantity] = &[BoundedQuantity {
            kind: BoundedQuantityKind::Quality,
            min: 0.0,
            max: 1.0,
            regimes: ALL_REGIMES,
        }];
        let candidate = metadata(CorrelationId::Longo2004, QUALITY, PLATE);
        let mut ctx = context(500.0);
        ctx.operating_point.quality = Some(0.01);

        let outcome = select_correlation(&[candidate], &ctx).unwrap();
        let selected = outcome.selected_assessment().unwrap();
        assert_eq!(selected.domain_status, Some(DomainStatus::NearBoundary));
        assert_eq!(selected.near_boundaries[0].normalized_distance, 0.01);
    }

    #[test]
    fn incomplete_refrigerant_evidence_is_retained_and_ranked_lower() {
        let candidates = [
            metadata_with_refrigerant(
                CorrelationId::Ko2021,
                RE_WIDE,
                PLATE,
                RefrigerantApplicability::EvidenceIncomplete,
            ),
            metadata(CorrelationId::Longo2004, RE_WIDE, PLATE),
        ];
        let outcome = select_correlation(&candidates, &context(500.0)).unwrap();
        assert_eq!(outcome.selected, CorrelationId::Longo2004);
        let incomplete = outcome
            .assessments
            .iter()
            .find(|assessment| assessment.id == CorrelationId::Ko2021)
            .unwrap();
        assert_eq!(
            incomplete.refrigerant_evidence,
            RefrigerantEvidenceStatus::EvidenceIncomplete
        );
        assert!(incomplete.rank_explanation.contains("EvidenceIncomplete"));
    }

    #[test]
    fn verified_list_requires_a_refrigerant_identifier() {
        const VERIFIED: &[&str] = &["R134a"];
        let candidate = metadata_with_refrigerant(
            CorrelationId::Longo2004,
            RE_WIDE,
            PLATE,
            RefrigerantApplicability::VerifiedList(VERIFIED),
        );
        let mut ctx = context(500.0);
        ctx.refrigerant = None;

        let error = select_correlation(&[candidate], &ctx).unwrap_err();
        assert_eq!(error.assessments().unwrap().len(), 1);
        let CorrelationSelectionError::NoCompatibleCandidates { assessments } = error else {
            panic!("expected missing refrigerant evidence rejection");
        };
        assert!(!assessments[0].accepted);
        assert_eq!(
            assessments[0].rejections,
            vec![CandidateRejection::MissingRefrigerantIdentifier]
        );
    }

    #[test]
    fn verified_refrigerant_comparison_trims_and_ignores_case() {
        const VERIFIED: &[&str] = &["R134a"];
        let candidate = metadata_with_refrigerant(
            CorrelationId::Longo2004,
            RE_WIDE,
            PLATE,
            RefrigerantApplicability::VerifiedList(VERIFIED),
        );
        let mut ctx = context(500.0);
        ctx.refrigerant = Some("  r134A  ".to_string());

        let outcome = select_correlation(&[candidate], &ctx).unwrap();
        assert_eq!(
            outcome.selected_assessment().unwrap().refrigerant_evidence,
            RefrigerantEvidenceStatus::Verified
        );
    }

    #[test]
    fn registry_does_not_claim_unverified_fluid_agnostic_coverage() {
        // Geometry-based single-phase BPHX channel DP may declare FluidAgnostic;
        // HTC / two-phase entries must stay EvidenceIncomplete until verified.
        assert!(registered_correlations().iter().all(|metadata| {
            matches!(
                metadata.domain.refrigerants,
                RefrigerantApplicability::EvidenceIncomplete
            ) || matches!(
                metadata.id,
                CorrelationId::SimplifiedChannel | CorrelationId::Martin1996
            )
        }));
    }

    #[test]
    fn central_registry_contains_heat_transfer_and_pressure_drop_entries() {
        let registered = registered_correlations();
        assert_eq!(registered.len(), 18);
        assert!(registered
            .iter()
            .any(|entry| entry.id == CorrelationId::Friedel1979
                && entry.domain.purpose == CorrelationPurpose::PressureDrop));
        assert!(registered.iter().any(|entry| {
            entry.id == CorrelationId::MullerSteinhagenHeck1986
                && entry.domain.purpose == CorrelationPurpose::PressureDrop
        }));
        assert!(registered.iter().any(|entry| {
            entry.id == CorrelationId::SimplifiedChannel
                && entry.domain.purpose == CorrelationPurpose::PressureDrop
        }));
        assert!(registered.iter().any(|entry| {
            entry.id == CorrelationId::Martin1996
                && entry.domain.purpose == CorrelationPurpose::PressureDrop
        }));
        assert!(registered.iter().any(|entry| {
            entry.id == CorrelationId::AmalfiThome2016
                && entry.domain.purpose == CorrelationPurpose::PressureDrop
        }));
        assert!(registered
            .iter()
            .any(|entry| entry.id == CorrelationId::Cooper1984));
        assert!(
            registered
                .iter()
                .filter(|entry| entry.domain.purpose == CorrelationPurpose::HeatTransfer)
                .count()
                > 1
        );
    }
}
