#![allow(dead_code)]

use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use crate::payload::Payload;

static TMP_SEQ: AtomicU64 = AtomicU64::new(0);

const STALE_LOCK_THRESHOLD: Duration = Duration::from_secs(30);

/// Outcome of the fetch that produced this cache entry. Surfaced so rendering and diagnostics
/// can tell a successful fetch from one that failed or timed out without peeking at the body.
/// Serialized in lowercase (`"ok"`, `"err"`, `"timeout"`); missing from an older cache file
/// deserializes back to `Ok` via `#[serde(default)]` on the [`CacheEntry::kind`] field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CacheEntryKind {
    #[default]
    Ok,
    Err,
    Timeout,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CacheEntry {
    pub refreshed_at: u64,
    pub ttl_seconds: u64,
    #[serde(default)]
    pub kind: CacheEntryKind,
    pub payload: Payload,
}

impl CacheEntry {
    pub fn new(payload: Payload, ttl_seconds: u64) -> Self {
        Self::new_with_kind(payload, ttl_seconds, CacheEntryKind::Ok)
    }

    pub fn new_with_kind(payload: Payload, ttl_seconds: u64, kind: CacheEntryKind) -> Self {
        Self {
            refreshed_at: now_secs(),
            ttl_seconds,
            kind,
            payload,
        }
    }

    pub fn is_fresh(&self) -> bool {
        age_of(self.refreshed_at) < self.ttl_seconds
    }
}

pub struct Cache {
    dir: PathBuf,
}

impl Cache {
    pub fn open_default() -> Option<Self> {
        Self::open(crate::paths::cache_dir()?)
    }

    pub fn open(dir: PathBuf) -> Option<Self> {
        std::fs::create_dir_all(&dir).ok()?;
        Some(Self { dir })
    }

    pub fn load(&self, widget_id: &str) -> Option<CacheEntry> {
        let data = std::fs::read_to_string(self.path_for(widget_id)).ok()?;
        serde_json::from_str(&data).ok()
    }

    pub fn store(&self, widget_id: &str, entry: &CacheEntry) -> std::io::Result<()> {
        let path = self.path_for(widget_id);
        let tmp = tmp_path_for(&path);
        let json = serde_json::to_string(entry).map_err(io_err)?;
        ensure_parent(&path)?;
        std::fs::write(&tmp, json)?;
        std::fs::rename(tmp, path)?;
        Ok(())
    }

    pub fn path_for(&self, widget_id: &str) -> PathBuf {
        self.dir.join(format!("{}.json", sanitize(widget_id)))
    }
}

/// Abstraction over the storage backend used by `fetch_all` / `load_entries`. The one-shot
/// splash uses the disk-backed `Cache`; `watch` runs against `MemCache` because the disk is
/// only justified as IPC between the splash parent and the detached fetch daemon — `watch`
/// has no child process, so a write-through to disk on every refresh is pure waste.
pub trait CacheBackend: Send + Sync {
    fn load(&self, key: &str) -> Option<CacheEntry>;
    fn store(&self, key: &str, entry: &CacheEntry) -> std::io::Result<()>;
    /// Non-blocking. `None` if a parallel fetch already holds the slot. Held across the fetch
    /// await so the same widget can't be refetched twice in one pass.
    fn try_lock(&self, key: &str) -> Option<CacheLockGuard>;
}

/// Token returned by [`CacheBackend::try_lock`]. Dropping it releases the lock — removes the
/// `.lock` file for the disk path, takes the key out of the in-flight set for the in-memory one.
pub enum CacheLockGuard {
    File(Lock),
    Memory(MemLockGuard),
}

impl CacheBackend for Cache {
    fn load(&self, key: &str) -> Option<CacheEntry> {
        Cache::load(self, key)
    }
    fn store(&self, key: &str, entry: &CacheEntry) -> std::io::Result<()> {
        Cache::store(self, key, entry)
    }
    fn try_lock(&self, key: &str) -> Option<CacheLockGuard> {
        Lock::try_acquire(&self.dir, key).map(CacheLockGuard::File)
    }
}

/// In-memory cache backend for `watch`. Same TTL semantics as the disk `Cache` (entries carry
/// `refreshed_at` + `ttl_seconds`), but all reads and writes are HashMap operations and the
/// per-key locks are a HashSet of in-flight keys. The disk `Cache` is only touched for
/// warm-start at the top of `watch` and best-effort flush at the bottom.
#[derive(Clone, Default)]
pub struct MemCache {
    entries: Arc<RwLock<HashMap<String, CacheEntry>>>,
    in_flight: Arc<Mutex<HashSet<String>>>,
    /// Monotonic counter bumped on every successful `store`. Watch's foreground reads it to
    /// decide whether to re-walk the entries map at all — when it hasn't advanced since last
    /// check, nothing has been written, and the per-frame cache read can be skipped.
    generation: Arc<AtomicU64>,
}

impl MemCache {
    pub fn new() -> Self {
        Self::default()
    }

    /// Current write generation. Use this to short-circuit redundant `load` walks when the
    /// memory map hasn't been mutated since last check.
    pub fn generation(&self) -> u64 {
        self.generation.load(Ordering::Acquire)
    }

    /// Copy entries matching `keys` from `disk` into a fresh `MemCache`. Missing keys are
    /// skipped — the watch fetch loop will populate them on its first pass.
    pub fn warm_from<'a>(disk: Option<&Cache>, keys: impl IntoIterator<Item = &'a str>) -> Self {
        let mem = Self::new();
        if let (Some(d), Ok(mut entries)) = (disk, mem.entries.write()) {
            entries.extend(
                keys.into_iter()
                    .filter_map(|k| d.load(k).map(|e| (k.to_string(), e))),
            );
        }
        mem
    }

    /// Write every in-memory entry back to disk. Errors per-key are swallowed — losing one
    /// write on shutdown only forces a refetch next session.
    pub fn flush_to(&self, disk: &Cache) {
        let Ok(entries) = self.entries.read() else {
            return;
        };
        for (key, entry) in entries.iter() {
            let _ = disk.store(key, entry);
        }
    }
}

impl CacheBackend for MemCache {
    fn load(&self, key: &str) -> Option<CacheEntry> {
        self.entries.read().ok()?.get(key).cloned()
    }
    fn store(&self, key: &str, entry: &CacheEntry) -> std::io::Result<()> {
        let Ok(mut e) = self.entries.write() else {
            return Ok(());
        };
        e.insert(key.to_string(), entry.clone());
        drop(e);
        self.generation.fetch_add(1, Ordering::Release);
        Ok(())
    }
    fn try_lock(&self, key: &str) -> Option<CacheLockGuard> {
        let mut set = self.in_flight.lock().ok()?;
        if set.contains(key) {
            return None;
        }
        set.insert(key.to_string());
        Some(CacheLockGuard::Memory(MemLockGuard {
            key: key.to_string(),
            in_flight: Arc::clone(&self.in_flight),
        }))
    }
}

pub struct MemLockGuard {
    key: String,
    in_flight: Arc<Mutex<HashSet<String>>>,
}

impl Drop for MemLockGuard {
    fn drop(&mut self) {
        if let Ok(mut set) = self.in_flight.lock() {
            set.remove(&self.key);
        }
    }
}

pub struct Lock {
    path: PathBuf,
}

impl Lock {
    pub fn try_acquire(dir: &Path, key: &str) -> Option<Self> {
        Self::try_acquire_with(dir, key, STALE_LOCK_THRESHOLD)
    }

    fn try_acquire_with(dir: &Path, key: &str, stale_after: Duration) -> Option<Self> {
        let path = lock_path_for(dir, key);
        match create_exclusive(&path) {
            Ok(()) => Some(Self { path }),
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
                if is_lock_stale(&path, stale_after) {
                    let _ = std::fs::remove_file(&path);
                    create_exclusive(&path).ok().map(|_| Self { path })
                } else {
                    None
                }
            }
            Err(_) => None,
        }
    }
}

impl Drop for Lock {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

fn lock_path_for(dir: &Path, key: &str) -> PathBuf {
    dir.join(format!("{}.lock", sanitize(key)))
}

fn create_exclusive(path: &Path) -> std::io::Result<()> {
    ensure_parent(path)?;
    let mut f = std::fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(path)?;
    writeln!(f, "{}", std::process::id())?;
    Ok(())
}

fn is_lock_stale(path: &Path, threshold: Duration) -> bool {
    let Ok(meta) = std::fs::metadata(path) else {
        return false;
    };
    let Ok(mtime) = meta.modified() else {
        return false;
    };
    mtime.elapsed().map(|d| d > threshold).unwrap_or(false)
}

/// Builds a per-invocation unique tmp path next to `final_path`. Pid keeps it distinct across
/// processes; the atomic counter keeps it distinct across concurrent calls within one process.
/// Exported so other modules (e.g. trust store) can share the write-tmp-then-rename idiom.
pub(crate) fn tmp_path_for(final_path: &Path) -> PathBuf {
    let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
    let pid = std::process::id();
    let file_name = final_path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    final_path.with_file_name(format!("{file_name}.{pid}.{seq}.tmp"))
}

/// Maps an arbitrary cache key to an on-disk filename stem by keeping ASCII alnum / `-` / `_`
/// and replacing everything else with `_`. Exposed because the `splashboard cache clear <id>`
/// command (in the binary crate) needs to compute the same stem from the same key — keeping a
/// private copy in `main.rs` would silently desync if this rule ever tightens. Marked
/// `#[doc(hidden)]` because external library consumers should not rely on it.
#[doc(hidden)]
pub fn sanitize(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

fn ensure_parent(path: &Path) -> std::io::Result<()> {
    match path.parent() {
        Some(p) => std::fs::create_dir_all(p),
        None => Ok(()),
    }
}

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

fn age_of(past: u64) -> u64 {
    now_secs().saturating_sub(past)
}

fn io_err<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::other(e.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::payload::{Body, TextData};

    fn sample() -> Payload {
        Payload {
            icon: None,
            status: None,
            format: None,
            body: Body::Text(TextData { value: "hi".into() }),
        }
    }

    #[test]
    fn entry_is_fresh_when_inside_ttl() {
        let entry = CacheEntry::new(sample(), 60);
        assert!(entry.is_fresh());
    }

    #[test]
    fn new_defaults_kind_to_ok() {
        let entry = CacheEntry::new(sample(), 60);
        assert_eq!(entry.kind, CacheEntryKind::Ok);
    }

    #[test]
    fn new_with_kind_carries_error_kind_through() {
        let entry = CacheEntry::new_with_kind(sample(), 30, CacheEntryKind::Err);
        assert_eq!(entry.kind, CacheEntryKind::Err);
    }

    #[test]
    fn legacy_entry_without_kind_field_deserializes_as_ok() {
        // Back-compat: any cache file written before the kind field existed must round-trip
        // unchanged so users don't lose their warm cache on upgrade. #[serde(default)] on the
        // field is the invariant under test.
        // Build the "legacy" shape by serializing a current CacheEntry and stripping the kind
        // field — avoids hand-coding the payload body tag/content conventions.
        let entry = CacheEntry::new(sample(), 60);
        let mut json: serde_json::Value = serde_json::to_value(&entry).unwrap();
        json.as_object_mut().unwrap().remove("kind");
        let reparsed: CacheEntry = serde_json::from_value(json).expect("legacy json must parse");
        assert_eq!(reparsed.kind, CacheEntryKind::Ok);
    }

    #[test]
    fn cache_entry_kind_serializes_lowercase() {
        let json = serde_json::to_string(&CacheEntryKind::Timeout).unwrap();
        assert_eq!(json, "\"timeout\"");
    }

    #[test]
    fn entry_is_stale_when_ttl_zero() {
        let entry = CacheEntry::new(sample(), 0);
        assert!(!entry.is_fresh());
    }

    #[test]
    fn entry_is_stale_when_timestamp_is_ancient() {
        let entry = CacheEntry {
            refreshed_at: 0,
            ttl_seconds: 60,
            kind: CacheEntryKind::Ok,
            payload: sample(),
        };
        assert!(!entry.is_fresh());
    }

    #[test]
    fn store_then_load_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let cache = Cache::open(dir.path().to_path_buf()).unwrap();
        let entry = CacheEntry::new(sample(), 60);
        cache.store("greeting", &entry).unwrap();
        let loaded = cache.load("greeting").unwrap();
        assert_eq!(loaded, entry);
    }

    #[test]
    fn load_returns_none_when_missing() {
        let dir = tempfile::tempdir().unwrap();
        let cache = Cache::open(dir.path().to_path_buf()).unwrap();
        assert!(cache.load("absent").is_none());
    }

    #[test]
    fn store_overwrites_previous_entry() {
        let dir = tempfile::tempdir().unwrap();
        let cache = Cache::open(dir.path().to_path_buf()).unwrap();
        cache.store("k", &CacheEntry::new(sample(), 10)).unwrap();
        let updated = CacheEntry::new(sample(), 999);
        cache.store("k", &updated).unwrap();
        assert_eq!(cache.load("k").unwrap(), updated);
    }

    #[test]
    fn path_for_sanitizes_unsafe_chars() {
        let dir = tempfile::tempdir().unwrap();
        let cache = Cache::open(dir.path().to_path_buf()).unwrap();
        let path = cache.path_for("../evil/id");
        assert!(path.starts_with(dir.path()));
        assert!(!path.to_string_lossy().contains(".."));
    }

    #[test]
    fn load_returns_none_for_corrupt_file() {
        let dir = tempfile::tempdir().unwrap();
        let cache = Cache::open(dir.path().to_path_buf()).unwrap();
        std::fs::write(cache.path_for("bad"), "not-json").unwrap();
        assert!(cache.load("bad").is_none());
    }

    #[test]
    fn tmp_path_is_unique_across_calls() {
        let final_path = Path::new("/tmp/cache/foo.json");
        let a = tmp_path_for(final_path);
        let b = tmp_path_for(final_path);
        assert_ne!(a, b);
        assert!(a.to_string_lossy().contains("foo.json."));
        assert!(a.to_string_lossy().ends_with(".tmp"));
    }

    #[test]
    fn lock_is_exclusive_until_dropped() {
        let dir = tempfile::tempdir().unwrap();
        let held = Lock::try_acquire(dir.path(), "k").expect("first acquire succeeds");
        assert!(
            Lock::try_acquire(dir.path(), "k").is_none(),
            "second acquire must fail while held"
        );
        drop(held);
        assert!(
            Lock::try_acquire(dir.path(), "k").is_some(),
            "acquire succeeds after drop"
        );
    }

    #[test]
    fn stale_lock_is_stolen() {
        let dir = tempfile::tempdir().unwrap();
        // Plant a fake lock file.
        let path = lock_path_for(dir.path(), "k");
        std::fs::write(&path, "999999\n").unwrap();
        std::thread::sleep(Duration::from_millis(5));
        let lock = Lock::try_acquire_with(dir.path(), "k", Duration::from_millis(1));
        assert!(lock.is_some(), "stale lock should be stolen");
    }

    #[test]
    fn fresh_lock_is_not_stolen() {
        let dir = tempfile::tempdir().unwrap();
        let _held = Lock::try_acquire(dir.path(), "k").unwrap();
        let lock = Lock::try_acquire_with(dir.path(), "k", Duration::from_secs(60));
        assert!(lock.is_none(), "fresh lock must not be stolen");
    }

    #[test]
    fn lock_drop_removes_file() {
        let dir = tempfile::tempdir().unwrap();
        {
            let _lock = Lock::try_acquire(dir.path(), "k").unwrap();
            assert!(lock_path_for(dir.path(), "k").exists());
        }
        assert!(!lock_path_for(dir.path(), "k").exists());
    }

    #[test]
    fn different_keys_do_not_block_each_other() {
        let dir = tempfile::tempdir().unwrap();
        let _a = Lock::try_acquire(dir.path(), "a").unwrap();
        let _b = Lock::try_acquire(dir.path(), "b").unwrap();
    }

    #[test]
    fn mem_cache_round_trips_through_the_trait() {
        let mem = MemCache::new();
        let entry = CacheEntry::new(sample(), 60);
        let backend: &dyn CacheBackend = &mem;
        backend.store("k", &entry).unwrap();
        assert_eq!(backend.load("k"), Some(entry));
    }

    #[test]
    fn mem_cache_try_lock_is_exclusive_until_dropped() {
        let mem = MemCache::new();
        let backend: &dyn CacheBackend = &mem;
        let held = backend.try_lock("k").expect("first acquire succeeds");
        assert!(
            backend.try_lock("k").is_none(),
            "second acquire must fail while held"
        );
        drop(held);
        assert!(
            backend.try_lock("k").is_some(),
            "acquire succeeds after drop"
        );
    }

    #[test]
    fn mem_cache_try_lock_separates_keys() {
        let mem = MemCache::new();
        let _a = mem.try_lock("a").unwrap();
        let _b = mem.try_lock("b").unwrap();
    }

    #[test]
    fn mem_cache_warm_from_copies_matching_disk_entries() {
        let dir = tempfile::tempdir().unwrap();
        let disk = Cache::open(dir.path().to_path_buf()).unwrap();
        let entry = CacheEntry::new(sample(), 60);
        disk.store("present", &entry).unwrap();

        let mem = MemCache::warm_from(Some(&disk), ["present", "absent"]);
        assert_eq!(
            <MemCache as CacheBackend>::load(&mem, "present"),
            Some(entry)
        );
        assert!(<MemCache as CacheBackend>::load(&mem, "absent").is_none());
    }

    #[test]
    fn mem_cache_generation_bumps_only_on_store() {
        let mem = MemCache::new();
        let gen0 = mem.generation();
        let backend: &dyn CacheBackend = &mem;
        // Pure reads / lock acquires must not advance the generation — readers gate per-frame
        // work on this, so a spurious bump would cause unnecessary redraws.
        let _ = backend.load("absent");
        let _held = backend.try_lock("k");
        assert_eq!(mem.generation(), gen0);
        backend.store("k", &CacheEntry::new(sample(), 60)).unwrap();
        assert_eq!(mem.generation(), gen0 + 1);
    }

    #[test]
    fn mem_cache_flush_to_writes_every_entry_back() {
        let dir = tempfile::tempdir().unwrap();
        let disk = Cache::open(dir.path().to_path_buf()).unwrap();
        let mem = MemCache::new();
        let a = CacheEntry::new(sample(), 60);
        let b = CacheEntry::new(sample(), 120);
        <MemCache as CacheBackend>::store(&mem, "a", &a).unwrap();
        <MemCache as CacheBackend>::store(&mem, "b", &b).unwrap();

        mem.flush_to(&disk);
        assert_eq!(disk.load("a"), Some(a));
        assert_eq!(disk.load("b"), Some(b));
    }

    #[test]
    fn warm_from_without_disk_returns_an_empty_cache() {
        // `watch` calls `warm_from(disk_cache.as_ref(), ...)`, and `Cache::open_default()` can
        // hand back `None`. The `None` disk arm skips the `extend` entirely — every requested
        // key simply starts cold and the fetch loop fills it in.
        let mem = MemCache::warm_from(None, ["present", "absent"]);
        assert!(<MemCache as CacheBackend>::load(&mem, "present").is_none());
        assert_eq!(mem.generation(), 0);
    }

    #[test]
    fn is_lock_stale_returns_false_when_file_is_missing() {
        // A vanished lock file (another process already cleared it) must not read as stale —
        // `metadata` fails and the `Ok(meta) else` arm bails to `false`.
        let dir = tempfile::tempdir().unwrap();
        let missing = dir.path().join("never-created.lock");
        assert!(!is_lock_stale(&missing, Duration::from_millis(1)));
    }

    #[test]
    fn ensure_parent_is_ok_for_a_path_without_a_parent() {
        // The filesystem root has no parent component, so `ensure_parent` takes the `None`
        // arm and short-circuits to `Ok` instead of attempting a `create_dir_all`.
        assert!(ensure_parent(Path::new("/")).is_ok());
    }

    #[test]
    fn io_err_wraps_a_display_value_into_an_other_kind_error() {
        let err = io_err("disk on fire");
        assert_eq!(err.kind(), std::io::ErrorKind::Other);
        assert!(err.to_string().contains("disk on fire"));
    }

    #[test]
    fn try_acquire_with_returns_none_when_lock_dir_cannot_be_created() {
        // Nest the lock dir under a regular file: `create_dir_all` fails with a
        // non-`AlreadyExists` error, exercising the `Err(_) => None` arm of `try_acquire_with`.
        let tmp = tempfile::tempdir().unwrap();
        let file = tmp.path().join("not-a-dir");
        std::fs::write(&file, "x").unwrap();
        let unwritable = file.join("nested");
        assert!(Lock::try_acquire_with(&unwritable, "k", Duration::from_secs(60)).is_none());
    }

    #[test]
    fn concurrent_stores_leave_valid_final_file() {
        use std::sync::Arc;
        use std::thread;

        let dir = tempfile::tempdir().unwrap();
        let cache = Arc::new(Cache::open(dir.path().to_path_buf()).unwrap());
        let handles: Vec<_> = (0..32)
            .map(|i| {
                let cache = Arc::clone(&cache);
                thread::spawn(move || {
                    let entry = CacheEntry::new(sample(), 100 + i);
                    cache.store("shared", &entry).unwrap();
                })
            })
            .collect();
        for h in handles {
            h.join().unwrap();
        }

        let loaded = cache.load("shared").expect("final file must parse");
        assert!(loaded.ttl_seconds >= 100);
        // No leftover tmp files should remain in the cache dir.
        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
            .collect();
        assert!(leftovers.is_empty(), "leftover tmp files: {leftovers:?}");
    }
}
