//! `snapshot` matcher evaluation on the live run path.
//!
//! The `snapshot` matcher needs filesystem context the synchronous
//! [`super::check_matcher`] helper does not carry: a directory to resolve
//! the snapshot file against and the `--update-snapshots` decision. This
//! module holds that context ([`SnapshotContext`]) plus the helper that
//! resolves a snapshot key to a path and records or replays it through the
//! shared [`crate::matchers::snapshot`] engine. The async evaluator routes
//! `Matcher::Snapshot` here; the rest of the executor never touches the
//! filesystem.

use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::matchers::snapshot::{evaluate_with_mode, UpdateSnapshotsMode};

/// Filesystem context the `snapshot` matcher needs to record or replay.
///
/// `dir` is the directory snapshot files resolve against (the suite's
/// `snapshots/` folder); a `Matcher::Snapshot(name)` resolves to
/// `dir/name.json` (a `.json` extension is appended when the name has
/// none). `mode` carries the `--update-snapshots` decision. The default
/// (`dir = "snapshots"`, `mode = Never`) diffs against the saved file and
/// fails when the snapshot is missing; only `Always` records, so a green
/// run never silently mints its own baseline (WOR-1292).
#[derive(Debug, Clone)]
pub struct SnapshotContext {
    /// Directory snapshot files live in. Names resolve relative to it.
    pub dir: PathBuf,
    /// Whether the matcher may rewrite an existing snapshot this run.
    pub mode: UpdateSnapshotsMode,
}

impl Default for SnapshotContext {
    fn default() -> Self {
        Self {
            dir: PathBuf::from("snapshots"),
            mode: UpdateSnapshotsMode::Never,
        }
    }
}

/// Evaluate a `snapshot` assertion against the resolved target value.
///
/// `name` is the snapshot key from `Matcher::Snapshot`; it resolves to a
/// file under [`SnapshotContext::dir`]. Under `--update-snapshots` the
/// current value is sanitized and written (record); otherwise the saved
/// value is loaded and deep-compared, a mismatch fails with the matcher's
/// readable diff, and a missing file fails with a hint to record
/// (WOR-1292). The "snapshot recorded" signal is logged here rather than
/// returned because the synchronous assertion contract is pass/fail.
pub(super) fn check_snapshot(
    actual: &Value,
    name: &str,
    snapshot: &SnapshotContext,
) -> Result<(), String> {
    let path = snapshot_path(&snapshot.dir, name);
    let outcome = evaluate_with_mode(&path, actual, &[], snapshot.mode)
        .map_err(|e| format!("snapshot `{name}` could not be read or written: {e}"))?;
    if outcome.updated {
        tracing::info!(
            target: "mcptest_core::executor",
            snapshot = %path.display(),
            "snapshot recorded"
        );
    }
    super::from_assertion(outcome.result)
}

/// Resolve a snapshot key to a file path under `dir`.
///
/// A key with no extension gets `.json` appended so `tools/echo/baseline`
/// becomes `<dir>/tools/echo/baseline.json`. A key that already carries an
/// extension (the operator pointed at a specific file) is used verbatim
/// under `dir`. Nested keys create the intermediate directories on write.
fn snapshot_path(dir: &Path, name: &str) -> PathBuf {
    let candidate = dir.join(name);
    if candidate.extension().is_some() {
        candidate
    } else {
        candidate.with_extension("json")
    }
}

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

    fn temp_dir(tag: &str) -> PathBuf {
        let dir =
            std::env::temp_dir().join(format!("mcptest-exec-snap-{}-{tag}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        dir
    }

    #[test]
    fn records_then_replays_through_a_temp_dir() {
        let dir = temp_dir("record-replay");
        let record = SnapshotContext {
            dir: dir.clone(),
            mode: UpdateSnapshotsMode::Always,
        };
        let value = json!({"a": 1, "b": [2, 3]});
        // Recording requires the explicit update mode.
        check_snapshot(&value, "tools/echo/baseline", &record).expect("record under update mode");
        assert!(dir.join("tools/echo/baseline.json").exists());
        // Replay runs use Never mode and pass on a matching value.
        let replay = SnapshotContext {
            dir: dir.clone(),
            mode: UpdateSnapshotsMode::Never,
        };
        check_snapshot(&value, "tools/echo/baseline", &replay).expect("replay matches");
        // A drifted value fails with a readable diff.
        let drifted = json!({"a": 1, "b": [2, 4]});
        let err = check_snapshot(&drifted, "tools/echo/baseline", &replay)
            .expect_err("drift should fail");
        assert!(err.contains("did not match"), "got: {err}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn missing_snapshot_fails_in_never_mode_with_record_hint() {
        let dir = temp_dir("missing");
        let replay = SnapshotContext {
            dir: dir.clone(),
            mode: UpdateSnapshotsMode::Never,
        };
        let err = check_snapshot(&json!({"v": 1}), "absent", &replay)
            .expect_err("missing snapshot must fail");
        assert!(err.contains("--update-snapshots"), "got: {err}");
        assert!(!dir.join("absent.json").exists(), "nothing may be written");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn update_mode_overwrites_and_passes() {
        let dir = temp_dir("update");
        let record = SnapshotContext {
            dir: dir.clone(),
            mode: UpdateSnapshotsMode::Always,
        };
        check_snapshot(&json!({"v": 1}), "baseline", &record).expect("seed");
        // Update mode rewrites the stale snapshot and passes.
        let update = SnapshotContext {
            dir: dir.clone(),
            mode: UpdateSnapshotsMode::Always,
        };
        check_snapshot(&json!({"v": 99}), "baseline", &update).expect("update overwrites");
        let on_disk: Value =
            serde_json::from_str(&std::fs::read_to_string(dir.join("baseline.json")).unwrap())
                .unwrap();
        assert_eq!(on_disk, json!({"v": 99}));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn path_appends_json_and_respects_explicit_extension() {
        let dir = Path::new("/tmp/snaps");
        assert_eq!(
            snapshot_path(dir, "tools/echo/baseline"),
            Path::new("/tmp/snaps/tools/echo/baseline.json")
        );
        assert_eq!(
            snapshot_path(dir, "explicit.snap"),
            Path::new("/tmp/snaps/explicit.snap")
        );
    }
}
