//! Cohort resolver: evaluates user metadata against all registered cohort predicates.
//!
//! Results are cached per user ID for fast repeated lookups. The cache is a
//! bounded LRU keyed by user ID; it is invalidated per-user when their metadata
//! changes and bounded in size so a high-cardinality user population cannot grow
//! it without limit (the cache is a pure accelerator — a miss is recomputed, so
//! eviction is always safe).

use std::{
    collections::HashMap,
    num::NonZeroUsize,
    sync::{Arc, Mutex, PoisonError},
};

use lru::LruCache;

use super::types::{CohortName, CohortRegistry, Predicate};

/// Default membership-cache capacity (number of distinct user IDs retained).
///
/// Sized to comfortably hold an active working set of users while keeping the
/// worst-case memory bound finite: each entry is a `u64` key plus a small
/// `Vec<Arc<str>>` of matched cohort names.
pub const DEFAULT_RESOLVER_CACHE_CAPACITY: usize = 100_000;

/// Evaluates user metadata against all registered `CohortDef`s and caches
/// the resulting cohort memberships per user.
///
/// The cache is a bounded `LruCache`: when it is full, the least-recently
/// resolved user is evicted. Eviction is loss-free — the next `resolve` for an
/// evicted user simply recomputes the membership from the registry — so the
/// bound trades a little recompute for a hard memory ceiling.
pub struct CohortResolver {
    registry: Arc<CohortRegistry>,
    cache: Mutex<LruCache<u64, Vec<CohortName>>>,
}

impl CohortResolver {
    /// Create a resolver backed by the given registry with the default cache
    /// capacity ([`DEFAULT_RESOLVER_CACHE_CAPACITY`]).
    #[must_use]
    pub fn new(registry: Arc<CohortRegistry>) -> Self {
        Self::with_cache_capacity(registry, DEFAULT_RESOLVER_CACHE_CAPACITY)
    }

    /// Create a resolver with an explicit membership-cache capacity.
    ///
    /// A `capacity` of 0 is clamped to 1 so the cache is always usable; the LRU
    /// will then hold at most the most-recently-resolved user.
    #[must_use]
    pub fn with_cache_capacity(registry: Arc<CohortRegistry>, capacity: usize) -> Self {
        // Clamp to >= 1: LruCache requires a non-zero capacity, and a 0-capacity
        // cache would be a footgun (every resolve recomputes). Clamping is the
        // least-surprising behavior for a pure accelerator.
        let cap = NonZeroUsize::new(capacity.max(1)).unwrap_or(NonZeroUsize::MIN);
        Self {
            registry,
            cache: Mutex::new(LruCache::new(cap)),
        }
    }

    /// Resolve which cohorts a user belongs to.
    ///
    /// Returns cached results if available (refreshing the entry's LRU
    /// recency). Otherwise evaluates all predicates against the user's metadata,
    /// caches the result, and returns it.
    ///
    /// Never panics on a poisoned cache mutex: the cache is a pure accelerator
    /// with short, panic-free critical sections, so on poison we recover the
    /// inner guard via [`PoisonError::into_inner`] and continue — a prior panic
    /// elsewhere degrades to a recompute, never a write-path-wide outage.
    #[must_use]
    pub fn resolve(&self, user_id: u64, metadata: &HashMap<String, String>) -> Vec<CohortName> {
        // Fast path: return cached result, bumping its LRU recency.
        {
            let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
            if let Some(cached) = cache.get(&user_id) {
                return cached.clone();
            }
        }

        // Slow path: evaluate all predicates without holding the cache lock.
        let mut memberships = Vec::new();
        for entry in self.registry.names() {
            if let Some(def_ref) = self.registry.get(&entry)
                && eval_predicate(&def_ref.predicate, metadata)
            {
                memberships.push(entry.clone());
            }
        }

        // Insert under the lock; a concurrent resolve for the same user is
        // harmless (both compute the same membership for the same metadata).
        let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
        cache.put(user_id, memberships.clone());
        memberships
    }

    /// Invalidate cached cohort memberships for a user.
    ///
    /// Call this when user metadata is updated so the next `resolve()` call
    /// re-evaluates all predicates.
    ///
    /// Never panics on a poisoned cache mutex: it recovers the inner guard via
    /// [`PoisonError::into_inner`] (the cache is a pure accelerator).
    pub fn invalidate(&self, user_id: u64) {
        let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
        cache.pop(&user_id);
    }

    /// Invalidate every cached membership at once.
    ///
    /// Call this whenever the cohort *registry* changes (a cohort is defined or
    /// removed at runtime): every cached entry was computed against the previous
    /// cohort set and is now stale. Per-user `invalidate` is insufficient — a
    /// newly-defined cohort affects all users whose membership was already
    /// resolved, and there is no cheap way to know which of them now match the
    /// new predicate without re-resolving them.
    ///
    /// A full clear is correct and cheap precisely because the cache is a pure
    /// accelerator: every dropped entry is recomputed from the registry on the
    /// next `resolve()`. The alternative (leaving the cache intact) silently
    /// drops attribution for the new cohort until each user is LRU-evicted or
    /// their metadata is rewritten — a no-error, no-log wrong answer.
    ///
    /// Never panics on a poisoned cache mutex: it recovers the inner guard via
    /// [`PoisonError::into_inner`] (the cache is a pure accelerator).
    pub fn invalidate_all(&self) {
        let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
        cache.clear();
    }

    /// Number of cached user resolutions (for diagnostics).
    ///
    /// Never panics on a poisoned cache mutex: it recovers the inner guard via
    /// [`PoisonError::into_inner`].
    #[must_use]
    pub fn cache_len(&self) -> usize {
        self.cache
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .len()
    }

    /// Maximum number of user resolutions the cache will retain.
    #[must_use]
    pub fn cache_capacity(&self) -> usize {
        self.cache
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .cap()
            .get()
    }
}

impl std::fmt::Debug for CohortResolver {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Use try_lock so Debug formatting never blocks or panics on a poisoned
        // or already-held lock; report None if we cannot acquire it.
        let cache_len: Option<usize> = self.cache.try_lock().ok().map(|cache| cache.len());
        f.debug_struct("CohortResolver")
            .field("cache_len", &cache_len)
            .finish_non_exhaustive()
    }
}

/// Evaluate a predicate against user metadata.
///
/// Returns `true` if the user's metadata satisfies the predicate.
fn eval_predicate(pred: &Predicate, metadata: &HashMap<String, String>) -> bool {
    match pred {
        Predicate::Eq { field, value } => metadata.get(field) == Some(value),
        Predicate::Any { field, values } => metadata.get(field).is_some_and(|v| values.contains(v)),
        Predicate::Range { field, lo, hi } => metadata
            .get(field)
            .and_then(|v| v.parse::<f64>().ok())
            .is_some_and(|n| n >= *lo && n <= *hi),
        Predicate::And(preds) => preds.iter().all(|p| eval_predicate(p, metadata)),
        Predicate::Or(preds) => preds.iter().any(|p| eval_predicate(p, metadata)),
    }
}

/// Public predicate evaluator for ad-hoc cohort queries.
///
/// Used by the query executor when `cohort_predicate` is set but no
/// materialized cohort exists.
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn evaluate_predicate(pred: &Predicate, metadata: &HashMap<String, String>) -> bool {
    eval_predicate(pred, metadata)
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::cohort::types::CohortDef;

    fn make_registry() -> Arc<CohortRegistry> {
        let reg = CohortRegistry::new();
        reg.define(CohortDef {
            name: "tech_en".into(),
            predicate: Predicate::And(vec![
                Predicate::Eq {
                    field: "locale".into(),
                    value: "en".into(),
                },
                Predicate::Eq {
                    field: "primary_category".into(),
                    value: "tech".into(),
                },
            ]),
        })
        .unwrap();
        reg.define(CohortDef {
            name: "sports".into(),
            predicate: Predicate::Eq {
                field: "primary_category".into(),
                value: "sports".into(),
            },
        })
        .unwrap();
        Arc::new(reg)
    }

    fn tech_user_metadata() -> HashMap<String, String> {
        let mut m = HashMap::new();
        m.insert("locale".into(), "en".into());
        m.insert("primary_category".into(), "tech".into());
        m
    }

    fn sports_user_metadata() -> HashMap<String, String> {
        let mut m = HashMap::new();
        m.insert("locale".into(), "en".into());
        m.insert("primary_category".into(), "sports".into());
        m
    }

    #[test]
    fn resolve_tech_user() {
        let reg = make_registry();
        let resolver = CohortResolver::new(reg);
        let memberships = resolver.resolve(1, &tech_user_metadata());
        let names: Vec<String> = memberships.iter().map(|n| n.to_string()).collect();
        assert!(names.contains(&"tech_en".to_string()));
        assert!(!names.contains(&"sports".to_string()));
    }

    #[test]
    fn resolve_sports_user() {
        let reg = make_registry();
        let resolver = CohortResolver::new(reg);
        let memberships = resolver.resolve(2, &sports_user_metadata());
        let names: Vec<String> = memberships.iter().map(|n| n.to_string()).collect();
        assert!(!names.contains(&"tech_en".to_string()));
        assert!(names.contains(&"sports".to_string()));
    }

    #[test]
    fn resolve_caches_result() {
        let reg = make_registry();
        let resolver = CohortResolver::new(reg);

        let m1 = resolver.resolve(1, &tech_user_metadata());
        assert_eq!(resolver.cache_len(), 1);

        // Second call should return cached result (we cannot directly observe
        // caching, but we can verify the result is identical).
        let m2 = resolver.resolve(1, &tech_user_metadata());
        assert_eq!(m1.len(), m2.len());
    }

    #[test]
    fn invalidate_clears_cache() {
        let reg = make_registry();
        let resolver = CohortResolver::new(reg);

        let _ = resolver.resolve(1, &tech_user_metadata());
        assert_eq!(resolver.cache_len(), 1);

        resolver.invalidate(1);
        assert_eq!(resolver.cache_len(), 0);
    }

    #[test]
    fn invalidate_all_clears_every_entry() {
        let reg = make_registry();
        let resolver = CohortResolver::new(reg);

        // Populate several distinct users.
        let _ = resolver.resolve(1, &tech_user_metadata());
        let _ = resolver.resolve(2, &sports_user_metadata());
        let _ = resolver.resolve(3, &tech_user_metadata());
        assert_eq!(resolver.cache_len(), 3);

        // A registry change invalidates every cached membership at once.
        resolver.invalidate_all();
        assert_eq!(resolver.cache_len(), 0);

        // The cache still works after a full clear: the next resolve recomputes
        // and re-caches, since the cache is a pure accelerator.
        let memberships = resolver.resolve(1, &tech_user_metadata());
        assert!(memberships.iter().any(|n| n.to_string() == "tech_en"));
        assert_eq!(resolver.cache_len(), 1);
    }

    #[test]
    fn cache_is_bounded_and_evicts_lru() {
        let reg = make_registry();
        let resolver = CohortResolver::with_cache_capacity(reg, 2);
        assert_eq!(resolver.cache_capacity(), 2);

        let meta = tech_user_metadata();
        // Fill to capacity with users 1 and 2.
        let _ = resolver.resolve(1, &meta);
        let _ = resolver.resolve(2, &meta);
        assert_eq!(resolver.cache_len(), 2);

        // Touch user 1 so it becomes most-recently-used.
        let _ = resolver.resolve(1, &meta);

        // Resolving user 3 must evict the least-recently-used (user 2),
        // keeping the cache at its bound rather than growing unbounded.
        let _ = resolver.resolve(3, &meta);
        assert_eq!(resolver.cache_len(), 2, "cache must stay at its capacity");
    }

    #[test]
    fn zero_capacity_is_clamped_to_one() {
        let reg = make_registry();
        let resolver = CohortResolver::with_cache_capacity(reg, 0);
        assert_eq!(resolver.cache_capacity(), 1);

        // A capacity-1 cache still functions: resolves succeed and the result
        // is correct even though only one entry is retained at a time.
        let memberships = resolver.resolve(1, &tech_user_metadata());
        assert!(memberships.iter().any(|n| n.to_string() == "tech_en"));
        assert_eq!(resolver.cache_len(), 1);
    }

    #[test]
    fn eval_eq_predicate() {
        let pred = Predicate::Eq {
            field: "locale".into(),
            value: "en".into(),
        };
        let mut meta = HashMap::new();
        meta.insert("locale".into(), "en".into());
        assert!(eval_predicate(&pred, &meta));
        meta.insert("locale".into(), "fr".into());
        assert!(!eval_predicate(&pred, &meta));
    }

    #[test]
    fn eval_any_predicate() {
        let pred = Predicate::Any {
            field: "locale".into(),
            values: vec!["en".into(), "fr".into()],
        };
        let mut meta = HashMap::new();
        meta.insert("locale".into(), "en".into());
        assert!(eval_predicate(&pred, &meta));
        meta.insert("locale".into(), "de".into());
        assert!(!eval_predicate(&pred, &meta));
    }

    #[test]
    fn eval_range_predicate() {
        let pred = Predicate::Range {
            field: "age".into(),
            lo: 18.0,
            hi: 35.0,
        };
        let mut meta = HashMap::new();
        meta.insert("age".into(), "25".into());
        assert!(eval_predicate(&pred, &meta));
        meta.insert("age".into(), "40".into());
        assert!(!eval_predicate(&pred, &meta));
        meta.insert("age".into(), "not_a_number".into());
        assert!(!eval_predicate(&pred, &meta));
    }

    #[test]
    fn eval_and_predicate() {
        let pred = Predicate::And(vec![
            Predicate::Eq {
                field: "a".into(),
                value: "1".into(),
            },
            Predicate::Eq {
                field: "b".into(),
                value: "2".into(),
            },
        ]);
        let mut meta = HashMap::new();
        meta.insert("a".into(), "1".into());
        meta.insert("b".into(), "2".into());
        assert!(eval_predicate(&pred, &meta));
        meta.insert("b".into(), "3".into());
        assert!(!eval_predicate(&pred, &meta));
    }

    #[test]
    fn eval_or_predicate() {
        let pred = Predicate::Or(vec![
            Predicate::Eq {
                field: "x".into(),
                value: "1".into(),
            },
            Predicate::Eq {
                field: "x".into(),
                value: "2".into(),
            },
        ]);
        let mut meta = HashMap::new();
        meta.insert("x".into(), "1".into());
        assert!(eval_predicate(&pred, &meta));
        meta.insert("x".into(), "3".into());
        assert!(!eval_predicate(&pred, &meta));
    }

    #[test]
    fn eval_missing_field_is_false() {
        let pred = Predicate::Eq {
            field: "missing".into(),
            value: "value".into(),
        };
        let meta = HashMap::new();
        assert!(!eval_predicate(&pred, &meta));
    }

    #[test]
    fn resolver_is_send_and_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<CohortResolver>();
    }

    #[test]
    fn poisoned_cache_recovers_instead_of_panicking() {
        // A panic while holding the cache lock poisons the Mutex. The resolver
        // must recover the inner guard (PoisonError::into_inner) rather than
        // cascade the panic into every subsequent resolve/invalidate, since the
        // cache is a pure accelerator with nothing to protect on poison.
        let reg = make_registry();
        let resolver = Arc::new(CohortResolver::new(reg));
        let r2 = Arc::clone(&resolver);

        // Poison the lock by panicking while holding it on another thread.
        let poison = std::thread::spawn(move || {
            let _guard = r2.cache.lock().unwrap();
            panic!("intentional poison");
        });
        assert!(poison.join().is_err(), "poison thread should have panicked");

        // Every cache-touching method must still work after poison.
        let memberships = resolver.resolve(1, &tech_user_metadata());
        assert!(memberships.iter().any(|n| n.to_string() == "tech_en"));
        let _ = resolver.cache_len();
        let _ = resolver.cache_capacity();
        resolver.invalidate(1);
        resolver.invalidate_all();
        // A subsequent resolve recomputes cleanly after recovery.
        let again = resolver.resolve(2, &sports_user_metadata());
        assert!(again.iter().any(|n| n.to_string() == "sports"));
    }

    #[test]
    fn concurrent_invalidate_all_during_slow_path_insert_is_eventually_consistent() {
        // The slow path evaluates predicates WITHOUT the lock, then inserts
        // under the lock. An invalidate_all() racing between the evaluation and
        // the insert can leave a freshly-computed (now-stale-vs-registry) entry
        // in the cache. The cache is a pure accelerator, so the contract is
        // eventual consistency: a fresh resolve always recomputes the correct
        // membership for the current metadata, never panics, and the cache
        // never grows beyond its bound. This exercises that window under
        // contention and asserts the result stays correct and the structure
        // sound.
        let reg = make_registry();
        let resolver = Arc::new(CohortResolver::new(reg));

        let n_threads: usize = 8;
        let iters: usize = 200;
        let mut handles = Vec::with_capacity(n_threads);
        for t in 0..n_threads {
            let r = Arc::clone(&resolver);
            handles.push(std::thread::spawn(move || {
                let meta = tech_user_metadata();
                for i in 0..iters {
                    if (t + i) % 3 == 0 {
                        // Interleave full invalidations with resolves to drive
                        // the invalidate-all / slow-path-insert race.
                        r.invalidate_all();
                    } else {
                        let user = u64::try_from(i % 16).unwrap_or(0);
                        let m = r.resolve(user, &meta);
                        // Whatever lands, it must be the correct membership for
                        // the supplied metadata — a stale insert can only ever
                        // re-cache the same (correct) value here because the
                        // registry is not mutated during the race.
                        assert!(m.iter().any(|n| n.to_string() == "tech_en"));
                    }
                }
            }));
        }
        for h in handles {
            assert!(h.join().is_ok(), "no thread should panic in the race");
        }

        // After the storm, a fresh resolve still returns the correct membership
        // and the cache remains within its bound (pure-accelerator invariant).
        let m = resolver.resolve(99, &sports_user_metadata());
        assert!(m.iter().any(|n| n.to_string() == "sports"));
        assert!(resolver.cache_len() <= resolver.cache_capacity());
    }
}
