//! LRU audio cache keyed by `(text_sha256, voice_id, provider, params_hash)`.
//!
//! Repeated synth of identical text + identical params is common
//! ("/repetir", greeting, daily summary). Caching the bytes saves
//! a full Edge round-trip + transcode (~1-3s) per hit.
//!
//! Memory-bounded: operator sets `max_bytes`; eviction is least-
//! recently-used. TTL applies independently — entries older than
//! `ttl_seconds` are skipped on read regardless of LRU position.

use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use lru::LruCache;
use sha2::{Digest, Sha256};

/// Composite key fingerprint. Hex sha256 of `text + "\0" + voice +
/// "\0" + provider + "\0" + params_hash` so consumers can stringify
/// for audit events.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey(pub String);

impl CacheKey {
    pub fn build(text: &str, voice_id: &str, provider: &str, params_hash: &str) -> Self {
        let mut h = Sha256::new();
        h.update(text.as_bytes());
        h.update(&[0u8]);
        h.update(voice_id.as_bytes());
        h.update(&[0u8]);
        h.update(provider.as_bytes());
        h.update(&[0u8]);
        h.update(params_hash.as_bytes());
        Self(format!("{:x}", h.finalize()))
    }
}

#[derive(Debug, Clone)]
struct Entry {
    audio_bytes: Vec<u8>,
    inserted_at: Instant,
    bytes: usize,
}

/// Cloneable LRU cache. Clones share the underlying state via Arc.
#[derive(Debug, Clone)]
pub struct AudioCache {
    inner: Arc<Mutex<Inner>>,
    ttl: Duration,
    max_bytes: usize,
}

#[derive(Debug)]
struct Inner {
    lru: LruCache<CacheKey, Entry>,
    current_bytes: usize,
}

impl AudioCache {
    /// `max_bytes = 0` ⇒ cache disabled (every put + get a no-op).
    pub fn new(max_bytes: usize, ttl: Duration) -> Self {
        let cap = std::num::NonZeroUsize::new(if max_bytes == 0 { 1 } else { 16_384 }).unwrap();
        Self {
            inner: Arc::new(Mutex::new(Inner {
                lru: LruCache::new(cap),
                current_bytes: 0,
            })),
            ttl,
            max_bytes,
        }
    }

    /// Fetch when present + fresh. Updates LRU recency on hit.
    pub fn get(&self, key: &CacheKey) -> Option<Vec<u8>> {
        if self.max_bytes == 0 {
            return None;
        }
        let mut g = self.inner.lock().ok()?;
        let entry = g.lru.get(key)?;
        if entry.inserted_at.elapsed() > self.ttl {
            // Stale — drop and return miss.
            let bytes = entry.bytes;
            g.lru.pop(key);
            g.current_bytes = g.current_bytes.saturating_sub(bytes);
            return None;
        }
        Some(entry.audio_bytes.clone())
    }

    /// Insert. Evicts LRU entries until `current_bytes ≤ max_bytes`.
    /// Audio larger than `max_bytes` is silently dropped (caller's
    /// concern; we don't blow the budget on a single insert).
    pub fn put(&self, key: CacheKey, audio_bytes: Vec<u8>) {
        if self.max_bytes == 0 {
            return;
        }
        let bytes = audio_bytes.len();
        if bytes > self.max_bytes {
            return;
        }
        let Ok(mut g) = self.inner.lock() else {
            return;
        };
        // If key existed, subtract its old bytes first.
        if let Some(old) = g.lru.pop(&key) {
            g.current_bytes = g.current_bytes.saturating_sub(old.bytes);
        }
        g.lru.put(
            key,
            Entry {
                audio_bytes,
                inserted_at: Instant::now(),
                bytes,
            },
        );
        g.current_bytes += bytes;
        // Evict LRU until under budget.
        while g.current_bytes > self.max_bytes {
            let Some((_k, evicted)) = g.lru.pop_lru() else {
                break;
            };
            g.current_bytes = g.current_bytes.saturating_sub(evicted.bytes);
        }
    }

    /// Snapshot total bytes held — for stats / metrics.
    pub fn current_bytes(&self) -> usize {
        self.inner.lock().map(|g| g.current_bytes).unwrap_or(0)
    }

    /// Snapshot entry count.
    pub fn len(&self) -> usize {
        self.inner.lock().map(|g| g.lru.len()).unwrap_or(0)
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

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

    fn k(text: &str) -> CacheKey {
        CacheKey::build(text, "v", "p", "h")
    }

    #[test]
    fn put_then_get_roundtrip() {
        let c = AudioCache::new(1024, Duration::from_secs(60));
        c.put(k("hello"), vec![1, 2, 3]);
        assert_eq!(c.get(&k("hello")).unwrap(), vec![1, 2, 3]);
    }

    #[test]
    fn miss_returns_none() {
        let c = AudioCache::new(1024, Duration::from_secs(60));
        assert!(c.get(&k("never inserted")).is_none());
    }

    #[test]
    fn lru_evicts_when_over_budget() {
        let c = AudioCache::new(10, Duration::from_secs(60));
        c.put(k("a"), vec![1; 6]);
        c.put(k("b"), vec![2; 6]); // evicts a (6+6=12 > 10)
        assert!(c.get(&k("a")).is_none());
        assert_eq!(c.get(&k("b")).unwrap().len(), 6);
    }

    #[test]
    fn ttl_expiry_returns_miss() {
        let c = AudioCache::new(1024, Duration::from_millis(10));
        c.put(k("x"), vec![1, 2, 3]);
        std::thread::sleep(Duration::from_millis(20));
        assert!(c.get(&k("x")).is_none());
    }

    #[test]
    fn distinct_params_distinct_keys() {
        let k1 = CacheKey::build("hola", "v1", "edge", "p1");
        let k2 = CacheKey::build("hola", "v2", "edge", "p1");
        assert_ne!(k1, k2);
    }

    #[test]
    fn cache_disabled_when_max_bytes_zero() {
        let c = AudioCache::new(0, Duration::from_secs(60));
        c.put(k("x"), vec![1, 2, 3]);
        assert!(c.get(&k("x")).is_none());
        assert_eq!(c.current_bytes(), 0);
    }
}
