//! Filesystem-backed cache store.
//!
//! Entries live under `root/<first-2-chars>/<key>.bin` so any single
//! directory stays small even with tens of thousands of cached
//! results. A matching `.meta` sidecar carries the timestamps the
//! eviction logic needs without forcing the consumer to re-read the
//! payload.

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use etcetera::base_strategy::{choose_base_strategy, BaseStrategy};
use serde::{Deserialize, Serialize};

use super::{CacheEntry, CacheError, CacheStore};

/// On-disk metadata sidecar.
///
/// Stored as JSON next to the payload so a partial write can be
/// detected and reported through [`CacheError::Corrupted`].
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Metadata {
    created_at_unix: u64,
    accessed_at_unix: u64,
    /// Server-declared expiry; `None` for entries written before TTL
    /// support landed (defaulting to never-expire). Serialized with
    /// `default` + `skip_serializing_if = is_none` so existing
    /// sidecars round-trip without rewriting the on-disk format.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    expires_at_unix: Option<u64>,
}

/// Filesystem-backed cache store.
///
/// Defaults to the per-OS cache dir joined with `mcptest` so multi-user
/// machines do not collide. The actual root is fixed at construction
/// time; rotating directories means rebuilding the store.
#[derive(Debug, Clone)]
pub struct FsCacheStore {
    root: PathBuf,
}

impl FsCacheStore {
    /// Build a store at the user's standard cache directory.
    ///
    /// Returns an error only when the platform cannot resolve a cache
    /// dir (a very rare sandboxed case).
    pub fn user_default() -> Result<Self, CacheError> {
        let base = choose_base_strategy()
            .ok()
            .map(|s| s.cache_dir())
            .ok_or_else(|| CacheError::Io {
                path: PathBuf::from("$XDG_CACHE_HOME"),
                source: std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    "could not resolve user cache directory",
                ),
            })?;
        Ok(Self::with_root(base.join("mcptest")))
    }

    /// Build a store rooted at the given directory.
    ///
    /// Tests use this to point at a `tempfile::tempdir()` so the
    /// production cache dir stays untouched.
    pub fn with_root<P: Into<PathBuf>>(root: P) -> Self {
        Self { root: root.into() }
    }

    /// Path to the root directory the store writes into.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Evict every entry whose `accessed_at_unix` is older than the
    /// supplied age in seconds. Returns the count removed.
    pub fn evict_older_than(&self, seconds: u64) -> Result<usize, CacheError> {
        let cutoff = now_unix().saturating_sub(seconds);
        self.evict(&|entry: &CacheEntry| entry.accessed_at_unix < cutoff)
    }

    /// Evict oldest-accessed entries until total bytes is under
    /// `max_bytes`. LRU by access time.
    pub fn evict_to_size(&self, max_bytes: u64) -> Result<usize, CacheError> {
        let mut entries = self.collect_entries()?;
        let total: u64 = entries.iter().map(|e| e.size_bytes).sum();
        if total <= max_bytes {
            return Ok(0);
        }
        // Oldest first so we delete the least-recently-used.
        entries.sort_by_key(|e| e.accessed_at_unix);
        let mut running = total;
        let mut removed = 0usize;
        for entry in entries {
            if running <= max_bytes {
                break;
            }
            self.delete(&entry.key)?;
            running = running.saturating_sub(entry.size_bytes);
            removed += 1;
        }
        Ok(removed)
    }

    fn shard_dir(&self, key: &str) -> PathBuf {
        // Sharding on the first two chars splits keys 256 ways, which is
        // plenty for a per-developer cache; a hash with this much entropy
        // distributes evenly across shards.
        let shard = if key.len() >= 2 { &key[..2] } else { "_" };
        self.root.join(shard)
    }

    fn payload_path(&self, key: &str) -> PathBuf {
        self.shard_dir(key).join(format!("{key}.bin"))
    }

    fn meta_path(&self, key: &str) -> PathBuf {
        self.shard_dir(key).join(format!("{key}.meta"))
    }

    fn read_metadata(&self, key: &str) -> Result<Option<Metadata>, CacheError> {
        let path = self.meta_path(key);
        match fs::read(&path) {
            Ok(bytes) => {
                let meta: Metadata =
                    serde_json::from_slice(&bytes).map_err(|e| CacheError::Corrupted {
                        path: path.clone(),
                        reason: e.to_string(),
                    })?;
                Ok(Some(meta))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(CacheError::Io { path, source: e }),
        }
    }

    fn write_metadata(&self, key: &str, meta: &Metadata) -> Result<(), CacheError> {
        let path = self.meta_path(key);
        let bytes = serde_json::to_vec(meta).map_err(|e| CacheError::Corrupted {
            path: path.clone(),
            reason: e.to_string(),
        })?;
        atomic_write(&path, &bytes)
    }

    fn collect_entries(&self) -> Result<Vec<CacheEntry>, CacheError> {
        let mut out = Vec::new();
        if !self.root.exists() {
            return Ok(out);
        }
        let shards = fs::read_dir(&self.root).map_err(|e| CacheError::Io {
            path: self.root.clone(),
            source: e,
        })?;
        for shard in shards {
            let shard = shard.map_err(|e| CacheError::Io {
                path: self.root.clone(),
                source: e,
            })?;
            let shard_path = shard.path();
            if !shard_path.is_dir() {
                continue;
            }
            collect_shard(&shard_path, &mut out, self)?;
        }
        Ok(out)
    }
}

fn collect_shard(
    shard: &Path,
    out: &mut Vec<CacheEntry>,
    store: &FsCacheStore,
) -> Result<(), CacheError> {
    let iter = fs::read_dir(shard).map_err(|e| CacheError::Io {
        path: shard.to_path_buf(),
        source: e,
    })?;
    for entry in iter {
        let entry = entry.map_err(|e| CacheError::Io {
            path: shard.to_path_buf(),
            source: e,
        })?;
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) != Some("bin") {
            continue;
        }
        let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
            continue;
        };
        let size = entry
            .metadata()
            .map_err(|e| CacheError::Io {
                path: path.clone(),
                source: e,
            })?
            .len();
        let meta = store.read_metadata(&stem)?.unwrap_or(Metadata {
            created_at_unix: 0,
            accessed_at_unix: 0,
            expires_at_unix: None,
        });
        out.push(CacheEntry {
            key: stem,
            size_bytes: size,
            created_at_unix: meta.created_at_unix,
            accessed_at_unix: meta.accessed_at_unix,
            expires_at_unix: meta.expires_at_unix,
        });
    }
    Ok(())
}

/// Atomic write: write to `<path>.tmp` then rename. Rename is atomic on
/// the same filesystem, so a crashed run leaves either the old file or
/// no temp at all, never half a file under the real name.
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), CacheError> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| CacheError::Io {
            path: parent.to_path_buf(),
            source: e,
        })?;
    }
    let tmp = path.with_extension("tmp");
    {
        let mut f = fs::File::create(&tmp).map_err(|e| CacheError::Io {
            path: tmp.clone(),
            source: e,
        })?;
        f.write_all(bytes).map_err(|e| CacheError::Io {
            path: tmp.clone(),
            source: e,
        })?;
        f.sync_all().map_err(|e| CacheError::Io {
            path: tmp.clone(),
            source: e,
        })?;
    }
    fs::rename(&tmp, path).map_err(|e| CacheError::Io {
        path: path.to_path_buf(),
        source: e,
    })?;
    Ok(())
}

fn now_unix() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

impl CacheStore for FsCacheStore {
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
        let path = self.payload_path(key);
        let bytes = match fs::read(&path) {
            Ok(b) => b,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                tracing::debug!(
                    target: "mcptest_core::cache::store::fs",
                    key,
                    hit = false,
                    "cache miss"
                );
                return Ok(None);
            }
            Err(e) => {
                return Err(CacheError::Io { path, source: e });
            }
        };
        tracing::debug!(
            target: "mcptest_core::cache::store::fs",
            key,
            hit = true,
            size = bytes.len(),
            "cache hit"
        );
        // Expiry-on-read: a stale entry past its server-declared TTL
        // is treated as a miss and the sidecar + payload are evicted
        // in the same pass so the caller does not have to re-handle
        // the file later.
        let existing = self.read_metadata(key)?;
        let expires_at_unix = existing.as_ref().and_then(|m| m.expires_at_unix);
        if super::is_expired(expires_at_unix, now_unix()) {
            self.delete(key)?;
            tracing::debug!(
                target: "mcptest_core::cache::store::fs",
                key,
                "cache miss (expired)"
            );
            return Ok(None);
        }
        // Touch access time so LRU eviction works.
        let created = existing.as_ref().map(|m| m.created_at_unix).unwrap_or(0);
        let meta = Metadata {
            created_at_unix: created,
            accessed_at_unix: now_unix(),
            expires_at_unix,
        };
        self.write_metadata(key, &meta)?;
        Ok(Some(bytes))
    }

    fn put(&self, key: &str, value: &[u8]) -> Result<(), CacheError> {
        self.put_with_ttl(key, value, None)
    }

    fn put_with_ttl(
        &self,
        key: &str,
        value: &[u8],
        expires_at_unix: Option<u64>,
    ) -> Result<(), CacheError> {
        let path = self.payload_path(key);
        atomic_write(&path, value)?;
        let existing = self.read_metadata(key)?;
        // Preserve the existing expiry when the caller did not supply
        // one. This keeps a `put_with_ttl(.., None)` from clearing a
        // server-declared TTL that landed on an earlier write.
        let expires_at_unix =
            expires_at_unix.or_else(|| existing.as_ref().and_then(|m| m.expires_at_unix));
        let now = now_unix();
        let meta = Metadata {
            created_at_unix: existing.map(|m| m.created_at_unix).unwrap_or(now),
            accessed_at_unix: now,
            expires_at_unix,
        };
        tracing::debug!(
            target: "mcptest_core::cache::store::fs",
            key,
            size = value.len(),
            "cache put"
        );
        self.write_metadata(key, &meta)
    }

    fn delete(&self, key: &str) -> Result<(), CacheError> {
        tracing::debug!(
            target: "mcptest_core::cache::store::fs",
            key,
            "cache delete"
        );
        let payload = self.payload_path(key);
        match fs::remove_file(&payload) {
            Ok(()) => {}
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(e) => {
                return Err(CacheError::Io {
                    path: payload,
                    source: e,
                });
            }
        }
        let meta = self.meta_path(key);
        match fs::remove_file(&meta) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(CacheError::Io {
                path: meta,
                source: e,
            }),
        }
    }

    fn list(&self) -> Result<Vec<String>, CacheError> {
        Ok(self.collect_entries()?.into_iter().map(|e| e.key).collect())
    }

    fn evict(&self, predicate: &dyn Fn(&CacheEntry) -> bool) -> Result<usize, CacheError> {
        let entries = self.collect_entries()?;
        let mut removed = 0usize;
        for entry in entries {
            if predicate(&entry) {
                self.delete(&entry.key)?;
                removed += 1;
            }
        }
        if removed > 0 {
            tracing::info!(
                target: "mcptest_core::cache::store::fs",
                removed,
                "evicted cache entries"
            );
        }
        Ok(removed)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs as stdfs;
    use std::thread::sleep;
    use std::time::Duration;
    use tempfile::tempdir;

    fn key(s: &str) -> String {
        // Use a 64-char hex-like key so the shard logic gets two real chars.
        format!("{s:>64}").replace(' ', "a")
    }

    #[test]
    fn put_then_get_round_trips_value() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        store.put(&key("abc"), b"hello world").unwrap();
        let got = store.get(&key("abc")).unwrap();
        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
    }

    #[test]
    fn get_returns_none_for_missing_key() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let got = store.get(&key("missing")).unwrap();
        assert!(got.is_none());
    }

    #[test]
    fn delete_removes_the_entry() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = key("rm");
        store.put(&k, b"data").unwrap();
        store.delete(&k).unwrap();
        assert!(store.get(&k).unwrap().is_none());
        // Second delete of an absent key is a no-op.
        store.delete(&k).unwrap();
    }

    #[test]
    fn list_returns_every_stored_key() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let a = key("aa");
        let b = key("bb");
        store.put(&a, b"x").unwrap();
        store.put(&b, b"y").unwrap();
        let mut keys = store.list().unwrap();
        keys.sort();
        let mut expected = vec![a, b];
        expected.sort();
        assert_eq!(keys, expected);
    }

    #[test]
    fn put_creates_sharded_directories() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = "ab1234567890";
        store.put(k, b"data").unwrap();
        let shard = dir.path().join("ab");
        assert!(shard.exists(), "shard dir should exist");
        assert!(shard.join(format!("{k}.bin")).exists());
        assert!(shard.join(format!("{k}.meta")).exists());
    }

    #[test]
    fn evict_with_predicate_returns_count() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let a = key("aa");
        let b = key("bb");
        store.put(&a, b"x").unwrap();
        store.put(&b, b"y").unwrap();
        let target = a.clone();
        let removed = store
            .evict(&move |entry: &CacheEntry| entry.key == target)
            .unwrap();
        assert_eq!(removed, 1);
        assert!(store.get(&a).unwrap().is_none());
        assert!(store.get(&b).unwrap().is_some());
    }

    #[test]
    fn evict_older_than_removes_stale_entries() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = key("old");
        store.put(&k, b"x").unwrap();
        // Force the metadata to look ancient.
        let meta = Metadata {
            created_at_unix: 0,
            accessed_at_unix: 0,
            expires_at_unix: None,
        };
        store.write_metadata(&k, &meta).unwrap();
        let removed = store.evict_older_than(1).unwrap();
        assert_eq!(removed, 1);
        assert!(store.get(&k).unwrap().is_none());
    }

    #[test]
    fn evict_to_size_keeps_under_cap() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        for i in 0..5u8 {
            let k = key(&format!("k{i}"));
            store.put(&k, &[i; 100]).unwrap();
        }
        // Total = 500 bytes. Trim to 200.
        store.evict_to_size(200).unwrap();
        let remaining = store.list().unwrap();
        let total: u64 = remaining
            .iter()
            .map(|k| store.get(k).unwrap().unwrap().len() as u64)
            .sum();
        assert!(total <= 200, "total {total} should be under cap 200");
    }

    #[test]
    fn evict_to_size_is_lru_by_access_time() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let old = key("old");
        let new = key("new");
        store.put(&old, &[1u8; 100]).unwrap();
        // Backdate `old` so it looks least-recently-used.
        store
            .write_metadata(
                &old,
                &Metadata {
                    created_at_unix: 0,
                    accessed_at_unix: 0,
                    expires_at_unix: None,
                },
            )
            .unwrap();
        sleep(Duration::from_millis(10));
        store.put(&new, &[2u8; 100]).unwrap();
        // Cap at 100 bytes: oldest should go, newest should stay.
        let removed = store.evict_to_size(100).unwrap();
        assert_eq!(removed, 1);
        assert!(store.get(&old).unwrap().is_none());
        assert!(store.get(&new).unwrap().is_some());
    }

    #[test]
    fn atomic_put_does_not_leave_temp_files_on_failure() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = "cd000000";
        store.put(k, b"hello").unwrap();
        // Walk the shard dir: only .bin and .meta should remain, no .tmp.
        let shard = dir.path().join("cd");
        for entry in stdfs::read_dir(&shard).unwrap() {
            let path = entry.unwrap().path();
            let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
            assert!(
                ext == "bin" || ext == "meta",
                "unexpected leftover file: {path:?}"
            );
        }
    }

    #[test]
    fn corrupted_metadata_is_reported_via_error() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = "ef000000";
        store.put(k, b"hello").unwrap();
        // Corrupt the sidecar.
        let meta_path = store.meta_path(k);
        stdfs::write(&meta_path, b"not json").unwrap();
        let err = store.get(k).unwrap_err();
        assert!(matches!(err, CacheError::Corrupted { .. }));
    }

    #[test]
    fn put_with_ttl_records_the_expiry_on_the_sidecar() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = key("ttl");
        let deadline = now_unix() + 60;
        store.put_with_ttl(&k, b"data", Some(deadline)).unwrap();
        let meta = store.read_metadata(&k).unwrap().expect("metadata present");
        assert_eq!(meta.expires_at_unix, Some(deadline));
        // Fresh entries still round-trip through get().
        assert_eq!(store.get(&k).unwrap().as_deref(), Some(&b"data"[..]));
    }

    #[test]
    fn get_evicts_an_expired_entry_and_returns_none() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = key("stale");
        // Write with an expiry one second in the past so the next
        // read trips the expiry-on-read rule without sleeping.
        let deadline = now_unix().saturating_sub(1);
        store.put_with_ttl(&k, b"stale", Some(deadline)).unwrap();
        assert!(store.get(&k).unwrap().is_none());
        // The eviction is in-line: a subsequent get is also a miss
        // because the sidecar and payload were removed.
        assert!(store.read_metadata(&k).unwrap().is_none());
    }

    #[test]
    fn put_without_ttl_preserves_an_existing_ttl() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = key("preserve");
        let deadline = now_unix() + 600;
        store.put_with_ttl(&k, b"first", Some(deadline)).unwrap();
        // Overwrite with a bare put: the sidecar's expires_at_unix
        // must survive because nothing told the store to clear it.
        store.put(&k, b"second").unwrap();
        let meta = store.read_metadata(&k).unwrap().expect("sidecar present");
        assert_eq!(meta.expires_at_unix, Some(deadline));
    }

    #[test]
    fn cache_entry_lists_with_the_expires_at_field_populated() {
        let dir = tempdir().unwrap();
        let store = FsCacheStore::with_root(dir.path());
        let k = key("listed");
        let deadline = now_unix() + 30;
        store.put_with_ttl(&k, b"x", Some(deadline)).unwrap();
        // Read the sidecar directly to confirm the field round-trips
        // through the on-disk JSON. The eviction-walk helper takes a
        // `Fn` so we cannot easily capture state into it; the
        // sidecar is the public contract for downstream readers
        // anyway.
        let meta = store.read_metadata(&k).unwrap().expect("sidecar present");
        assert_eq!(meta.expires_at_unix, Some(deadline));
    }
}
