use std::collections::HashMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Result;
use serde::{Deserialize, Serialize};

/// Seconds since Unix epoch. Cross-platform via std::time::SystemTime.
fn now_epoch() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64
}

/// Metadata stored alongside each cache entry.
///
/// Persisted to eviction.json. The blob_hash + is_hashseq fields allow
/// rebuilding the in-memory content index after daemon restart without
/// re-scanning the entire blob store.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntryMeta {
    pub compute_ns: u64,
    #[serde(default)]
    pub ops: u64,
    #[serde(default)]
    pub mem_bytes: u64,
    #[serde(default)]
    pub peak_mem: u64,
    pub created_at: i64,
    pub last_access: i64,
    pub access_count: u64,
    pub size_bytes: u64,
    pub tag: String,
    /// Hex-encoded blake3 hash of the stored blob (or HashSeq manifest).
    /// Added in 0.3.1 -- missing from older eviction.json files, hence default.
    #[serde(default)]
    pub blob_hash: String,
    /// True if blob_hash refers to a HashSeq (chunked large value).
    #[serde(default)]
    pub is_hashseq: bool,
}

impl EntryMeta {
    pub fn new(
        compute_ns: u64, ops: u64, mem_bytes: u64, peak_mem: u64,
        size_bytes: u64, tag: &str,
    ) -> Self {
        let now = now_epoch();
        Self {
            compute_ns, ops, mem_bytes, peak_mem,
            created_at: now, last_access: now,
            access_count: 1, size_bytes,
            tag: tag.to_string(),
            blob_hash: String::new(),
            is_hashseq: false,
        }
    }

    /// Set blob identity after storing.
    pub fn with_blob(mut self, hash_hex: &str, is_hashseq: bool) -> Self {
        self.blob_hash = hash_hex.to_string();
        self.is_hashseq = is_hashseq;
        self
    }

    pub fn touch(&mut self) {
        self.last_access = now_epoch();
        self.access_count += 1;
    }

    /// Eviction priority. Higher = keep longer.
    pub fn priority(&self) -> f64 {
        let age = (now_epoch() - self.created_at).max(1) as f64;
        let cost = (self.compute_ns as f64) / 1e9;
        let size = self.size_bytes.max(1) as f64;
        (cost * self.access_count as f64) / (age * size)
    }
}

/// In-memory eviction index. Persisted to eviction.json on changes.
pub struct EvictionIndex {
    entries: HashMap<String, EntryMeta>,
    path: std::path::PathBuf,
}

impl EvictionIndex {
    pub fn load(ns_dir: &Path) -> Result<Self> {
        let path = ns_dir.join("eviction.json");
        let entries = if path.exists() {
            let text = std::fs::read_to_string(&path)?;
            serde_json::from_str(&text).unwrap_or_default()
        } else {
            HashMap::new()
        };
        Ok(Self { entries, path })
    }

    pub fn record(&mut self, key: &str, meta: EntryMeta) {
        self.entries.insert(key.to_string(), meta);
    }

    pub fn touch(&mut self, key: &str) {
        if let Some(m) = self.entries.get_mut(key) {
            m.touch();
        }
    }

    pub fn get_meta(&self, key: &str) -> Option<&EntryMeta> {
        self.entries.get(key)
    }

    /// Iterate all entries (for rebuilding content index on startup).
    pub fn entries(&self) -> impl Iterator<Item = (&str, &EntryMeta)> {
        self.entries.iter().map(|(k, v)| (k.as_str(), v))
    }

    pub fn total_bytes(&self) -> u64 {
        self.entries.values().map(|m| m.size_bytes).sum()
    }

    pub fn eviction_candidates(&self, budget: u64) -> Vec<String> {
        if budget == 0 { return vec![]; }
        let total = self.total_bytes();
        if total <= budget { return vec![]; }

        let mut by_priority: Vec<_> = self.entries.iter()
            .map(|(k, m)| (k.clone(), m.priority(), m.size_bytes))
            .collect();
        by_priority.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));

        let mut freed = 0u64;
        let need = total - budget;
        let mut to_evict = vec![];
        for (key, _pri, size) in by_priority {
            to_evict.push(key);
            freed += size;
            if freed >= need { break; }
        }
        to_evict
    }

    pub fn evict_by_tag(&mut self, tag_prefix: &str) -> Vec<String> {
        let keys: Vec<_> = self.entries.keys()
            .filter(|k| self.entries[*k].tag.starts_with(tag_prefix))
            .cloned()
            .collect();
        for k in &keys {
            self.entries.remove(k);
        }
        keys
    }

    pub fn remove(&mut self, key: &str) {
        self.entries.remove(key);
    }

    pub fn persist(&self) -> Result<()> {
        let json = serde_json::to_string_pretty(&self.entries)?;
        std::fs::write(&self.path, json)?;
        Ok(())
    }
}

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

    fn tmp_dir() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    #[test]
    fn entry_meta_priority_increases_with_compute_cost() {
        let cheap = EntryMeta::new(1_000, 0, 0, 0, 100, "");
        let expensive = EntryMeta::new(1_000_000_000, 0, 0, 0, 100, "");
        assert!(expensive.priority() > cheap.priority());
    }

    #[test]
    fn entry_meta_touch_increments_access() {
        let mut m = EntryMeta::new(1000, 0, 0, 0, 100, "test");
        assert_eq!(m.access_count, 1);
        m.touch();
        assert_eq!(m.access_count, 2);
    }

    #[test]
    fn entry_meta_with_blob() {
        let m = EntryMeta::new(1000, 0, 0, 0, 100, "v1")
            .with_blob("abcd1234", true);
        assert_eq!(m.blob_hash, "abcd1234");
        assert!(m.is_hashseq);
    }

    #[test]
    fn entry_meta_blob_hash_defaults_empty() {
        let json = r#"{"compute_ns":1000,"created_at":1735689600,"last_access":1735689600,"access_count":1,"size_bytes":100,"tag":"v1"}"#;
        let m: EntryMeta = serde_json::from_str(json).unwrap();
        assert!(m.blob_hash.is_empty());
        assert!(!m.is_hashseq);
    }

    #[test]
    fn eviction_index_load_empty() {
        let dir = tmp_dir();
        let idx = EvictionIndex::load(dir.path()).unwrap();
        assert_eq!(idx.total_bytes(), 0);
    }

    #[test]
    fn eviction_index_record_and_persist_with_blob() {
        let dir = tmp_dir();
        let mut idx = EvictionIndex::load(dir.path()).unwrap();
        idx.record("abc", EntryMeta::new(5000, 0, 0, 0, 256, "v1")
            .with_blob("deadbeef", false));
        idx.record("def", EntryMeta::new(9000, 0, 0, 0, 512, "v1")
            .with_blob("cafebabe", true));
        assert_eq!(idx.total_bytes(), 768);
        idx.persist().unwrap();

        // Reload: blob_hash survives
        let idx2 = EvictionIndex::load(dir.path()).unwrap();
        assert_eq!(idx2.total_bytes(), 768);
        assert_eq!(idx2.get_meta("abc").unwrap().blob_hash, "deadbeef");
        assert!(idx2.get_meta("def").unwrap().is_hashseq);
    }

    #[test]
    fn eviction_index_entries_iterator() {
        let dir = tmp_dir();
        let mut idx = EvictionIndex::load(dir.path()).unwrap();
        idx.record("a", EntryMeta::new(100, 0, 0, 0, 10, "t").with_blob("h1", false));
        idx.record("b", EntryMeta::new(200, 0, 0, 0, 20, "t").with_blob("h2", true));
        assert_eq!(idx.entries().count(), 2);
    }

    #[test]
    fn eviction_candidates_under_budget_returns_empty() {
        let dir = tmp_dir();
        let mut idx = EvictionIndex::load(dir.path()).unwrap();
        idx.record("a", EntryMeta::new(1000, 0, 0, 0, 100, ""));
        idx.record("b", EntryMeta::new(1000, 0, 0, 0, 100, ""));
        assert!(idx.eviction_candidates(1000).is_empty());
    }

    #[test]
    fn eviction_candidates_over_budget_evicts_lowest_priority() {
        let dir = tmp_dir();
        let mut idx = EvictionIndex::load(dir.path()).unwrap();
        idx.record("cheap", EntryMeta::new(1, 0, 0, 0, 500, ""));
        idx.record("expensive", EntryMeta::new(1_000_000_000, 0, 0, 0, 500, ""));
        let evicted = idx.eviction_candidates(500);
        assert_eq!(evicted.len(), 1);
        assert_eq!(evicted[0], "cheap");
    }

    #[test]
    fn eviction_unlimited_budget_never_evicts() {
        let dir = tmp_dir();
        let mut idx = EvictionIndex::load(dir.path()).unwrap();
        idx.record("a", EntryMeta::new(1000, 0, 0, 0, 999_999_999, ""));
        assert!(idx.eviction_candidates(0).is_empty());
    }

    #[test]
    fn evict_by_tag_removes_matching() {
        let dir = tmp_dir();
        let mut idx = EvictionIndex::load(dir.path()).unwrap();
        idx.record("a", EntryMeta::new(1000, 0, 0, 0, 100, "v1"));
        idx.record("b", EntryMeta::new(1000, 0, 0, 0, 100, "v2"));
        idx.record("c", EntryMeta::new(1000, 0, 0, 0, 100, "v1.1"));
        let removed = idx.evict_by_tag("v1");
        assert_eq!(removed.len(), 2);
        assert_eq!(idx.total_bytes(), 100);
    }

    #[test]
    fn touch_updates_existing_entry() {
        let dir = tmp_dir();
        let mut idx = EvictionIndex::load(dir.path()).unwrap();
        idx.record("key", EntryMeta::new(1000, 0, 0, 0, 100, ""));
        idx.touch("key");
        idx.touch("nonexistent");
    }
}
