//! Cached backend wrapper for fluid property queries.
//!
//! Wraps any `FluidBackend` with a thread-local LRU cache to avoid
//! redundant calculations. No mutex contention; zero allocation on cache hit.

use crate::backend::FluidBackend;
use crate::cache::{cache_clear, cache_get, cache_insert};
use crate::errors::FluidResult;
use crate::types::{CriticalPoint, FluidId, FluidState, Phase, Property};
use std::sync::atomic::{AtomicUsize, Ordering};

static NEXT_BACKEND_ID: AtomicUsize = AtomicUsize::new(0);

/// Backend wrapper that caches property queries in a thread-local LRU cache.
///
/// Wraps any `FluidBackend` and caches successful property() results.
/// Other trait methods (critical_point, phase, etc.) delegate to the inner backend
/// without caching, as they are typically called less frequently.
///
/// # Example
///
/// ```
/// use entropyk_fluids::{CachedBackend, FluidBackend, FluidId, Property, FluidState, TestBackend};
/// use entropyk_core::{Pressure, Temperature};
///
/// let inner = TestBackend::new();
/// let cached = CachedBackend::new(inner);
///
/// let state = FluidState::from_pt(
///     Pressure::from_bar(1.0),
///     Temperature::from_celsius(25.0),
/// );
///
/// let v1 = cached.property(FluidId::new("R134a"), Property::Density, state.clone()).unwrap();
/// let v2 = cached.property(FluidId::new("R134a"), Property::Density, state).unwrap();
/// assert_eq!(v1, v2); // Second call served from cache
/// ```
pub struct CachedBackend<B: FluidBackend> {
    backend_id: usize,
    inner: B,
}

impl<B: FluidBackend> CachedBackend<B> {
    /// Create a new cached backend wrapping the given backend.
    pub fn new(inner: B) -> Self {
        let backend_id = NEXT_BACKEND_ID.fetch_add(1, Ordering::Relaxed);
        CachedBackend { backend_id, inner }
    }

    /// Clear the thread-local cache. Call at solver iteration boundaries if needed.
    ///
    /// **Note:** This clears the cache for *all* `CachedBackend` instances on the current
    /// thread, since they share one thread-local cache. If you need per-backend invalidation,
    /// use separate threads or a different caching strategy.
    pub fn clear_cache(&self) {
        cache_clear();
    }

    /// Get a reference to the inner backend.
    pub fn inner(&self) -> &B {
        &self.inner
    }
}

impl<B: FluidBackend> FluidBackend for CachedBackend<B> {
    fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
        if let Some(v) = cache_get(self.backend_id, &fluid, property, &state) {
            return Ok(v);
        }
        let v = self
            .inner
            .property(fluid.clone(), property, state.clone())?;
        // SAFETY (Story 0.7): never cache non-finite values. A transient
        // line-search probe can legitimately return inf (a domain edge); if
        // cached, it would later be re-served at a quantized-near state where
        // the true value is finite — poisoning the solve (2026-07-19 incident).
        if v.is_finite() {
            cache_insert(self.backend_id, &fluid, property, &state, v);
        }
        Ok(v)
    }

    fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
        self.inner.critical_point(fluid)
    }

    fn is_fluid_available(&self, fluid: &FluidId) -> bool {
        self.inner.is_fluid_available(fluid)
    }

    fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
        self.inner.phase(fluid, state)
    }

    fn list_fluids(&self) -> Vec<FluidId> {
        self.inner.list_fluids()
    }

    fn full_state(
        &self,
        fluid: FluidId,
        p: entropyk_core::Pressure,
        h: entropyk_core::Enthalpy,
    ) -> FluidResult<crate::types::ThermoState> {
        self.inner.full_state(fluid, p, h)
    }

    // Saturation/mixture helpers MUST delegate too: the trait's default impls
    // return `UnsupportedProperty`, which silently degrades seeds through the
    // wrapper (2026-07-19 seed-fallback incident).
    fn saturation_pressure_t(&self, fluid: FluidId, t_k: f64) -> FluidResult<f64> {
        self.inner.saturation_pressure_t(fluid, t_k)
    }

    fn saturation_enthalpy_t(&self, fluid: FluidId, t_k: f64, quality: f64) -> FluidResult<f64> {
        self.inner.saturation_enthalpy_t(fluid, t_k, quality)
    }

    fn bubble_point(
        &self,
        pressure: entropyk_core::Pressure,
        mixture: &crate::mixture::Mixture,
    ) -> FluidResult<entropyk_core::Temperature> {
        self.inner.bubble_point(pressure, mixture)
    }

    fn dew_point(
        &self,
        pressure: entropyk_core::Pressure,
        mixture: &crate::mixture::Mixture,
    ) -> FluidResult<entropyk_core::Temperature> {
        self.inner.dew_point(pressure, mixture)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_backend::TestBackend;
    use entropyk_core::{Pressure, Temperature};

    #[test]
    fn test_cache_hit_returns_same_value() {
        let inner = TestBackend::new();
        let cached = CachedBackend::new(inner);

        let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));

        let v1 = cached
            .property(FluidId::new("R134a"), Property::Density, state.clone())
            .unwrap();
        let v2 = cached
            .property(FluidId::new("R134a"), Property::Density, state)
            .unwrap();
        assert_eq!(v1, v2);
    }

    #[test]
    fn test_cache_miss_delegates_to_backend() {
        let inner = TestBackend::new();
        let cached = CachedBackend::new(inner);

        let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));

        let v = cached
            .property(FluidId::new("R134a"), Property::Density, state)
            .unwrap();
        assert!(v > 0.0);
    }

    #[test]
    fn test_cache_invalidation() {
        let inner = TestBackend::new();
        let cached = CachedBackend::new(inner);

        let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));

        let _ = cached
            .property(FluidId::new("R134a"), Property::Density, state.clone())
            .unwrap();
        cached.clear_cache();
        // After clear, next query should still work (delegates to backend)
        let v = cached
            .property(FluidId::new("R134a"), Property::Density, state)
            .unwrap();
        assert!(v > 0.0);
    }

    #[test]
    fn test_cached_benchmark_10k_queries() {
        let inner = TestBackend::new();
        let cached = CachedBackend::new(inner);

        let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));

        for _ in 0..10_000 {
            let _ = cached
                .property(FluidId::new("R134a"), Property::Density, state.clone())
                .unwrap();
        }
    }

    #[test]
    fn test_cached_backend_implements_fluid_backend() {
        let inner = TestBackend::new();
        let cached = CachedBackend::new(inner);

        assert!(cached.is_fluid_available(&FluidId::new("R134a")));
        let cp = cached.critical_point(FluidId::new("R134a")).unwrap();
        assert!(cp.temperature_kelvin() > 300.0);
        let fluids = cached.list_fluids();
        assert!(!fluids.is_empty());
    }

    /// Backend returning `Ok(inf)` at one scripted state and a finite value
    /// elsewhere — reproduces the 2026-07-19 cache-poisoning incident.
    struct InfAtExtremeBackend {
        calls: std::sync::atomic::AtomicUsize,
    }

    impl FluidBackend for InfAtExtremeBackend {
        fn property(
            &self,
            _fluid: FluidId,
            _property: Property,
            state: FluidState,
        ) -> FluidResult<f64> {
            self.calls
                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            if let FluidState::PressureEnthalpy(_, h) = state {
                if h.to_joules_per_kg() > 1.0e12 {
                    // Transient probe at a blown-up iterate: inf, as CoolProp
                    // would return at a domain edge.
                    return Ok(f64::INFINITY);
                }
            }
            Ok(4186.0)
        }

        fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
            Err(crate::errors::FluidError::NoCriticalPoint { fluid: fluid.0 })
        }

        fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
            true
        }

        fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<crate::types::Phase> {
            Ok(crate::types::Phase::Liquid)
        }

        fn list_fluids(&self) -> Vec<FluidId> {
            vec![FluidId::new("Water")]
        }

        fn full_state(
            &self,
            fluid: FluidId,
            _p: entropyk_core::Pressure,
            _h: entropyk_core::Enthalpy,
        ) -> FluidResult<crate::types::ThermoState> {
            Err(crate::errors::FluidError::UnsupportedProperty {
                property: format!("full_state for {}", fluid.0),
            })
        }
    }

    #[test]
    fn test_non_finite_values_are_never_cached() {
        let inner = InfAtExtremeBackend {
            calls: std::sync::atomic::AtomicUsize::new(0),
        };
        let cached = CachedBackend::new(inner);

        let extreme = FluidState::from_ph(
            Pressure::from_pascals(2.0e5),
            entropyk_core::Enthalpy::from_joules_per_kg(1.0e13),
        );
        let inf = cached
            .property(FluidId::new("Water"), Property::Cp, extreme)
            .unwrap();
        assert!(inf.is_infinite(), "setup: backend must return inf");

        // Legitimate query at a normal state afterwards: must NOT see the
        // poisoning inf (it must hit the real backend and get 4186.0).
        let normal = FluidState::from_ph(
            Pressure::from_pascals(2.0e5),
            entropyk_core::Enthalpy::from_joules_per_kg(100_000.0),
        );
        let v = cached
            .property(FluidId::new("Water"), Property::Cp, normal.clone())
            .unwrap();
        assert_eq!(v, 4186.0, "poisoning: cached inf was re-served");

        // The finite value IS cached (second identical query → no new call).
        let v2 = cached
            .property(FluidId::new("Water"), Property::Cp, normal)
            .unwrap();
        assert_eq!(v2, 4186.0);
        assert_eq!(
            cached
                .inner
                .calls
                .load(std::sync::atomic::Ordering::Relaxed),
            2,
            "expected 2 backend calls (extreme + normal); the 2nd normal query must be a cache hit"
        );
    }
}
