//! Verdict cache: separate from the test-result cache.
//!
//! Why a separate cache: the test-result cache keys on call shape. The
//! verdict cache keys on the LLM judge's inputs (model, prompt-template
//! version, criteria, prompt, response). Two runs of the same test can
//! produce the same call shape but different judge inputs (different
//! criteria revisions for example), and conversely the same judge inputs
//! can appear across tests that send different call shapes. Sharing one
//! namespace would either over-invalidate or leak verdicts between tests.
//!
//! The cache is **opt-in**. LLM judges are non-deterministic and a stale
//! cached verdict can mask drift; ADR 0008 calls this out explicitly. Tests
//! enable caching by setting `cache.verdicts: true` inside the YAML
//! `evals:` block. The `--no-verdict-cache` CLI flag (wired
//! against the next ENGINE batch) overrides the YAML setting for spot
//! checks.

use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use etcetera::base_strategy::{choose_base_strategy, BaseStrategy};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;

/// Default TTL for cached verdicts: 24 hours.
///
/// Picked to match the test-result default so a single overnight CI cycle
/// either uses fresh verdicts or refreshes them. Tests override per-suite
/// via `cache.verdicts_ttl`.
pub const DEFAULT_TTL_SECS: u64 = 24 * 60 * 60;

const VERDICTS_DIR: &str = "verdicts";

/// Inputs that uniquely identify one verdict.
///
/// All five fields contribute to the SHA-256 key; changing any of them
/// produces a different cache slot, so a juror upgrade, a criteria edit, or
/// a prompt rewrite never collides with old verdicts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerdictKey<'a> {
    /// Identifier for the juror model (provider + model name).
    pub juror_model: &'a str,
    /// Version tag of the juror prompt template.
    pub juror_prompt_template_version: &'a str,
    /// Stable serialization of the eval criteria.
    pub criteria: &'a str,
    /// The prompt sent to the juror.
    pub prompt: &'a str,
    /// The candidate response under evaluation.
    pub response: &'a str,
}

impl<'a> VerdictKey<'a> {
    /// Hash the five fields into a stable hex key.
    ///
    /// Field separators are NUL bytes so a malicious response containing
    /// the separator cannot impersonate another tuple.
    pub fn fingerprint(&self) -> String {
        let mut hasher = Sha256::new();
        hasher.update(self.juror_model.as_bytes());
        hasher.update([0u8]);
        hasher.update(self.juror_prompt_template_version.as_bytes());
        hasher.update([0u8]);
        hasher.update(self.criteria.as_bytes());
        hasher.update([0u8]);
        hasher.update(self.prompt.as_bytes());
        hasher.update([0u8]);
        hasher.update(self.response.as_bytes());
        format!("{:x}", hasher.finalize())
    }
}

/// Persistent shape of one cached verdict.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VerdictRecord {
    /// Verdict label (`pass`, `fail`, or a free-form judgement string).
    pub verdict: String,
    /// Optional confidence score in `[0, 1]`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score: Option<f64>,
    /// Free-form rationale text the juror returned.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rationale: Option<String>,
    /// Unix epoch seconds when this verdict was recorded.
    pub stored_at: u64,
    /// TTL in seconds. The reader treats the entry as missing past
    /// `stored_at + ttl_secs`.
    pub ttl_secs: u64,
}

impl VerdictRecord {
    /// Build a record stamped with `now`.
    pub fn new(verdict: impl Into<String>, ttl_secs: u64, now: SystemTime) -> Self {
        Self {
            verdict: verdict.into(),
            score: None,
            rationale: None,
            stored_at: now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
            ttl_secs,
        }
    }

    /// Attach a confidence score.
    pub fn with_score(mut self, score: f64) -> Self {
        self.score = Some(score);
        self
    }

    /// Attach a rationale string.
    pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
        self.rationale = Some(rationale.into());
        self
    }

    /// `true` when `stored_at + ttl_secs` is before `now`.
    pub fn is_expired(&self, now: SystemTime) -> bool {
        let current = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
        current.saturating_sub(self.stored_at) >= self.ttl_secs
    }
}

/// Runtime configuration for the verdict cache.
#[derive(Debug, Clone)]
pub struct VerdictCacheConfig {
    /// Cache root directory.
    pub root: PathBuf,
    /// Default TTL applied when a record is stored without one.
    pub default_ttl_secs: u64,
    /// `true` when reads and writes are active. Defaults to `false`
    /// (opt-in per ADR 0008).
    pub enabled: bool,
}

impl Default for VerdictCacheConfig {
    fn default() -> Self {
        Self {
            root: default_cache_root(),
            default_ttl_secs: DEFAULT_TTL_SECS,
            enabled: false,
        }
    }
}

/// Default cache root: `~/.cache/mcptest/verdicts`.
///
/// Falls back to `./.mcptest-cache/verdicts` when the per-OS cache dir
/// cannot be resolved (rare; happens on locked-down container environments).
pub fn default_cache_root() -> PathBuf {
    match choose_base_strategy().ok().map(|s| s.cache_dir()) {
        Some(base) => base.join("mcptest").join(VERDICTS_DIR),
        None => PathBuf::from(".mcptest-cache").join(VERDICTS_DIR),
    }
}

/// Per-instance hit/miss counters.
///
/// Reporters consume `hits` to surface `verdict_cache_hits: N` in the
/// canonical run summary (`RunSummary::verdict_cache_hits`).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct VerdictCacheStats {
    /// Successful reads of a non-expired record.
    pub hits: u64,
    /// Lookups that missed (no file or expired entry).
    pub misses: u64,
    /// Records written.
    pub stores: u64,
}

/// Errors raised by the cache.
#[derive(Debug, Error)]
pub enum VerdictCacheError {
    /// Underlying filesystem error.
    #[error("verdict cache I/O: {0}")]
    Io(#[from] std::io::Error),
    /// JSON serialization or parse error.
    #[error("verdict cache JSON: {0}")]
    Json(#[from] serde_json::Error),
}

/// File-backed verdict cache.
///
/// One JSON file per fingerprint under `<root>/<fingerprint>.json`. The
/// flat layout means there is no index file to corrupt; the cache is
/// trivially rebuildable by deleting the directory.
#[derive(Debug)]
pub struct VerdictCache {
    config: VerdictCacheConfig,
    stats: VerdictCacheStats,
}

impl VerdictCache {
    /// Build a cache with the given config.
    pub fn new(config: VerdictCacheConfig) -> Self {
        Self {
            config,
            stats: VerdictCacheStats::default(),
        }
    }

    /// Build a disabled cache pointing at the default root.
    pub fn disabled() -> Self {
        Self::new(VerdictCacheConfig::default())
    }

    /// Build a cache enabled at the given root.
    pub fn enabled_at(root: impl Into<PathBuf>) -> Self {
        Self::new(VerdictCacheConfig {
            root: root.into(),
            default_ttl_secs: DEFAULT_TTL_SECS,
            enabled: true,
        })
    }

    /// `true` when the cache is operating.
    pub fn is_enabled(&self) -> bool {
        self.config.enabled
    }

    /// Current stats snapshot.
    pub fn stats(&self) -> VerdictCacheStats {
        self.stats
    }

    /// Look up `key`, returning `Some(record)` on a fresh hit, `None`
    /// otherwise.
    ///
    /// Updates the hit/miss counters. Disabled caches always miss.
    pub fn get(
        &mut self,
        key: &VerdictKey<'_>,
    ) -> Result<Option<VerdictRecord>, VerdictCacheError> {
        if !self.config.enabled {
            self.stats.misses = self.stats.misses.saturating_add(1);
            return Ok(None);
        }
        let path = self.path_for(key);
        let text = match fs::read_to_string(&path) {
            Ok(text) => text,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                self.stats.misses = self.stats.misses.saturating_add(1);
                return Ok(None);
            }
            Err(err) => return Err(err.into()),
        };
        let record: VerdictRecord = serde_json::from_str(&text)?;
        if record.is_expired(SystemTime::now()) {
            // Best-effort eviction; ignore failure because the next write
            // will overwrite the entry anyway.
            let _ = fs::remove_file(&path);
            self.stats.misses = self.stats.misses.saturating_add(1);
            return Ok(None);
        }
        self.stats.hits = self.stats.hits.saturating_add(1);
        Ok(Some(record))
    }

    /// Store `record` under `key`.
    ///
    /// Disabled caches accept the call as a no-op so callers do not need to
    /// gate writes themselves.
    pub fn put(
        &mut self,
        key: &VerdictKey<'_>,
        record: &VerdictRecord,
    ) -> Result<(), VerdictCacheError> {
        if !self.config.enabled {
            return Ok(());
        }
        fs::create_dir_all(&self.config.root)?;
        let path = self.path_for(key);
        let text = serde_json::to_string(record)?;
        fs::write(&path, text)?;
        self.stats.stores = self.stats.stores.saturating_add(1);
        Ok(())
    }

    fn path_for(&self, key: &VerdictKey<'_>) -> PathBuf {
        let mut path = self.config.root.clone();
        path.push(format!("{}.json", key.fingerprint()));
        path
    }

    /// Combined helper: get if present, otherwise compute and store.
    ///
    /// `producer` is invoked only on a miss. Useful from the jury runner
    /// where the caller has the juror invocation handy.
    pub fn get_or_compute<F>(
        &mut self,
        key: &VerdictKey<'_>,
        ttl_secs: u64,
        producer: F,
    ) -> Result<(VerdictRecord, bool), VerdictCacheError>
    where
        F: FnOnce() -> VerdictRecord,
    {
        if let Some(record) = self.get(key)? {
            return Ok((record, true));
        }
        let mut record = producer();
        // Apply the suite-level TTL when the producer left the default; tests
        // can override per-juror by setting `ttl_secs` directly on the record.
        if record.ttl_secs == 0 {
            record.ttl_secs = if ttl_secs == 0 {
                self.config.default_ttl_secs
            } else {
                ttl_secs
            };
        }
        self.put(key, &record)?;
        Ok((record, false))
    }
}

/// Path to one cached verdict, exposed for assertion helpers in tests.
pub fn cache_path_for(root: &Path, key: &VerdictKey<'_>) -> PathBuf {
    let mut path = root.to_path_buf();
    path.push(format!("{}.json", key.fingerprint()));
    path
}

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

    fn key_with(prompt: &str, response: &str) -> VerdictKey<'static> {
        VerdictKey {
            juror_model: "anthropic/claude-3-5-sonnet",
            juror_prompt_template_version: "v1",
            criteria: "{\"accuracy\":true}",
            prompt: Box::leak(prompt.to_string().into_boxed_str()),
            response: Box::leak(response.to_string().into_boxed_str()),
        }
    }

    #[test]
    fn fingerprint_is_stable_across_calls() {
        let key = key_with("Q?", "A.");
        assert_eq!(key.fingerprint(), key.fingerprint());
    }

    #[test]
    fn fingerprint_changes_with_each_field() {
        let base = key_with("Q?", "A.");
        let alt_model = VerdictKey {
            juror_model: "openai/gpt-5",
            ..base
        };
        let alt_template = VerdictKey {
            juror_prompt_template_version: "v2",
            ..base
        };
        let alt_criteria = VerdictKey {
            criteria: "{\"accuracy\":false}",
            ..base
        };
        let alt_prompt = key_with("different prompt", "A.");
        let alt_response = key_with("Q?", "different response");
        let base_fp = base.fingerprint();
        for other in [
            alt_model.fingerprint(),
            alt_template.fingerprint(),
            alt_criteria.fingerprint(),
            alt_prompt.fingerprint(),
            alt_response.fingerprint(),
        ] {
            assert_ne!(base_fp, other);
        }
    }

    #[test]
    fn put_then_get_returns_stored_record() {
        let dir = tempdir().unwrap();
        let mut cache = VerdictCache::enabled_at(dir.path());
        let key = key_with("hello", "world");
        let rec = VerdictRecord::new("pass", DEFAULT_TTL_SECS, SystemTime::now());
        cache.put(&key, &rec).unwrap();
        let fetched = cache.get(&key).unwrap().unwrap();
        assert_eq!(fetched.verdict, "pass");
        assert_eq!(cache.stats().hits, 1);
        assert_eq!(cache.stats().stores, 1);
    }

    #[test]
    fn get_on_missing_key_records_a_miss() {
        let dir = tempdir().unwrap();
        let mut cache = VerdictCache::enabled_at(dir.path());
        let key = key_with("missing", "missing");
        assert!(cache.get(&key).unwrap().is_none());
        assert_eq!(cache.stats().misses, 1);
    }

    #[test]
    fn expired_record_is_treated_as_miss() {
        let dir = tempdir().unwrap();
        let mut cache = VerdictCache::enabled_at(dir.path());
        let key = key_with("ttl", "ttl");
        let stale = VerdictRecord {
            verdict: "stale".into(),
            score: None,
            rationale: None,
            stored_at: 0,
            ttl_secs: 1,
        };
        cache.put(&key, &stale).unwrap();
        let path = cache_path_for(dir.path(), &key);
        assert!(path.exists());
        assert!(cache.get(&key).unwrap().is_none());
        assert_eq!(cache.stats().misses, 1);
        assert!(!path.exists(), "expired entry should be evicted");
    }

    #[test]
    fn disabled_cache_never_writes_or_reads() {
        let dir = tempdir().unwrap();
        let mut cache = VerdictCache::new(VerdictCacheConfig {
            root: dir.path().to_path_buf(),
            default_ttl_secs: DEFAULT_TTL_SECS,
            enabled: false,
        });
        let key = key_with("p", "r");
        let rec = VerdictRecord::new("pass", DEFAULT_TTL_SECS, SystemTime::now());
        cache.put(&key, &rec).unwrap();
        assert!(cache.get(&key).unwrap().is_none());
        assert_eq!(cache.stats().stores, 0);
        // Disabled cache reports misses so downstream stats stay accurate.
        assert!(cache.stats().misses >= 1);
        assert!(std::fs::read_dir(dir.path()).unwrap().next().is_none());
    }

    #[test]
    fn get_or_compute_writes_on_miss_then_returns_on_hit() {
        let dir = tempdir().unwrap();
        let mut cache = VerdictCache::enabled_at(dir.path());
        let key = key_with("a", "b");
        let (rec1, hit1) = cache
            .get_or_compute(&key, 60, || {
                VerdictRecord::new("pass", 60, SystemTime::now())
            })
            .unwrap();
        let (rec2, hit2) = cache
            .get_or_compute(&key, 60, || {
                panic!("producer should not run on hit");
            })
            .unwrap();
        assert!(!hit1);
        assert!(hit2);
        assert_eq!(rec1.verdict, "pass");
        assert_eq!(rec2.verdict, "pass");
    }

    #[test]
    fn default_root_lives_under_user_cache_dir() {
        let root = default_cache_root();
        assert!(root.ends_with(VERDICTS_DIR));
    }
}
