//! Saturation pressure domain detection for robust two-phase property queries.
//!
//! During Newton iterations the solver may probe states whose pressure lies
//! outside the fluid's saturation domain (below the triple point or above the
//! critical point). Saturated-property queries (`FluidState::from_px`) fail
//! there; silently falling back to a zero or quadratic ΔP makes the residual
//! **discontinuous**, which breaks line search (observed on DX evaporators
//! with tube MSH at full EXV opening: Newton stalls, then Picard explodes).
//!
//! Clamping the *query* pressure into the detected saturation domain keeps
//! every residual defined and smooth along the whole search path. The
//! converged solution still lies inside the true domain — the clamp only
//! regularizes intermediate iterates, exactly like the critical-point damping
//! already used by the fluids crate.

use entropyk_core::Pressure;
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};

/// Relative margin kept below the critical pressure for saturated queries.
const CRITICAL_MARGIN: f64 = 1e-6;
/// Absolute lower bound probed for the saturation domain [Pa].
const ABSOLUTE_PROBE_MIN_PA: f64 = 1.0;
/// Bisection iterations for the lower-domain search (1 Pa → P_crit needs ~42).
const BISECTION_ITERS: usize = 48;

/// Per-fluid cache of detected saturation domains. `None` means "detection
/// failed — do not clamp, keep the caller's legacy behavior".
static DOMAIN_CACHE: OnceLock<RwLock<HashMap<String, Option<(f64, f64)>>>> = OnceLock::new();

fn cache() -> &'static RwLock<HashMap<String, Option<(f64, f64)>>> {
    DOMAIN_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}

/// Detected saturation pressure domain `[P_min, P_max]` [Pa] for `fluid`,
/// probed once through `backend` and cached.
///
/// * `P_max` is the critical pressure from
///   [`FluidBackend::critical_point`] minus a small relative margin.
/// * `P_min` is found by bisection: the lowest pressure at which a saturated
///   density query succeeds (≈ triple-point pressure for real fluids).
///
/// Returns `None` when the fluid has no usable saturation domain on this
/// backend (incompressible fluids, unavailable critical point, or a backend
/// that fails saturated queries everywhere).
pub fn saturation_pressure_domain(
    backend: &Arc<dyn FluidBackend>,
    fluid: &str,
) -> Option<(f64, f64)> {
    if let Some(entry) = cache().read().ok().and_then(|map| map.get(fluid).copied()) {
        return entry;
    }
    let detected = detect_domain(backend, fluid);
    if let Ok(mut map) = cache().write() {
        map.insert(fluid.to_string(), detected);
    }
    detected
}

/// Clamps `p_pa` into the detected saturation domain of `fluid`.
///
/// Returns `Some(clamped_pressure)` when the domain is known, `None` when it
/// could not be detected — callers must then keep their current behavior.
/// Inside the domain this is the identity, so in-domain runs are unaffected.
pub fn clamp_to_saturation_domain(
    backend: &Arc<dyn FluidBackend>,
    fluid: &str,
    p_pa: f64,
) -> Option<f64> {
    let (p_min, p_max) = saturation_pressure_domain(backend, fluid)?;
    if !p_pa.is_finite() {
        // Non-finite probe pressures (NaN/±inf from a blown-up iterate) are
        // pulled to the nearest bound so residual evaluation stays defined.
        return Some(if p_pa.is_sign_negative() {
            p_min
        } else {
            p_max
        });
    }
    Some(p_pa.clamp(p_min, p_max))
}

/// Invalidates the cached domain for one fluid (test hook).
#[cfg(test)]
pub fn clear_cached_domain(fluid: &str) {
    if let Ok(mut map) = cache().write() {
        map.remove(fluid);
    }
}

fn detect_domain(backend: &Arc<dyn FluidBackend>, fluid: &str) -> Option<(f64, f64)> {
    let fluid_id = FluidId::new(fluid);
    let critical = backend.critical_point(fluid_id.clone()).ok()?;
    let p_crit = critical.pressure.to_pascals();
    if !p_crit.is_finite() || p_crit <= ABSOLUTE_PROBE_MIN_PA {
        return None;
    }

    let probe_ok = |p_pa: f64| -> bool {
        backend
            .property(
                fluid_id.clone(),
                Property::Density,
                FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)),
            )
            .map(|rho| rho.is_finite() && rho > 0.0)
            .unwrap_or(false)
    };

    // Phase 1: locate ONE pressure inside the saturated domain. Backends may
    // tabulate saturation strictly below the critical pressure, so probe a
    // ladder of candidates rather than assuming P_crit itself works.
    let anchor = [
        p_crit * (1.0 - CRITICAL_MARGIN),
        p_crit * 0.5,
        p_crit * 0.1,
        p_crit * 0.05,
        p_crit * 0.01,
        1.0e5,
        1.0e4,
        1.0e3,
    ]
    .into_iter()
    .find(|&p| p > ABSOLUTE_PROBE_MIN_PA && probe_ok(p))?;

    // Phase 2: bisect downward for P_min (lo fails, hi succeeds).
    let p_min = if probe_ok(ABSOLUTE_PROBE_MIN_PA) {
        ABSOLUTE_PROBE_MIN_PA
    } else {
        let mut lo = ABSOLUTE_PROBE_MIN_PA;
        let mut hi = anchor;
        for _ in 0..BISECTION_ITERS {
            let mid = (lo * hi).sqrt(); // geometric: domain spans decades
            if probe_ok(mid) {
                hi = mid;
            } else {
                lo = mid;
            }
        }
        hi
    };

    // Phase 3: bisect upward for P_max (lo succeeds, hi fails or is P_crit).
    let p_max = {
        let mut lo = anchor;
        let mut hi = p_crit;
        if probe_ok(p_crit * (1.0 - CRITICAL_MARGIN)) {
            p_crit * (1.0 - CRITICAL_MARGIN)
        } else {
            for _ in 0..BISECTION_ITERS {
                let mid = (lo * hi).sqrt();
                if probe_ok(mid) {
                    lo = mid;
                } else {
                    hi = mid;
                }
            }
            lo
        }
    };

    if p_min >= p_max {
        return None;
    }
    Some((p_min, p_max))
}

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

    #[test]
    fn test_domain_detected_for_r134a_on_test_backend() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        clear_cached_domain("R134a");
        let (p_min, p_max) = saturation_pressure_domain(&backend, "R134a")
            .expect("R134a saturation domain must be detectable");
        // The detected domain must match the backend's actual saturation
        // coverage (the TestBackend table ends around 13 bar, below the
        // R134a critical pressure — detection must not assume P_crit works).
        assert!(p_max > 1.0e5, "p_max={p_max}");
        assert!(p_min >= ABSOLUTE_PROBE_MIN_PA, "p_min={p_min}");
        assert!(p_min < p_max);
    }

    #[test]
    fn test_clamp_is_identity_inside_domain() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        clear_cached_domain("R134a");
        let p = 5.0e5; // 5 bar, inside R134a domain
        let clamped = clamp_to_saturation_domain(&backend, "R134a", p).unwrap();
        assert_eq!(clamped, p);
    }

    #[test]
    fn test_clamp_pulls_extreme_pressures_into_domain() {
        let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
        clear_cached_domain("R134a");
        let (p_min, p_max) = saturation_pressure_domain(&backend, "R134a").unwrap();
        assert_eq!(
            clamp_to_saturation_domain(&backend, "R134a", 1e-9).unwrap(),
            p_min
        );
        assert_eq!(
            clamp_to_saturation_domain(&backend, "R134a", 1e12).unwrap(),
            p_max
        );
        assert_eq!(
            clamp_to_saturation_domain(&backend, "R134a", f64::NEG_INFINITY).unwrap(),
            p_min
        );
        assert_eq!(
            clamp_to_saturation_domain(&backend, "R134a", f64::NAN).unwrap(),
            p_max
        );
    }
}
