//! Bounded render cache for expensive per-frame computations.
//!
//! Caches pre-computed render artifacts (markdown to styled text, discussion
//! tree layout, issue body rendering) keyed on `(content_hash, terminal_width)`.
//! Width is part of the key because line wrapping changes with terminal size.
//!
//! Invalidation strategies:
//! - **Width change** (`invalidate_width`): purge entries not matching current width
//! - **Theme change** (`invalidate_all`): full clear (colors changed)
//!
//! Single-threaded (TUI event loop) — no `Arc`/`Mutex` needed.

use std::collections::HashMap;

/// Default render cache capacity.
const DEFAULT_CAPACITY: usize = 256;

/// Cache key: content identity + terminal width that produced the render.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RenderCacheKey {
    /// Hash of the source content (e.g., `DefaultHasher` or FxHash of text).
    pub content_hash: u64,
    /// Terminal width at the time of rendering.
    pub terminal_width: u16,
}

impl RenderCacheKey {
    /// Create a new render cache key.
    #[must_use]
    pub fn new(content_hash: u64, terminal_width: u16) -> Self {
        Self {
            content_hash,
            terminal_width,
        }
    }
}

/// Bounded cache for pre-computed render artifacts.
///
/// Uses simple capacity-bounded insertion. When at capacity, the oldest
/// entry (lowest insertion order) is evicted. This is simpler than full
/// LRU because render cache hits tend to be ephemeral — the current
/// frame's renders are the most important.
pub struct RenderCache<V> {
    entries: HashMap<RenderCacheKey, (V, u64)>,
    capacity: usize,
    tick: u64,
}

impl<V> RenderCache<V> {
    /// Create a new cache with the default capacity (256).
    #[must_use]
    pub fn new() -> Self {
        Self {
            entries: HashMap::with_capacity(DEFAULT_CAPACITY),
            capacity: DEFAULT_CAPACITY,
            tick: 0,
        }
    }

    /// Create a new cache with the given capacity.
    ///
    /// # Panics
    /// Panics if `capacity` is zero.
    #[must_use]
    pub fn with_capacity(capacity: usize) -> Self {
        assert!(capacity > 0, "RenderCache capacity must be > 0");
        Self {
            entries: HashMap::with_capacity(capacity),
            capacity,
            tick: 0,
        }
    }

    /// Look up a cached render artifact.
    pub fn get(&self, key: &RenderCacheKey) -> Option<&V> {
        self.entries.get(key).map(|(v, _)| v)
    }

    /// Insert a render artifact, evicting the oldest entry if at capacity.
    pub fn put(&mut self, key: RenderCacheKey, value: V) {
        self.tick += 1;
        let tick = self.tick;

        if let Some(entry) = self.entries.get_mut(&key) {
            *entry = (value, tick);
            return;
        }

        if self.entries.len() >= self.capacity
            && let Some(oldest_key) = self
                .entries
                .iter()
                .min_by_key(|(_, (_, t))| *t)
                .map(|(k, _)| *k)
        {
            self.entries.remove(&oldest_key);
        }

        self.entries.insert(key, (value, tick));
    }

    /// Remove entries NOT matching the given width (terminal resize).
    ///
    /// After a resize, only entries rendered at the new width are still valid.
    pub fn invalidate_width(&mut self, keep_width: u16) {
        self.entries.retain(|k, _| k.terminal_width == keep_width);
    }

    /// Clear the entire cache (theme change — all colors invalidated).
    pub fn invalidate_all(&mut self) {
        self.entries.clear();
    }

    /// Number of entries currently cached.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether the cache is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

impl<V> Default for RenderCache<V> {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn key(hash: u64, width: u16) -> RenderCacheKey {
        RenderCacheKey::new(hash, width)
    }

    #[test]
    fn test_get_returns_recently_put_item() {
        let mut cache = RenderCache::with_capacity(4);
        cache.put(key(100, 80), "rendered-a");
        assert_eq!(cache.get(&key(100, 80)), Some(&"rendered-a"));
    }

    #[test]
    fn test_get_returns_none_for_missing_key() {
        let cache: RenderCache<&str> = RenderCache::with_capacity(4);
        assert_eq!(cache.get(&key(100, 80)), None);
    }

    #[test]
    fn test_same_hash_different_width_are_separate() {
        let mut cache = RenderCache::with_capacity(4);
        cache.put(key(100, 80), "wide");
        cache.put(key(100, 40), "narrow");

        assert_eq!(cache.get(&key(100, 80)), Some(&"wide"));
        assert_eq!(cache.get(&key(100, 40)), Some(&"narrow"));
        assert_eq!(cache.len(), 2);
    }

    #[test]
    fn test_put_overwrites_existing_key() {
        let mut cache = RenderCache::with_capacity(4);
        cache.put(key(100, 80), "v1");
        cache.put(key(100, 80), "v2");
        assert_eq!(cache.get(&key(100, 80)), Some(&"v2"));
        assert_eq!(cache.len(), 1);
    }

    #[test]
    fn test_eviction_at_capacity() {
        let mut cache = RenderCache::with_capacity(2);
        cache.put(key(1, 80), "a"); // tick 1
        cache.put(key(2, 80), "b"); // tick 2
        cache.put(key(3, 80), "c"); // tick 3 -> evicts key(1) (tick 1, oldest)

        assert_eq!(cache.get(&key(1, 80)), None, "oldest should be evicted");
        assert_eq!(cache.get(&key(2, 80)), Some(&"b"));
        assert_eq!(cache.get(&key(3, 80)), Some(&"c"));
    }

    #[test]
    fn test_invalidate_width_removes_non_matching() {
        let mut cache = RenderCache::with_capacity(8);
        cache.put(key(1, 80), "a");
        cache.put(key(2, 80), "b");
        cache.put(key(3, 120), "c");
        cache.put(key(4, 40), "d");

        cache.invalidate_width(80);

        assert_eq!(cache.get(&key(1, 80)), Some(&"a"), "width=80 kept");
        assert_eq!(cache.get(&key(2, 80)), Some(&"b"), "width=80 kept");
        assert_eq!(cache.get(&key(3, 120)), None, "width=120 removed");
        assert_eq!(cache.get(&key(4, 40)), None, "width=40 removed");
        assert_eq!(cache.len(), 2);
    }

    #[test]
    fn test_invalidate_all_clears_everything() {
        let mut cache = RenderCache::with_capacity(8);
        cache.put(key(1, 80), "a");
        cache.put(key(2, 120), "b");
        cache.put(key(3, 40), "c");

        cache.invalidate_all();

        assert!(cache.is_empty());
        assert_eq!(cache.len(), 0);
    }

    #[test]
    fn test_default_capacity_is_256() {
        let cache: RenderCache<String> = RenderCache::new();
        assert_eq!(cache.capacity, DEFAULT_CAPACITY);
        assert_eq!(cache.capacity, 256);
    }

    #[test]
    fn test_len_and_is_empty() {
        let mut cache = RenderCache::with_capacity(4);
        assert!(cache.is_empty());

        cache.put(key(1, 80), "a");
        assert!(!cache.is_empty());
        assert_eq!(cache.len(), 1);
    }

    #[test]
    #[should_panic(expected = "capacity must be > 0")]
    fn test_zero_capacity_panics() {
        let _: RenderCache<String> = RenderCache::with_capacity(0);
    }

    #[test]
    fn test_invalidate_width_on_empty_cache_is_noop() {
        let mut cache: RenderCache<&str> = RenderCache::with_capacity(4);
        cache.invalidate_width(80);
        assert!(cache.is_empty());
    }
}
