//! `snapshot` matcher: record under `--update-snapshots`, diff otherwise.
//!
//! Why this matcher exists: pinning a large response by hand is tedious and
//! noisy. Snapshots let the test capture what the server returned, save it to
//! disk, and assert on subsequent runs that the server still returns the same
//! shape. Sanitizers scrub volatile fields (timestamps, UUIDs) before
//! diffing so non-deterministic values do not produce false failures.
//! Recording (including the first one) only happens in update mode, so a CI
//! run that lost its baseline fails loudly instead of silently re-recording.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;

use super::{AssertionDiff, AssertionResult, MatcherKind};
use crate::Result;

/// Controls whether [`evaluate`] is allowed to overwrite an existing snapshot.
///
/// The CLI sets this to [`Always`](UpdateSnapshotsMode::Always) when the
/// operator passes `--update-snapshots`, and to
/// [`Never`](UpdateSnapshotsMode::Never) for normal CI runs. Keeping the
/// mode at the matcher boundary means future per-test overrides (a
/// `expect.snapshot.update: true` flag in YAML) can flip it for one
/// assertion without changing the runner contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UpdateSnapshotsMode {
    /// Honor the saved snapshot: diff against it on every run, and fail
    /// when the file is missing rather than auto-creating it. This is the
    /// safe default for CI.
    #[default]
    Never,
    /// Re-record the snapshot every time the matcher runs. The matcher
    /// reports the encounter as a pass; the operator reviews the diff in
    /// `git diff` before committing.
    Always,
}

/// Outcome of evaluating the snapshot matcher.
///
/// Beyond the standard [`AssertionResult`], the snapshot matcher also
/// reports whether it wrote (or rewrote) the saved file on disk. The
/// runner uses this signal to print the "Updated N snapshots" summary
/// described.
#[derive(Debug, Clone)]
pub struct SnapshotEvaluation {
    /// Standard assertion outcome (pass / fail plus optional diff).
    pub result: AssertionResult,
    /// `true` when the matcher touched the file system this call.
    pub updated: bool,
}

/// A scrubber applied to both the saved snapshot and the live `actual` value
/// before comparison.
///
/// Why a small enum and not a closure type: tests are loaded from YAML, so
/// every sanitizer has to round-trip through `serde`. A bounded enum keeps
/// the public surface stable and lets us add variants in future tickets
/// (for example a `Redact(Regex)` variant) without breaking call sites.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Sanitizer {
    /// Remove the values at these JSON Pointer paths before comparing.
    ///
    /// Use this for fields that are server-generated and not under test,
    /// such as `/createdAt` or `/result/id`. Paths that do not exist in the
    /// value are silently ignored, which is what you want for optional fields.
    IgnoreFields {
        /// JSON Pointer paths whose values should be stripped before diffing.
        paths: Vec<String>,
    },
}

/// Evaluate the `snapshot` matcher in the default ("never overwrite") mode.
///
/// Preserved for callers that do not care about the update signal. New code
/// should prefer [`evaluate_with_mode`] so the runner can count how many
/// snapshots were rewritten in a single run.
pub fn evaluate(
    snapshot_path: &Path,
    actual: &Value,
    sanitizers: &[Sanitizer],
) -> Result<AssertionResult> {
    Ok(evaluate_with_mode(
        snapshot_path,
        actual,
        sanitizers,
        UpdateSnapshotsMode::Never,
    )?
    .result)
}

/// Evaluate the `snapshot` matcher.
///
/// When `mode` is [`Always`](UpdateSnapshotsMode::Always), the matcher
/// writes (or rewrites) the saved snapshot from `actual` after sanitizers,
/// regardless of file presence, and reports a pass with `updated=true`.
/// Otherwise the snapshot must already exist: a missing file fails the
/// assertion without writing anything, because a run that lost its
/// baseline should fail loudly rather than silently re-record one. An
/// existing file is loaded, both sides are sanitized, and the results are
/// deep-compared.
pub fn evaluate_with_mode(
    snapshot_path: &Path,
    actual: &Value,
    sanitizers: &[Sanitizer],
    mode: UpdateSnapshotsMode,
) -> Result<SnapshotEvaluation> {
    if mode == UpdateSnapshotsMode::Always {
        // Re-record unconditionally. We persist the sanitized value so the
        // saved file matches what the matcher would diff against on a
        // subsequent run; otherwise sanitized fields would round-trip back
        // in and break determinism.
        let mut to_save = actual.clone();
        for s in sanitizers {
            apply_sanitizer(&mut to_save, s);
        }
        write_snapshot(snapshot_path, &to_save)?;
        return Ok(SnapshotEvaluation {
            result: AssertionResult::passed(MatcherKind::Snapshot),
            updated: true,
        });
    }
    if !snapshot_path.exists() {
        // No auto-create outside update mode: report what we would have
        // recorded (sanitized, like a real record) but touch nothing.
        let mut sanitized = actual.clone();
        for s in sanitizers {
            apply_sanitizer(&mut sanitized, s);
        }
        return Ok(SnapshotEvaluation {
            result: AssertionResult::failed(
                MatcherKind::Snapshot,
                AssertionDiff {
                    path: String::new(),
                    expected: None,
                    actual: Some(sanitized),
                    note: Some(format!(
                        "snapshot missing at {}; run with --update-snapshots to create it",
                        snapshot_path.display()
                    )),
                },
            ),
            updated: false,
        });
    }
    let raw = std::fs::read_to_string(snapshot_path)?;
    let saved: Value = serde_json::from_str(&raw)?;
    let mut left = saved.clone();
    let mut right = actual.clone();
    for s in sanitizers {
        apply_sanitizer(&mut left, s);
        apply_sanitizer(&mut right, s);
    }
    if left == right {
        Ok(SnapshotEvaluation {
            result: AssertionResult::passed(MatcherKind::Snapshot),
            updated: false,
        })
    } else {
        Ok(SnapshotEvaluation {
            result: AssertionResult::failed(
                MatcherKind::Snapshot,
                AssertionDiff {
                    path: String::new(),
                    expected: Some(left),
                    actual: Some(right),
                    note: Some(format!(
                        "snapshot at {} did not match (run with --update-snapshots to refresh)\nsee: {}",
                        snapshot_path.display(),
                        crate::error_anchors::SNAPSHOT_DRIFT
                    )),
                },
            ),
            updated: false,
        })
    }
}

/// Write `value` as pretty JSON with a trailing newline. Creates the parent
/// directory so the user does not need to mkdir it themselves.
fn write_snapshot(path: &Path, value: &Value) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut s = serde_json::to_string_pretty(value)?;
    s.push('\n');
    std::fs::write(path, s)?;
    Ok(())
}

/// Apply one sanitizer in place. Unknown pointer paths are silently skipped
/// because snapshots are often used in tests where some fields are optional.
fn apply_sanitizer(value: &mut Value, sanitizer: &Sanitizer) {
    match sanitizer {
        Sanitizer::IgnoreFields { paths } => {
            for p in paths {
                remove_at_pointer(value, p);
            }
        }
    }
}

/// Remove the value at a JSON Pointer (RFC 6901). For object members the key
/// is removed; for array members the slot becomes `null`. The pointer must
/// start with `/`.
fn remove_at_pointer(value: &mut Value, pointer: &str) {
    if pointer.is_empty() {
        *value = Value::Null;
        return;
    }
    let segments: Vec<String> = pointer
        .trim_start_matches('/')
        .split('/')
        .map(unescape_pointer)
        .collect();
    if segments.is_empty() {
        return;
    }
    let (last, parents) = match segments.split_last() {
        Some(v) => v,
        None => return,
    };
    let mut cursor: &mut Value = value;
    for seg in parents {
        cursor = match cursor {
            Value::Object(m) => match m.get_mut(seg) {
                Some(c) => c,
                None => return,
            },
            Value::Array(a) => match seg.parse::<usize>().ok().and_then(|i| a.get_mut(i)) {
                Some(c) => c,
                None => return,
            },
            _ => return,
        };
    }
    match cursor {
        Value::Object(m) => {
            m.remove(last);
        }
        Value::Array(a) => {
            if let Ok(i) = last.parse::<usize>() {
                if i < a.len() {
                    a[i] = Value::Null;
                }
            }
        }
        _ => {}
    }
}

fn unescape_pointer(seg: &str) -> String {
    seg.replace("~1", "/").replace("~0", "~")
}

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

    fn temp_path(name: &str) -> PathBuf {
        let dir =
            std::env::temp_dir().join(format!("mcptest-snapshot-test-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        dir.join(name)
    }

    /// Seed a snapshot file the way the CLI does under `--update-snapshots`.
    fn record(path: &Path, value: &Value, sanitizers: &[Sanitizer]) {
        let outcome =
            evaluate_with_mode(path, value, sanitizers, UpdateSnapshotsMode::Always).unwrap();
        assert!(outcome.result.pass);
        assert!(outcome.updated);
    }

    #[test]
    fn missing_snapshot_fails_without_update_mode_and_writes_nothing() {
        let path = temp_path("missing.snap.json");
        let _ = std::fs::remove_file(&path);
        let outcome =
            evaluate_with_mode(&path, &json!({"a": 1}), &[], UpdateSnapshotsMode::Never).unwrap();
        assert!(!outcome.result.pass);
        assert!(!outcome.updated);
        assert!(!path.exists(), "missing snapshot must not be auto-created");
        let note = outcome.result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("snapshot missing at"), "note: {note}");
        assert!(
            note.contains("run with --update-snapshots to create it"),
            "note: {note}"
        );
    }

    #[test]
    fn missing_snapshot_failure_reports_sanitized_actual() {
        let path = temp_path("missing-sanitized.snap.json");
        let _ = std::fs::remove_file(&path);
        let sanitizers = vec![Sanitizer::IgnoreFields {
            paths: vec!["/ts".into()],
        }];
        let r = evaluate(&path, &json!({"id": "abc", "ts": 1000}), &sanitizers).unwrap();
        assert!(!r.pass);
        assert_eq!(r.diffs[0].actual, Some(json!({"id": "abc"})));
        assert!(!path.exists());
    }

    #[test]
    fn matching_snapshot_passes_on_second_run() {
        let path = temp_path("match.snap.json");
        let _ = std::fs::remove_file(&path);
        let value = json!({"x": "y"});
        record(&path, &value, &[]);
        let r = evaluate(&path, &value, &[]).unwrap();
        assert!(r.pass);
    }

    #[test]
    fn drifted_snapshot_fails_with_diff() {
        let path = temp_path("drift.snap.json");
        let _ = std::fs::remove_file(&path);
        record(&path, &json!({"x": 1}), &[]);
        let r = evaluate(&path, &json!({"x": 2}), &[]).unwrap();
        assert!(!r.pass);
        let note = r.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("did not match"));
        assert!(
            note.contains(crate::error_anchors::SNAPSHOT_DRIFT),
            "drift note should link the troubleshooting entry: {note}"
        );
    }

    #[test]
    fn ignore_fields_sanitizer_scrubs_volatile_values() {
        // Edge case: timestamp differs but the sanitizer hides it.
        let path = temp_path("scrub.snap.json");
        let _ = std::fs::remove_file(&path);
        let saved = json!({"id": "abc", "ts": 1000});
        record(&path, &saved, &[]);
        let live = json!({"id": "abc", "ts": 2000});
        let sanitizers = vec![Sanitizer::IgnoreFields {
            paths: vec!["/ts".into()],
        }];
        let r = evaluate(&path, &live, &sanitizers).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
    }

    #[test]
    fn ignore_unknown_path_is_a_noop() {
        let path = temp_path("noop.snap.json");
        let _ = std::fs::remove_file(&path);
        record(&path, &json!({"a": 1}), &[]);
        let sanitizers = vec![Sanitizer::IgnoreFields {
            paths: vec!["/does/not/exist".into()],
        }];
        let r = evaluate(&path, &json!({"a": 1}), &sanitizers).unwrap();
        assert!(r.pass);
    }

    #[test]
    fn sanitizer_roundtrips_through_serde_yaml() {
        let original = Sanitizer::IgnoreFields {
            paths: vec!["/result/timestamp".into(), "/result/id".into()],
        };
        let yaml = serde_yaml::to_string(&original).unwrap();
        let parsed: Sanitizer = serde_yaml::from_str(&yaml).unwrap();
        assert_eq!(parsed, original);
    }

    #[test]
    fn sanitizer_deserializes_from_tagged_yaml_document() {
        let yaml = "type: ignore_fields\npaths:\n  - /result/timestamp\n";
        let parsed: Sanitizer = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(
            parsed,
            Sanitizer::IgnoreFields {
                paths: vec!["/result/timestamp".into()],
            }
        );
    }

    #[test]
    fn update_mode_rewrites_existing_snapshot_and_reports_updated() {
        let path = temp_path("update.snap.json");
        let _ = std::fs::remove_file(&path);
        // Seed with a stale value.
        record(&path, &json!({"x": 1}), &[]);
        let live = json!({"x": 99});
        let outcome = evaluate_with_mode(&path, &live, &[], UpdateSnapshotsMode::Always).unwrap();
        assert!(outcome.result.pass);
        assert!(outcome.updated);
        let on_disk: Value =
            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(on_disk, live);
    }

    #[test]
    fn update_mode_writes_first_run_and_reports_updated() {
        let path = temp_path("update-first.snap.json");
        let _ = std::fs::remove_file(&path);
        let outcome =
            evaluate_with_mode(&path, &json!({"a": "b"}), &[], UpdateSnapshotsMode::Always)
                .unwrap();
        assert!(outcome.result.pass);
        assert!(outcome.updated);
        assert!(path.exists());
    }

    #[test]
    fn never_mode_never_reports_updated() {
        let path = temp_path("never.snap.json");
        let _ = std::fs::remove_file(&path);
        record(&path, &json!({"hello": "world"}), &[]);
        let outcome = evaluate_with_mode(
            &path,
            &json!({"hello": "world"}),
            &[],
            UpdateSnapshotsMode::Never,
        )
        .unwrap();
        assert!(outcome.result.pass);
        assert!(!outcome.updated, "matching run should not touch disk");
    }

    #[test]
    fn update_mode_first_write_is_sanitized_and_identical_to_a_re_record() {
        let path = temp_path("sanitized-create.snap.json");
        let _ = std::fs::remove_file(&path);
        let sanitizers = vec![Sanitizer::IgnoreFields {
            paths: vec!["/ts".into()],
        }];
        // First record: file absent, value carries a volatile field.
        record(&path, &json!({"id": "abc", "ts": 111}), &sanitizers);
        let first_bytes = std::fs::read(&path).unwrap();
        let parsed: Value = serde_json::from_slice(&first_bytes).unwrap();
        assert_eq!(
            parsed,
            json!({"id": "abc"}),
            "volatile field must be scrubbed"
        );
        // Re-record with a value differing only in the sanitized field: the
        // bytes on disk must come out identical.
        let _ = std::fs::remove_file(&path);
        record(&path, &json!({"id": "abc", "ts": 999}), &sanitizers);
        let second_bytes = std::fs::read(&path).unwrap();
        assert_eq!(first_bytes, second_bytes);
    }

    #[test]
    fn unknown_sanitizer_variant_returns_serde_error() {
        let yaml = "type: redact\npattern: \"[0-9]+\"\n";
        let err = serde_yaml::from_str::<Sanitizer>(yaml).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("redact") || msg.contains("variant"),
            "unexpected error message: {msg}"
        );
    }
}
