//! Run-path result cache hook for the live executor (WOR-1398).
//!
//! Why this exists: `mcptest run --cache` opts a suite into serving a tool
//! call's recorded response from a content-addressed store instead of
//! dispatching it live, so a stable read does not pay for the server on every
//! run. It is off by default; a bare `mcptest run` never consults the cache.
//!
//! The eligibility rule is deliberately narrow: only `tools/call` actions are
//! cacheable (resource reads and prompt gets are not), and the key folds the
//! tool name, normalized arguments, the matcher signature, and the runner
//! version, so re-tuning an assertion or bumping the binary never serves a
//! stale hit. Keys are scoped to a per-suite cache directory (the run path
//! roots the store at `<suite_dir>/.mcptest/cache`), so two suites that reuse
//! a server name never collide. A server upgrade is not auto-detected: the
//! cache is opt-in and the author clears it (or runs `mcptest cache clear`)
//! after changing the target, which is why the feature stays off by default.

use std::sync::Arc;

use serde_json::Value;
use sha2::{Digest, Sha256};

use crate::cache::store::{derive, CacheKeyInputs, CacheStore};
use crate::suite::{Action, Assertion};

use super::{McpExecutor, ToolPlan};

/// Installed cache state: the backing store plus the read/write gates that the
/// `--no-cache-read` and `--no-cache-write` flags fold in.
pub(crate) struct ResultCache {
    /// Content-addressed store the executor reads from and writes to.
    store: Arc<dyn CacheStore>,
    /// Whether a hit may be served (cleared by `--no-cache-read`).
    read: bool,
    /// Whether a fresh response may be stored (cleared by `--no-cache-write`).
    write: bool,
}

impl McpExecutor {
    /// Install a run-path result cache (WOR-1398). The store is consulted
    /// before a `tools/call` dispatch and updated after a live one; `read` and
    /// `write` carry the `--no-cache-read` / `--no-cache-write` decisions.
    /// Without this builder the executor never touches the cache.
    pub fn with_cache(mut self, store: Arc<dyn CacheStore>, read: bool, write: bool) -> Self {
        self.result_cache = Some(ResultCache { store, read, write });
        self
    }

    /// Serve a full cached dispatch result for a tool plan, or `None` on a
    /// miss or when caching is off. Mirrors the fixture-injection path:
    /// `measured` is `None` because nothing was dispatched, and the transform
    /// context is rebuilt the same way the live path builds it.
    pub(super) fn cached_dispatch(
        &self,
        test_name: &str,
        tool: &ToolPlan,
    ) -> Option<super::DispatchedTool> {
        let response = self.cache_get(tool)?;
        Some(super::DispatchedTool {
            response,
            ctx: super::dispatch::transform_context(test_name, tool),
            measured: None,
            response_headers: None,
        })
    }

    /// Serve a cached response for a tool plan, or `None` when caching is off,
    /// reads are disabled, the action is not a cacheable `tools/call`, or the
    /// key is absent. The returned value is the stored `{"result": ...}`
    /// envelope the assertions run against.
    pub(super) fn cache_get(&self, tool: &ToolPlan) -> Option<Value> {
        let cache = self.result_cache.as_ref()?;
        if !cache.read {
            return None;
        }
        let key = cache_key(tool)?;
        let bytes = cache.store.get(&key).ok()??;
        serde_json::from_slice(&bytes).ok()
    }

    /// Store a freshly dispatched response under the tool plan's key. A no-op
    /// when caching is off, writes are disabled, or the action is not
    /// cacheable. A store failure never fails the run: the live response is
    /// already in hand, so a full disk just means no speedup next time.
    pub(super) fn cache_put(&self, tool: &ToolPlan, response: &Value) {
        let Some(cache) = self.result_cache.as_ref() else {
            return;
        };
        if !cache.write {
            return;
        }
        let Some(key) = cache_key(tool) else {
            return;
        };
        if let Ok(bytes) = serde_json::to_vec(response) {
            let _ = cache.store.put(&key, &bytes);
        }
    }
}

/// Derive the cache key for a tool plan, or `None` when the action is not a
/// cacheable `tools/call`.
fn cache_key(tool: &ToolPlan) -> Option<String> {
    let Action::ToolCall { tool: name, args } = &tool.action else {
        return None;
    };
    let signature = matcher_signature(&tool.expectations);
    Some(derive(&CacheKeyInputs {
        server_id: &tool.server,
        tool_name: name,
        normalized_args: args,
        server_version_pin: None,
        runner_version: crate::version::semver(),
        matcher_signature: &signature,
    }))
}

/// Stable signature of a test's assertions. [`Assertion`] is not `Serialize`,
/// so we hash its `Debug` form; the runner version is also in the key, so a
/// `Debug` format change across versions never produces a stale hit within a
/// single version.
fn matcher_signature(expectations: &[Assertion]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(format!("{expectations:?}").as_bytes());
    format!("{:x}", hasher.finalize())
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;
    use crate::cache::store::FsCacheStore;
    use crate::connector::ServerPool;
    use crate::suite::Matcher;
    use serde_json::json;

    fn tool_plan(tool: &str, args: Value, target: &str) -> ToolPlan {
        ToolPlan {
            server: "main".to_string(),
            action: Action::ToolCall {
                tool: tool.to_string(),
                args,
            },
            expectations: vec![Assertion {
                target: target.to_string(),
                matcher: Matcher::Exact(json!("ok")),
                weight: 1.0,
                name: None,
                transform: None,
            }],
            budgets: Default::default(),
            transport: Default::default(),
            scoring: Default::default(),
            transform: None,
            inject_error: None,
            metamorphic: None,
            fuzz: None,
            negative_path: None,
            responder: None,
        }
    }

    fn empty_executor() -> McpExecutor {
        McpExecutor::new(Arc::new(ServerPool::new()), HashMap::new())
    }

    #[test]
    fn key_is_stable_for_the_same_plan() {
        let a = cache_key(&tool_plan("search", json!({ "q": "hi" }), "result")).unwrap();
        let b = cache_key(&tool_plan("search", json!({ "q": "hi" }), "result")).unwrap();
        assert_eq!(a, b);
        assert_eq!(a.len(), 64);
    }

    #[test]
    fn key_changes_when_the_matcher_changes() {
        let a = cache_key(&tool_plan("search", json!({ "q": "hi" }), "result.a")).unwrap();
        let b = cache_key(&tool_plan("search", json!({ "q": "hi" }), "result.b")).unwrap();
        assert_ne!(a, b, "re-targeting an assertion must invalidate the key");
    }

    #[test]
    fn non_tool_actions_have_no_key() {
        let mut plan = tool_plan("search", json!({}), "result");
        plan.action = Action::ResourceRead {
            uri: "file:///x".to_string(),
        };
        assert!(cache_key(&plan).is_none());
    }

    #[test]
    fn put_then_get_round_trips_when_enabled() {
        let dir = tempfile::tempdir().unwrap();
        let store = Arc::new(FsCacheStore::with_root(dir.path()));
        let exec = empty_executor().with_cache(store, true, true);
        let plan = tool_plan("search", json!({ "q": "hi" }), "result");
        let envelope = json!({ "result": { "content": [{ "type": "text", "text": "ok" }] } });
        exec.cache_put(&plan, &envelope);
        assert_eq!(exec.cache_get(&plan), Some(envelope));
    }

    #[test]
    fn reads_disabled_never_serve_a_hit() {
        let dir = tempfile::tempdir().unwrap();
        let store = Arc::new(FsCacheStore::with_root(dir.path()));
        // Write allowed, read disabled.
        let exec = empty_executor().with_cache(store, false, true);
        let plan = tool_plan("search", json!({ "q": "hi" }), "result");
        exec.cache_put(&plan, &json!({ "result": "x" }));
        assert_eq!(exec.cache_get(&plan), None);
    }
}
