//! LLM response cache using BLAKE3 content hashing.
//!
//! Caches Claude API responses to avoid redundant calls for the same
//! file content. The cache key includes both the content hash and model
//! identifier to ensure version consistency.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use tracing::{debug, instrument};

/// A cached LLM response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedResponse {
    /// The extracted claims as JSON (raw from LLM).
    pub claims_json: String,
    /// When this response was cached (Unix timestamp).
    pub cached_at: u64,
    /// Number of input tokens used.
    pub input_tokens: usize,
    /// Number of output tokens generated.
    pub output_tokens: usize,
}

/// LLM response cache backed by filesystem.
pub struct LlmCache {
    /// Directory for cache files.
    cache_dir: PathBuf,
}

impl LlmCache {
    /// Create a new cache with the specified directory.
    pub fn new(cache_dir: PathBuf) -> Self {
        Self { cache_dir }
    }

    /// Generate a cache key from content, model, and prompt.
    ///
    /// The key is a BLAKE3 hash of:
    /// - File content
    /// - Model identifier
    /// - System prompt (ensures cache invalidation when prompt changes)
    ///
    /// This replaces the previous hardcoded `PROMPT_VERSION` approach with
    /// actual prompt content, enabling automatic cache invalidation when
    /// prompts are modified.
    pub fn cache_key(content: &str, model: &str, prompt: &str) -> String {
        let mut hasher = blake3::Hasher::new();
        hasher.update(content.as_bytes());
        hasher.update(b"|");
        hasher.update(model.as_bytes());
        hasher.update(b"|");
        hasher.update(prompt.as_bytes());

        let hash = hasher.finalize();
        hex::encode(&hash.as_bytes()[..16]) // Use first 16 bytes (32 hex chars)
    }

    /// Compute the hash of a prompt for observation tracking.
    ///
    /// This returns a shorter hash suitable for database indexing
    /// and human-readable display.
    pub fn prompt_hash(prompt: &str) -> String {
        let hash = blake3::hash(prompt.as_bytes());
        hex::encode(&hash.as_bytes()[..8]) // First 8 bytes = 16 hex chars
    }

    /// Get a cached response if it exists.
    #[instrument(skip(self), fields(cache_dir = %self.cache_dir.display()))]
    pub fn get(&self, key: &str) -> Option<CachedResponse> {
        let cache_file = self.cache_dir.join(format!("{}.json", key));

        if !cache_file.exists() {
            debug!(key, "Cache miss");
            return None;
        }

        match std::fs::read_to_string(&cache_file) {
            Ok(content) => match serde_json::from_str(&content) {
                Ok(response) => {
                    debug!(key, "Cache hit");
                    Some(response)
                }
                Err(e) => {
                    debug!(key, error = %e, "Failed to parse cached response");
                    None
                }
            },
            Err(e) => {
                debug!(key, error = %e, "Failed to read cache file");
                None
            }
        }
    }

    /// Store a response in the cache.
    #[instrument(skip(self, response), fields(cache_dir = %self.cache_dir.display()))]
    pub fn put(&self, key: &str, response: &CachedResponse) {
        // Ensure cache directory exists
        if let Err(e) = std::fs::create_dir_all(&self.cache_dir) {
            debug!(error = %e, "Failed to create cache directory");
            return;
        }

        let cache_file = self.cache_dir.join(format!("{}.json", key));

        match serde_json::to_string_pretty(response) {
            Ok(content) => {
                if let Err(e) = std::fs::write(&cache_file, content) {
                    debug!(key, error = %e, "Failed to write cache file");
                } else {
                    debug!(key, "Cached response");
                }
            }
            Err(e) => {
                debug!(key, error = %e, "Failed to serialize response for cache");
            }
        }
    }
}

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

    #[test]
    fn test_cache_key_deterministic() {
        let prompt = "Extract security claims";
        let key1 = LlmCache::cache_key("hello world", "claude-sonnet-4-20250514", prompt);
        let key2 = LlmCache::cache_key("hello world", "claude-sonnet-4-20250514", prompt);
        assert_eq!(key1, key2);
    }

    #[test]
    fn test_cache_key_different_content() {
        let prompt = "Extract security claims";
        let key1 = LlmCache::cache_key("hello", "claude-sonnet-4-20250514", prompt);
        let key2 = LlmCache::cache_key("world", "claude-sonnet-4-20250514", prompt);
        assert_ne!(key1, key2);
    }

    #[test]
    fn test_cache_key_different_model() {
        let prompt = "Extract security claims";
        let key1 = LlmCache::cache_key("hello", "claude-sonnet-4-20250514", prompt);
        let key2 = LlmCache::cache_key("hello", "claude-3-opus-20240229", prompt);
        assert_ne!(key1, key2);
    }

    #[test]
    fn test_cache_key_different_prompt() {
        let key1 = LlmCache::cache_key("hello", "gemini-3-flash-preview", "prompt v1");
        let key2 = LlmCache::cache_key("hello", "gemini-3-flash-preview", "prompt v2");
        assert_ne!(key1, key2);
    }

    #[test]
    fn test_prompt_hash() {
        let hash1 = LlmCache::prompt_hash("my prompt");
        let hash2 = LlmCache::prompt_hash("my prompt");
        assert_eq!(hash1, hash2);
        assert_eq!(hash1.len(), 16); // 8 bytes = 16 hex chars

        let hash3 = LlmCache::prompt_hash("different prompt");
        assert_ne!(hash1, hash3);
    }

    #[test]
    fn test_cache_round_trip() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let cache = LlmCache::new(temp_dir.path().to_path_buf());

        let response = CachedResponse {
            claims_json: r#"{"claims": []}"#.to_string(),
            cached_at: 12345,
            input_tokens: 100,
            output_tokens: 50,
        };

        let key = "test-key";
        cache.put(key, &response);

        let retrieved = cache.get(key).expect("should find cached response");
        assert_eq!(retrieved.claims_json, response.claims_json);
        assert_eq!(retrieved.cached_at, response.cached_at);
        assert_eq!(retrieved.input_tokens, response.input_tokens);
        assert_eq!(retrieved.output_tokens, response.output_tokens);
    }

    #[test]
    fn test_cache_miss() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let cache = LlmCache::new(temp_dir.path().to_path_buf());

        let result = cache.get("nonexistent-key");
        assert!(result.is_none());
    }
}
