//! In-memory caches shared by the MCP tool layer.
//!
//! Two process-wide singletons:
//!
//! - [`YAML_CACHE`] — content-hashed inline YAML storage. The caller hands a
//!   YAML body to `cache_yaml` once; subsequent tools accept the returned
//!   `cache:{hex}` handle anywhere a `degree_id` is accepted. Removes the
//!   per-call repaste tax for hosted MCP clients whose filesystem isn't
//!   reachable by the server (`yaml_path` returns ENOENT).
//!
//! - [`ARTIFACT_CACHE`] — small LRU of `AnalysisArtifacts` keyed by the
//!   (yaml-hash, `max_plans`, `include_courses`) tuple. Three sequential
//!   `render_plan_graph` calls on the same YAML now run the plan-generation
//!   pipeline once instead of three times.
//!
//! Both caches live as `LazyLock<Mutex<…>>` statics because the analyze
//! pipeline reaches them from five tool modules (`analyze`, `audit`,
//! `report`, `plan_graph`, `course_detail`). Threading the state through
//! every call site would be invasive for what is effectively a process
//! singleton.

use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant};

use crate::mcp::tools::analyze::AnalysisArtifacts;

/// Prefix that marks an in-memory YAML-cache handle.
///
/// `degree_id` resolution in `resolve_degree_id` matches against this prefix
/// before falling through to the sample registry or the DB lookup.
pub const YAML_CACHE_PREFIX: &str = "cache:";

/// How long a `cache_yaml` handle stays valid.
///
/// 24 h spans a typical multi-session investigation: validate → audit →
/// analyze → iterate on recommendations → regenerate plans without
/// re-pasting the YAML body each time.
pub const YAML_CACHE_TTL: Duration = Duration::from_hours(24);
const ARTIFACT_CACHE_CAPACITY: usize = 4;

/// Entry count past which [`YamlCache::insert`] runs the O(n) expiry sweep.
/// Keeps the common-case write cheap while still bounding cache growth.
const SWEEP_THRESHOLD: usize = 64;

/// Outcome of looking up a `cache:<hash>` handle.
///
/// Distinguishes a handle that was never minted from one that has aged out, so
/// callers can give a precise hint instead of a single "unknown or expired".
pub enum CacheLookup {
    /// Handle is live; carries the body and the remaining TTL.
    Hit(Arc<str>, Duration),
    /// Handle was minted but has passed its TTL.
    Expired,
    /// No such handle was ever minted (typo, or a different server process).
    Unknown,
}

// ============================================================================
// YAML cache
// ============================================================================

/// One cached YAML body with its insertion timestamp.
struct CachedYaml {
    body: Arc<str>,
    inserted_at: Instant,
}

/// Content-hashed YAML cache. Keyed by `cache:{16-hex}` handles.
#[derive(Default)]
pub struct YamlCache {
    entries: HashMap<String, CachedYaml>,
}

impl YamlCache {
    /// Compute the canonical handle for `body`. Deterministic — the same
    /// body always hashes to the same handle, so re-caching is idempotent.
    #[must_use]
    pub fn handle_for(body: &str) -> String {
        let mut hasher = DefaultHasher::new();
        body.hash(&mut hasher);
        format!("{YAML_CACHE_PREFIX}{:016x}", hasher.finish())
    }

    /// Insert a YAML body, returning its handle.
    ///
    /// The expiry sweep is O(n), so we only run it once the cache has grown
    /// past `SWEEP_THRESHOLD` rather than on every write — keeping the write
    /// lock held briefly in the common case (the field report's intermittent
    /// `cache_yaml` timeouts) while still bounding growth under heavy use.
    pub fn insert(&mut self, body: String) -> String {
        if self.entries.len() >= SWEEP_THRESHOLD {
            self.sweep_expired();
        }
        let handle = Self::handle_for(&body);
        self.entries.insert(
            handle.clone(),
            CachedYaml {
                body: Arc::from(body),
                inserted_at: Instant::now(),
            },
        );
        handle
    }

    /// Fetch by handle. Returns the body plus the remaining TTL on success.
    /// Returns `None` if missing or expired. The remaining TTL lets callers
    /// surface "this handle expires in N seconds" so they can pre-emptively
    /// re-cache before it lapses mid-session.
    #[must_use]
    pub fn get(&self, handle: &str) -> Option<(Arc<str>, Duration)> {
        match self.lookup(handle) {
            CacheLookup::Hit(body, remaining) => Some((body, remaining)),
            CacheLookup::Expired | CacheLookup::Unknown => None,
        }
    }

    /// Like [`Self::get`] but distinguishes an aged-out handle from one that
    /// was never minted, so the caller can surface a precise hint.
    #[must_use]
    pub fn lookup(&self, handle: &str) -> CacheLookup {
        let Some(entry) = self.entries.get(handle) else {
            return CacheLookup::Unknown;
        };
        YAML_CACHE_TTL
            .checked_sub(entry.inserted_at.elapsed())
            .map_or(CacheLookup::Expired, |remaining| {
                CacheLookup::Hit(entry.body.clone(), remaining)
            })
    }

    /// Current entry count. Exposed for the `cache_yaml` response so callers
    /// can see the cache state alongside the freshly-issued handle.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether the cache has no entries. Pairs with [`Self::len`].
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    fn sweep_expired(&mut self) {
        let now = Instant::now();
        self.entries
            .retain(|_, e| now.duration_since(e.inserted_at) < YAML_CACHE_TTL);
    }
}

/// Process-wide YAML cache.
pub static YAML_CACHE: LazyLock<Mutex<YamlCache>> =
    LazyLock::new(|| Mutex::new(YamlCache::default()));

// ============================================================================
// Artifact cache
// ============================================================================

/// Composite key — the inputs that uniquely determine an [`AnalysisArtifacts`].
///
/// `include_courses` is canonicalised (sorted) before hashing so different
/// orderings of the same set hit the same cache entry.
type ArtifactKey = u64;

fn make_artifact_key(
    yaml: &str,
    max_plans: Option<usize>,
    include_courses: Option<&[String]>,
    random_seed: Option<u64>,
    analysis_timeout_seconds: Option<u64>,
) -> ArtifactKey {
    let mut hasher = DefaultHasher::new();
    yaml.hash(&mut hasher);
    max_plans.hash(&mut hasher);
    if let Some(courses) = include_courses {
        // Sort before hashing so different orderings of the same set produce
        // the same key — callers shouldn't have to canonicalise upstream.
        let mut sorted: Vec<&str> = courses.iter().map(String::as_str).collect();
        sorted.sort_unstable();
        for entry in &sorted {
            entry.hash(&mut hasher);
        }
    } else {
        // Distinguish None from Some(vec![]) — both legitimate "no constraint" forms.
        b"__none_include_courses__".hash(&mut hasher);
    }
    // The default-seed case folds the yaml-hash back into itself, which is
    // harmless. The explicit-seed case partitions the cache so two callers
    // with different seeds don't share artifacts.
    random_seed.hash(&mut hasher);
    // Distinct deadlines produce distinct artifacts — a caller retrying
    // with a longer timeout shouldn't be served the previously time-
    // truncated entry.
    analysis_timeout_seconds.hash(&mut hasher);
    hasher.finish()
}

struct ArtifactEntry {
    key: ArtifactKey,
    value: Arc<AnalysisArtifacts>,
    last_accessed: Instant,
}

/// Small LRU of analysis artifacts.
///
/// Bounded at `ARTIFACT_CACHE_CAPACITY` to keep memory usage predictable —
/// each entry retains the full `MetricsAggregator`, `School`, `DAG`, and
/// `SelectedPlans` for the run.
#[derive(Default)]
pub struct ArtifactCache {
    entries: Vec<ArtifactEntry>,
}

impl ArtifactCache {
    /// Look up by composite key. Updates the entry's last-accessed timestamp
    /// on hit so the eviction order tracks recency.
    fn get(&mut self, key: ArtifactKey) -> Option<Arc<AnalysisArtifacts>> {
        let idx = self.entries.iter().position(|e| e.key == key)?;
        self.entries[idx].last_accessed = Instant::now();
        Some(self.entries[idx].value.clone())
    }

    /// Insert. Evicts the oldest entry when at capacity.
    fn insert(&mut self, key: ArtifactKey, value: Arc<AnalysisArtifacts>) {
        if self.entries.len() >= ARTIFACT_CACHE_CAPACITY {
            if let Some(oldest_idx) = self
                .entries
                .iter()
                .enumerate()
                .min_by_key(|(_, e)| e.last_accessed)
                .map(|(i, _)| i)
            {
                self.entries.remove(oldest_idx);
            }
        }
        self.entries.push(ArtifactEntry {
            key,
            value,
            last_accessed: Instant::now(),
        });
    }

    /// Current entry count. Exposed for diagnostics.
    #[must_use]
    pub const fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether the cache has no entries. Pairs with [`Self::len`].
    #[must_use]
    pub const fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

/// Process-wide artifact cache.
pub static ARTIFACT_CACHE: LazyLock<Mutex<ArtifactCache>> =
    LazyLock::new(|| Mutex::new(ArtifactCache::default()));

/// Fetch a cached `AnalysisArtifacts` for the given inputs, building +
/// inserting on miss. Returns the same `Arc` on subsequent calls so
/// `render_plan_graph` + `analyze_degree` + `course_detail` on the same
/// YAML share one expensive pipeline run.
///
/// The cache key folds in `random_seed` and `analysis_timeout_seconds` so
/// callers passing different seeds or deadlines get distinct artifacts
/// (and the default cases share keys naturally).
///
/// # Errors
/// Forwards the parse-error string from [`crate::mcp::tools::analyze::build_artifacts`].
pub(crate) fn cached_artifacts(
    yaml: &str,
    max_plans: Option<usize>,
    include_courses: Option<&[String]>,
    random_seed: Option<u64>,
    analysis_timeout_seconds: Option<u64>,
) -> Result<Arc<AnalysisArtifacts>, String> {
    let key = make_artifact_key(
        yaml,
        max_plans,
        include_courses,
        random_seed,
        analysis_timeout_seconds,
    );

    // Drop the lock guard before returning the Arc on a hit.
    let hit = ARTIFACT_CACHE
        .lock()
        .expect("artifact cache mutex poisoned")
        .get(key);
    if let Some(arc) = hit {
        return Ok(arc);
    }

    let artifacts = crate::mcp::tools::analyze::build_artifacts(
        yaml,
        max_plans,
        include_courses,
        random_seed,
        analysis_timeout_seconds,
    )?;
    let arc = Arc::new(artifacts);
    ARTIFACT_CACHE
        .lock()
        .expect("artifact cache mutex poisoned")
        .insert(key, arc.clone());
    Ok(arc)
}

// ============================================================================
// Tests
// ============================================================================

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

    #[test]
    fn test_yaml_cache_handle_is_content_hashed_and_idempotent() {
        let body = "degree:\n  id: x\n".to_string();
        let h1 = YamlCache::handle_for(&body);
        let h2 = YamlCache::handle_for(&body);
        assert_eq!(h1, h2, "same body must hash to the same handle");
        assert!(h1.starts_with(YAML_CACHE_PREFIX));

        let different = YamlCache::handle_for("degree:\n  id: y\n");
        assert_ne!(h1, different, "different bodies must hash differently");
    }

    #[test]
    fn test_yaml_cache_round_trips_through_insert_get() {
        let mut cache = YamlCache::default();
        let body = "degree:\n  id: round-trip\n".to_string();
        let handle = cache.insert(body.clone());
        let (retrieved, remaining) = cache.get(&handle).expect("present after insert");
        assert_eq!(&*retrieved, body);
        // Fresh insert: remaining TTL should be close to the full window.
        // Allow a small slack for the time elapsed between insert and get.
        assert!(remaining <= YAML_CACHE_TTL);
        let near_full = YAML_CACHE_TTL
            .checked_sub(Duration::from_mins(1))
            .expect("YAML_CACHE_TTL must exceed 60s");
        assert!(
            remaining > near_full,
            "remaining ({remaining:?}) should be within 60s of full TTL ({YAML_CACHE_TTL:?})"
        );
    }

    #[test]
    fn test_yaml_cache_ttl_is_24_hours() {
        // Bumped from 1 h to 24 h so handles survive a multi-session
        // investigation; guard the constant directly so accidental edits
        // (or future "tighten the cache" refactors) get caught.
        assert_eq!(YAML_CACHE_TTL, Duration::from_hours(24));
    }

    #[test]
    fn test_yaml_cache_unknown_handle_returns_none() {
        let cache = YamlCache::default();
        assert!(cache.get("cache:deadbeefdeadbeef").is_none());
    }

    #[test]
    fn test_lookup_distinguishes_unknown_expired_and_hit() {
        let mut cache = YamlCache::default();
        // Never minted → Unknown.
        assert!(matches!(
            cache.lookup("cache:0000000000000000"),
            CacheLookup::Unknown
        ));

        // Fresh handle → Hit with a remaining TTL.
        let handle = cache.insert("degree:\n  id: lk\n".to_string());
        assert!(matches!(cache.lookup(&handle), CacheLookup::Hit(_, _)));

        // Backdate past the TTL window → Expired (distinct from Unknown), so
        // the server can tell the caller to re-cache rather than guess a typo.
        let stale = Instant::now()
            .checked_sub(YAML_CACHE_TTL + Duration::from_secs(1))
            .expect("backdated instant supported on this platform");
        cache
            .entries
            .get_mut(&handle)
            .expect("present after insert")
            .inserted_at = stale;
        assert!(matches!(cache.lookup(&handle), CacheLookup::Expired));
    }

    #[test]
    fn test_make_artifact_key_order_independent_for_include_courses() {
        // Same set of courses in different orders → same cache key.
        let a = make_artifact_key(
            "yaml",
            Some(10),
            Some(&["CS101".into(), "CS201".into()]),
            None,
            None,
        );
        let b = make_artifact_key(
            "yaml",
            Some(10),
            Some(&["CS201".into(), "CS101".into()]),
            None,
            None,
        );
        assert_eq!(a, b);
    }

    #[test]
    fn test_cached_artifacts_returns_same_arc_on_repeated_lookup() {
        // Repeated `cached_artifacts` calls with the same inputs must return
        // the same Arc so downstream tools share work instead of re-running
        // the analysis pipeline. Use a YAML body unique to this test
        // (suffix-marker in the comment) so concurrent tests can't evict
        // the entry between the two calls in this thread.
        let yaml = "degree:\n  id: t-cache-arc\n  institution: T\n  program: T\n  total_credits: 8\n  gpa_minimum: 2.0\n\nrequirements:\n  intro:\n    name: Intro\n    type: all\n    category: major\n    courses: [CS101, CS201]\n\ncourses:\n  CS101:\n    title: Intro\n    prefix: CS\n    number: \"101\"\n    credits: 4\n  CS201:\n    title: Adv\n    prefix: CS\n    number: \"201\"\n    credits: 4\n    prerequisites_raw: \"CS101\"\n# unique-marker: cached_artifacts arc-eq test\n";
        let first = cached_artifacts(yaml, Some(50), None, None, None).expect("first build");
        let second = cached_artifacts(yaml, Some(50), None, None, None).expect("cache hit");
        assert!(
            Arc::ptr_eq(&first, &second),
            "second call must hit the cache and return the same Arc"
        );
    }

    #[test]
    fn test_make_artifact_key_distinguishes_none_from_empty_includes() {
        // None and Some(vec![]) are both "no constraint" semantically, but
        // the cache key separates them so swapping forms doesn't accidentally
        // collide (build_artifacts treats them identically; we err on safety).
        let none = make_artifact_key("yaml", Some(10), None, None, None);
        let empty = make_artifact_key("yaml", Some(10), Some(&[]), None, None);
        assert_ne!(none, empty);
    }

    #[test]
    fn test_make_artifact_key_distinguishes_max_plans() {
        // Different `max_plans` must produce different keys — otherwise a
        // capped-at-50 result would be returned for a caller asking for 500.
        let small = make_artifact_key("yaml", Some(50), None, None, None);
        let large = make_artifact_key("yaml", Some(500), None, None, None);
        assert_ne!(small, large);
        let none = make_artifact_key("yaml", None, None, None, None);
        assert_ne!(small, none);
    }

    #[test]
    fn test_yaml_cache_get_returns_none_for_expired_entry() {
        // Backdate the insertion to before the TTL window so the next `get`
        // sees it as expired. Exercises the elapsed-time gate in `get` (and
        // implicitly `sweep_expired`'s identical check).
        let mut cache = YamlCache::default();
        let body = "degree:\n  id: ttl\n".to_string();
        let handle = cache.insert(body);
        let stale = Instant::now()
            .checked_sub(YAML_CACHE_TTL + Duration::from_secs(1))
            .expect("Instant supports the backdated arithmetic on this platform");
        cache
            .entries
            .get_mut(&handle)
            .expect("present after insert")
            .inserted_at = stale;
        assert!(cache.get(&handle).is_none());
    }

    #[test]
    fn test_cached_artifacts_returns_distinct_arc_when_max_plans_differs() {
        // Same YAML, different `max_plans` → different keys → distinct Arcs.
        // Guards against silent cache-key collisions that would serve a
        // smaller result set for a caller asking for more plans.
        let yaml = crate::mcp::tools::samples::yaml_for_key("csu")
            .expect("csu sample key must resolve to embedded YAML");
        let a = cached_artifacts(yaml, Some(25), None, None, None).expect("first build");
        let b = cached_artifacts(yaml, Some(50), None, None, None).expect("second build");
        assert!(
            !Arc::ptr_eq(&a, &b),
            "different max_plans must produce distinct cache entries"
        );
    }

    #[test]
    fn test_artifact_cache_evicts_least_recently_used_at_capacity() {
        // Insert ARTIFACT_CACHE_CAPACITY + 1 distinct entries into a fresh
        // ArtifactCache and confirm the oldest entry was evicted while the
        // most recent ones survive.
        let yaml = crate::mcp::tools::samples::yaml_for_key("csu")
            .expect("csu sample key must resolve to embedded YAML");
        let sample = crate::mcp::tools::analyze::build_artifacts(yaml, Some(10), None, None, None)
            .expect("build");
        let shared = Arc::new(sample);

        let mut cache = ArtifactCache::default();
        for k in 0..ARTIFACT_CACHE_CAPACITY as u64 {
            cache.insert(k, shared.clone());
        }
        assert_eq!(cache.len(), ARTIFACT_CACHE_CAPACITY);

        // Bump key 1's recency so key 0 becomes the eviction target.
        assert!(cache.get(1).is_some());
        cache.insert(99, shared);

        assert_eq!(cache.len(), ARTIFACT_CACHE_CAPACITY);
        assert!(cache.get(0).is_none(), "oldest entry should be evicted");
        assert!(cache.get(1).is_some(), "recently accessed key must remain");
        assert!(cache.get(99).is_some(), "newest insertion must be present");
    }
}
