//! Timing baseline file format and regression detection.
//!
//! See ADR 0034 for the rationale. The module ships:
//!
//! - [`TimingBaseline`] and [`BaselineEntry`]: the YAML wire format.
//! - [`load`] and [`write()`]: file round-trip helpers.
//! - [`check_against`]: regression detector that walks a
//!   [`crate::report::Report`] and emits a [`RegressionWarning`] whenever a
//!   test exceeds `p90_ms * (1 + tolerance/100)`. Skips cache hits, skipped
//!   tests, tests with no baseline entry, and entries with zero p90.
//!
//! A future ticket folds the run's per-test p90 back into the baseline file
//! (`mcptest baseline update`) and wires the runner's `--check-baseline`
//! flag end to end; the detector itself ships here.

use std::fs;
use std::path::Path;

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::diff::ResponseFingerprint;
use crate::report::Report;
use crate::security::ServerFingerprint;

/// Versioned YAML document at `mcptest.timing-baseline.yml`.
///
/// One file per repo. Keyed by test ID so lookups and regenerations stay
/// deterministic. The schema mirrors the compliance baseline so users see
/// a single, consistent pattern across both gates.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TimingBaseline {
    /// Format version. v1.1 ships `"0.1"`; bumps are additive only
    /// (same policy as ADR 0001 for the YAML schema).
    pub version: String,

    /// RFC 3339 UTC timestamp of the last `mcptest baseline update`.
    pub generated_at: String,

    /// `CARGO_PKG_VERSION` of the binary that wrote the file. Surfaced
    /// in `mcptest doctor` so users can spot stale baselines after an
    /// upgrade.
    pub mcptest_version: String,

    /// One entry per test ID in the suite. Order is preserved on
    /// round-trip so diffs stay readable.
    pub tests: Vec<BaselineEntry>,

    /// The approved launch identity of the server under test, captured by
    /// `mcptest baseline update --server-command ...` for the SEC-065
    /// binary-pinning lane. `None` for a timing-only baseline, and for any
    /// baseline written before fingerprinting existed. The `serde(default)` is
    /// load-bearing back-compat: an old file with no `server_fingerprint` key
    /// still deserializes to `None`, and `skip_serializing_if` keeps the key out
    /// of a fresh timing-only file so its diff stays unchanged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub server_fingerprint: Option<ServerFingerprint>,
}

/// Per-test timing baseline.
///
/// The runner records `p50_ms` and `p90_ms` over the last `samples` runs
/// (configurable, default 20). `check_against` compares the new run's
/// duration against `p90_ms` plus the user's tolerance.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BaselineEntry {
    /// Stable test identifier. Matches `TestResult::id` in the canonical
    /// run report.
    pub id: String,

    /// 50th percentile duration over the rolling window, milliseconds.
    pub p50_ms: f64,

    /// 90th percentile duration over the rolling window, milliseconds.
    /// This is what regression detection compares against.
    pub p90_ms: f64,

    /// Number of runs that contributed to the percentiles. Capped at the
    /// configured window size (default 20).
    pub samples: u32,

    /// RFC 3339 UTC timestamp of the last run that updated this entry.
    pub last_updated: String,

    /// Value-tolerant fingerprint of this test's last captured tool
    /// response, when one was recorded. Anchors the response-shape drift
    /// lane (WOR-1468): a later run can fingerprint the same tool and diff
    /// the structure hash against this to catch behavioral drift the
    /// schema diff never sees. `None` for a timing-only entry and for any
    /// baseline written before this field existed. The `serde(default)` is
    /// load-bearing back-compat (an old entry with no key still loads), and
    /// `skip_serializing_if` keeps the key out of a timing-only entry so its
    /// diff stays unchanged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub response_fingerprint: Option<ResponseFingerprint>,
}

impl BaselineEntry {
    /// Attach a captured response fingerprint to this entry, returning the
    /// updated entry. Exposed as the capture API for the runner: the
    /// canonical [`Report`] does not carry raw tool response bodies, so
    /// `bootstrap_from_run` and `fold_run_into_baseline` cannot derive the
    /// fingerprint themselves; a follow-up runner ticket calls this once it
    /// has the live response in hand.
    #[must_use]
    pub fn with_response_fingerprint(mut self, fingerprint: ResponseFingerprint) -> Self {
        self.response_fingerprint = Some(fingerprint);
        self
    }
}

/// Outcome of a regression check for a single test.
///
/// Emitted by [`check_against`] when a test exceeds the configured
/// tolerance over its baseline p90. The runner surfaces these as warnings
/// (or hard failures, under `--strict-baseline`) in the reporter output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RegressionWarning {
    /// Test ID that regressed.
    pub test_id: String,

    /// The baseline p90 we compared against, milliseconds.
    pub baseline_p90_ms: f64,

    /// The actual duration recorded for this run, milliseconds.
    pub actual_ms: f64,

    /// `actual_ms / baseline_p90_ms`. A value of 1.5 means the test took
    /// 50% longer than its baseline.
    pub ratio: f64,
}

/// Typed errors surfaced by the timing baseline module.
///
/// Kept local to the module so the v1.1 implementation can grow new
/// variants without churning the crate-level [`crate::Error`] enum. The
/// runner converts to that enum at the call site.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BaselineError {
    /// Read or write against the baseline file failed.
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),

    /// The YAML file did not parse or could not be re-serialized.
    #[error("yaml error: {0}")]
    Yaml(#[from] serde_yaml::Error),
}

/// Load a timing baseline from disk.
///
/// Returns the parsed [`TimingBaseline`] on success. The caller is
/// expected to surface missing-file errors as "no baseline yet, run
/// `mcptest baseline update`" rather than a hard error.
pub fn load(path: &Path) -> Result<TimingBaseline, BaselineError> {
    let contents = fs::read_to_string(path)?;
    let baseline: TimingBaseline = serde_yaml::from_str(&contents)?;
    Ok(baseline)
}

/// Write a timing baseline to disk.
///
/// Serializes to YAML and overwrites `path`. The caller is responsible
/// for choosing the path (typically `mcptest.timing-baseline.yml` next to
/// the test sources).
pub fn write(baseline: &TimingBaseline, path: &Path) -> Result<(), BaselineError> {
    let yaml = serde_yaml::to_string(baseline)?;
    fs::write(path, yaml)?;
    Ok(())
}

/// Build a fresh [`TimingBaseline`] from a single canonical run
/// report.
///
/// The first `mcptest baseline update` after a run uses this to
/// bootstrap the file: every non-skipped, non-cache-hit test row
/// becomes one [`BaselineEntry`] with `p50_ms = p90_ms =
/// duration_ms` and `samples = 1`. Subsequent updates would fold
/// new runs into a rolling window across the existing entries;
/// that windowing landed as a deliberate follow-up so the v1 file
/// shape stays additive.
///
/// `now_rfc3339` is supplied by the caller so the CLI can render it
/// once and the function stays pure (no `time` or `chrono` calls).
/// `mcptest_version` is the binary's `CARGO_PKG_VERSION` echoed
/// onto the file for `mcptest doctor` to surface as a staleness
/// hint after a bump.
pub fn bootstrap_from_run(
    run: &Report,
    now_rfc3339: impl Into<String>,
    mcptest_version: impl Into<String>,
) -> TimingBaseline {
    let now: String = now_rfc3339.into();
    let mcptest_version: String = mcptest_version.into();
    let tests: Vec<BaselineEntry> = run
        .tests
        .iter()
        .filter(|t| !t.cache_hit)
        .filter(|t| !matches!(t.status, crate::report::TestStatus::Skip))
        .map(|t| {
            let ms = t.duration_ms as f64;
            BaselineEntry {
                id: t.name.clone(),
                p50_ms: ms,
                p90_ms: ms,
                samples: 1,
                last_updated: now.clone(),
                // Bootstrap is timing-only; response capture is the runner's
                // job once it has the live response (see with_response_fingerprint).
                response_fingerprint: None,
            }
        })
        .collect();
    TimingBaseline {
        version: "0.1".to_string(),
        generated_at: now,
        mcptest_version,
        tests,
        // Bootstrap is timing-only; the CLI captures a fingerprint separately
        // when the user passes a server target.
        server_fingerprint: None,
    }
}

/// Fold a fresh canonical run into an existing baseline file,
/// returning the updated baseline.
///
/// The rolling-window math lives off-disk (the file shape carries
/// only p50/p90 + sample count) so the v1 update is a
/// weighted blend: each existing entry's new p90 is the rolling
/// average across the recorded `samples + 1` runs, capped at
/// `max_samples`. New tests are added with the bootstrap shape; a
/// test that disappears from the run stays in the baseline so a
/// transient `--tag` filter does not silently delete its entry.
pub fn fold_run_into_baseline(
    existing: &TimingBaseline,
    run: &Report,
    max_samples: u32,
    now_rfc3339: impl Into<String>,
    mcptest_version: impl Into<String>,
) -> TimingBaseline {
    let now: String = now_rfc3339.into();
    let mcptest_version: String = mcptest_version.into();
    let mut index: std::collections::HashMap<String, BaselineEntry> = existing
        .tests
        .iter()
        .map(|e| (e.id.clone(), e.clone()))
        .collect();
    for test in &run.tests {
        if test.cache_hit || matches!(test.status, crate::report::TestStatus::Skip) {
            continue;
        }
        let ms = test.duration_ms as f64;
        let entry = index.entry(test.name.clone()).or_insert(BaselineEntry {
            id: test.name.clone(),
            p50_ms: ms,
            p90_ms: ms,
            samples: 0,
            last_updated: now.clone(),
            // A new entry from a fold carries no captured response; the
            // runner attaches one via with_response_fingerprint when it has
            // the live response. An existing entry's fingerprint is preserved
            // because the fold mutates timing fields only, never this one.
            response_fingerprint: None,
        });
        let prior_samples = u32::min(entry.samples, max_samples.saturating_sub(1));
        let new_samples = prior_samples.saturating_add(1).min(max_samples);
        let prior_weight = f64::from(prior_samples);
        let new_weight = 1.0;
        let total_weight = prior_weight + new_weight;
        entry.p50_ms = (entry.p50_ms * prior_weight + ms * new_weight) / total_weight;
        entry.p90_ms = (entry.p90_ms * prior_weight + ms * new_weight) / total_weight;
        entry.samples = new_samples;
        entry.last_updated.clone_from(&now);
    }
    // Preserve declaration order from the existing file, appending
    // new tests in run order so the diff stays small.
    let mut out_tests: Vec<BaselineEntry> = Vec::with_capacity(index.len());
    let mut emitted: std::collections::HashSet<String> = std::collections::HashSet::new();
    for entry in &existing.tests {
        if let Some(updated) = index.remove(&entry.id) {
            emitted.insert(entry.id.clone());
            out_tests.push(updated);
        }
    }
    for test in &run.tests {
        if emitted.contains(&test.name) {
            continue;
        }
        if let Some(entry) = index.remove(&test.name) {
            out_tests.push(entry);
        }
    }
    TimingBaseline {
        version: "0.1".to_string(),
        generated_at: now,
        mcptest_version,
        tests: out_tests,
        // A timing fold preserves any approved fingerprint already on the file;
        // re-capturing it is the CLI's job when the user passes a server target.
        server_fingerprint: existing.server_fingerprint.clone(),
    }
}

/// Compare a fresh run against the baseline and return regression
/// warnings.
///
/// Walks `run.tests` and, for each test that matches an entry in
/// `baseline.tests` by name, emits a [`RegressionWarning`] when
/// `duration_ms > p90_ms * (1.0 + tolerance_pct / 100.0)`.
///
/// Skip rules:
///
/// - **Cache hits.** A test with `cache_hit == true` reports
///   `duration_ms = 1` per the  convention so the duration
///   does not reflect a real measurement; comparing it against the
///   baseline would always claim an improvement, so we drop these
///   results from the check.
/// - **Skipped tests.** A skipped test has no useful duration; drop.
/// - **Tests with no baseline.** A new test has not accumulated a
///   p90 yet; drop. The first `mcptest baseline update` populates
///   them.
/// - **Baseline entries with zero `p90_ms`.** Division by zero would
///   produce infinite ratios; treat as not-yet-baselined.
///
/// Returns the warnings in the order tests appear in `run.tests`, so
/// reporters surface regressions next to the failing test row.
pub fn check_against(
    baseline: &TimingBaseline,
    run: &Report,
    tolerance_pct: f64,
) -> Result<Vec<RegressionWarning>, BaselineError> {
    // Build a name -> p90 lookup so the inner loop is O(N+M) instead
    // of O(N*M). The baseline file holds at most one entry per test
    // id; ties resolve to the last entry the way `HashMap::insert`
    // would, which matches the file ordering already preserved on
    // load.
    let mut index: std::collections::HashMap<&str, f64> =
        std::collections::HashMap::with_capacity(baseline.tests.len());
    for entry in &baseline.tests {
        if entry.p90_ms > 0.0 {
            index.insert(entry.id.as_str(), entry.p90_ms);
        }
    }
    let multiplier = 1.0 + tolerance_pct / 100.0;
    let mut warnings: Vec<RegressionWarning> = Vec::new();
    for test in &run.tests {
        if test.cache_hit {
            continue;
        }
        if matches!(test.status, crate::report::TestStatus::Skip) {
            continue;
        }
        let Some(&baseline_p90_ms) = index.get(test.name.as_str()) else {
            continue;
        };
        let actual_ms = test.duration_ms as f64;
        let threshold = baseline_p90_ms * multiplier;
        if actual_ms > threshold {
            let ratio = actual_ms / baseline_p90_ms;
            warnings.push(RegressionWarning {
                test_id: test.name.clone(),
                baseline_p90_ms,
                actual_ms,
                ratio,
            });
        }
    }
    Ok(warnings)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::{Report, RunSummary};

    fn sample_baseline() -> TimingBaseline {
        TimingBaseline {
            version: "0.1".into(),
            generated_at: "2026-05-16T12:00:00Z".into(),
            mcptest_version: "1.0.0".into(),
            tests: vec![
                BaselineEntry {
                    id: "tools/list returns expected tools".into(),
                    p50_ms: 12.5,
                    p90_ms: 25.0,
                    samples: 20,
                    last_updated: "2026-05-16T12:00:00Z".into(),
                    response_fingerprint: None,
                },
                BaselineEntry {
                    id: "resources/read handles utf8 payload".into(),
                    p50_ms: 8.0,
                    p90_ms: 14.5,
                    samples: 17,
                    last_updated: "2026-05-15T09:30:00Z".into(),
                    response_fingerprint: None,
                },
            ],
            server_fingerprint: None,
        }
    }

    fn empty_run() -> Report {
        Report {
            schema_version: "0.1".into(),
            mcptest_version: "1.0.0".into(),
            generated_at: "2026-05-16T12:34:56Z".into(),
            run_id: "00000000-0000-0000-0000-000000000000".into(),
            // Derived (WOR-1317) so the fixture cannot drift from its
            // (empty) test vec.
            summary: RunSummary::from_tests(&[]),
            tests: Vec::new(),
            cost: None,
            metadata: None,
            matrix: None,
            protocol_violations: Vec::new(),
        }
    }

    #[test]
    fn round_trips_baseline_through_yaml() {
        let baseline = sample_baseline();
        let yaml = serde_yaml::to_string(&baseline).unwrap();
        let parsed: TimingBaseline = serde_yaml::from_str(&yaml).unwrap();
        assert_eq!(parsed, baseline);
    }

    #[test]
    fn load_reads_what_write_wrote() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("timing.yml");
        let baseline = sample_baseline();
        write(&baseline, &path).unwrap();
        let reloaded = load(&path).unwrap();
        assert_eq!(reloaded, baseline);
    }

    #[test]
    fn load_returns_io_error_when_file_missing() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("does-not-exist.yml");
        let err = load(&path).unwrap_err();
        assert!(matches!(err, BaselineError::Io(_)), "got {err:?}");
    }

    #[test]
    fn load_returns_yaml_error_on_garbage_input() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("bad.yml");
        fs::write(&path, "version: [this, is, not, valid]\n").unwrap();
        let err = load(&path).unwrap_err();
        assert!(matches!(err, BaselineError::Yaml(_)), "got {err:?}");
    }

    // ===  v1: check_against runtime detector ===

    fn run_with_tests(tests: Vec<crate::report::TestResult>) -> Report {
        let mut report = empty_run();
        report.summary = RunSummary::from_tests(&tests);
        report.tests = tests;
        report
    }

    fn passing_test(name: &str, duration_ms: u64) -> crate::report::TestResult {
        crate::report::TestResult {
            name: name.into(),
            status: crate::report::TestStatus::Pass,
            duration_ms,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: None,
            file: None,
            line: None,
            steps: Vec::new(),
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
            natural_language: false,
        }
    }

    #[test]
    fn check_against_returns_empty_when_every_test_is_under_threshold() {
        // Baseline p90 is 25ms; tolerance 50% so threshold is 37.5ms.
        // Run at 30ms: under threshold, no warning.
        let baseline = sample_baseline();
        let run = run_with_tests(vec![passing_test("tools/list returns expected tools", 30)]);
        let warnings = check_against(&baseline, &run, 50.0).expect("no error");
        assert!(
            warnings.is_empty(),
            "expected no warnings, got {warnings:?}"
        );
    }

    #[test]
    fn check_against_flags_a_test_that_exceeds_p90_plus_tolerance() {
        // Baseline p90 is 25ms; tolerance 50% so threshold is 37.5ms.
        // Run at 40ms: over threshold, one warning.
        let baseline = sample_baseline();
        let run = run_with_tests(vec![passing_test("tools/list returns expected tools", 40)]);
        let warnings = check_against(&baseline, &run, 50.0).expect("no error");
        assert_eq!(warnings.len(), 1);
        let w = &warnings[0];
        assert_eq!(w.test_id, "tools/list returns expected tools");
        assert_eq!(w.baseline_p90_ms, 25.0);
        assert_eq!(w.actual_ms, 40.0);
        assert!((w.ratio - 1.6).abs() < 1e-9, "ratio was {}", w.ratio);
    }

    #[test]
    fn check_against_drops_tests_with_no_baseline_entry() {
        // A test the baseline does not know about cannot regress: drop it.
        let baseline = sample_baseline();
        let run = run_with_tests(vec![passing_test("brand new test", 10000)]);
        let warnings = check_against(&baseline, &run, 50.0).expect("no error");
        assert!(
            warnings.is_empty(),
            "expected no warnings, got {warnings:?}"
        );
    }

    #[test]
    fn check_against_skips_cache_hits() {
        // A cache hit reports duration_ms=1 by convention; including it
        // would always claim an improvement so we drop it.
        let baseline = sample_baseline();
        let mut hit = passing_test("tools/list returns expected tools", 1);
        hit.cache_hit = true;
        let run = run_with_tests(vec![hit]);
        let warnings = check_against(&baseline, &run, 50.0).expect("no error");
        assert!(warnings.is_empty(), "cache hits must not produce warnings");
    }

    #[test]
    fn check_against_skips_skipped_tests() {
        let baseline = sample_baseline();
        let mut skipped = passing_test("tools/list returns expected tools", 999);
        skipped.status = crate::report::TestStatus::Skip;
        let run = run_with_tests(vec![skipped]);
        let warnings = check_against(&baseline, &run, 50.0).expect("no error");
        assert!(
            warnings.is_empty(),
            "skipped tests must not produce warnings"
        );
    }

    #[test]
    fn check_against_skips_baseline_entries_with_zero_p90() {
        // A zero p90 would be a bootstrap entry; treat it as not-yet
        // baselined rather than producing infinite ratios.
        let mut baseline = sample_baseline();
        baseline.tests[0].p90_ms = 0.0;
        let run = run_with_tests(vec![passing_test("tools/list returns expected tools", 500)]);
        let warnings = check_against(&baseline, &run, 50.0).expect("no error");
        assert!(
            warnings.is_empty(),
            "zero p90 must not regress, got {warnings:?}"
        );
    }

    #[test]
    fn check_against_preserves_run_order_in_warnings() {
        let baseline = sample_baseline();
        let run = run_with_tests(vec![
            passing_test("resources/read handles utf8 payload", 30),
            passing_test("tools/list returns expected tools", 40),
        ]);
        let warnings = check_against(&baseline, &run, 50.0).expect("no error");
        assert_eq!(warnings.len(), 2);
        // The runner ordering is what reporters render against; pin it.
        assert_eq!(warnings[0].test_id, "resources/read handles utf8 payload");
        assert_eq!(warnings[1].test_id, "tools/list returns expected tools");
    }

    #[test]
    fn check_against_zero_tolerance_means_any_overrun_is_a_regression() {
        // Tolerance 0% means threshold == p90. A test at p90+1ms regresses.
        let baseline = sample_baseline();
        let run = run_with_tests(vec![passing_test("tools/list returns expected tools", 26)]);
        let warnings = check_against(&baseline, &run, 0.0).expect("no error");
        assert_eq!(warnings.len(), 1);
    }

    #[test]
    fn bootstrap_from_run_creates_an_entry_per_recorded_test() {
        let run = run_with_tests(vec![
            passing_test("tools/list returns expected tools", 17),
            passing_test("resources/read handles utf8 payload", 22),
        ]);
        let baseline = bootstrap_from_run(&run, "2026-05-30T12:00:00Z", "1.0.0");
        assert_eq!(baseline.version, "0.1");
        assert_eq!(baseline.tests.len(), 2);
        let first = &baseline.tests[0];
        assert_eq!(first.id, "tools/list returns expected tools");
        assert_eq!(first.p50_ms, 17.0);
        assert_eq!(first.p90_ms, 17.0);
        assert_eq!(first.samples, 1);
        assert_eq!(first.last_updated, "2026-05-30T12:00:00Z");
    }

    #[test]
    fn bootstrap_from_run_drops_cache_hits_and_skipped_tests() {
        let mut cache_hit = passing_test("cached", 5);
        cache_hit.cache_hit = true;
        let mut skipped = passing_test("skipped", 0);
        skipped.status = crate::report::TestStatus::Skip;
        let run = run_with_tests(vec![cache_hit, skipped, passing_test("kept", 9)]);
        let baseline = bootstrap_from_run(&run, "2026-05-30T12:00:00Z", "1.0.0");
        assert_eq!(baseline.tests.len(), 1);
        assert_eq!(baseline.tests[0].id, "kept");
    }

    #[test]
    fn fold_run_into_baseline_updates_existing_entries_in_place_order() {
        let baseline = sample_baseline();
        // First entry baselined at p90=25; run at 19. Expect a
        // weighted blend toward 19. Existing samples=20, max=20 so
        // the new sample evicts the oldest weight in the average.
        let run = run_with_tests(vec![passing_test("tools/list returns expected tools", 19)]);
        let updated = fold_run_into_baseline(&baseline, &run, 20, "2026-05-30T12:00:00Z", "1.0.0");
        assert_eq!(updated.tests.len(), baseline.tests.len());
        let entry = &updated.tests[0];
        assert_eq!(entry.id, "tools/list returns expected tools");
        // The blend formula: (prior * 19 + new * 1) / 20 with prior p90 = 25.
        let expected_p90 = (25.0 * 19.0 + 19.0) / 20.0;
        assert!(
            (entry.p90_ms - expected_p90).abs() < 1e-9,
            "expected p90 ~{expected_p90}, got {}",
            entry.p90_ms
        );
        assert_eq!(entry.samples, 20);
        assert_eq!(entry.last_updated, "2026-05-30T12:00:00Z");
    }

    #[test]
    fn fold_run_into_baseline_appends_new_tests_in_run_order() {
        let baseline = sample_baseline();
        let run = run_with_tests(vec![
            passing_test("brand-new test", 11),
            passing_test("another new one", 17),
        ]);
        let updated = fold_run_into_baseline(&baseline, &run, 20, "2026-05-30T12:00:00Z", "1.0.0");
        assert_eq!(updated.tests.len(), baseline.tests.len() + 2);
        let new_ids: Vec<&str> = updated
            .tests
            .iter()
            .skip(baseline.tests.len())
            .map(|e| e.id.as_str())
            .collect();
        assert_eq!(new_ids, vec!["brand-new test", "another new one"]);
    }

    #[test]
    fn fold_run_into_baseline_preserves_disappeared_tests() {
        // The new run does not include `resources/read handles utf8
        // payload`; the existing entry must survive so a transient
        // `--tag` filter does not silently delete it.
        let baseline = sample_baseline();
        let run = run_with_tests(vec![passing_test("tools/list returns expected tools", 20)]);
        let updated = fold_run_into_baseline(&baseline, &run, 20, "2026-05-30T12:00:00Z", "1.0.0");
        let ids: Vec<&str> = updated.tests.iter().map(|e| e.id.as_str()).collect();
        assert!(ids.contains(&"resources/read handles utf8 payload"));
    }

    // === server_fingerprint (SEC-065 baseline capture) ===

    #[test]
    fn baseline_without_server_fingerprint_field_still_deserializes() {
        // The critical back-compat guarantee: a baseline written before the
        // fingerprint field existed has no `server_fingerprint` key, and must
        // still load to `None` rather than failing the whole gate.
        let legacy = r#"
version: "0.1"
generated_at: "2026-05-16T12:00:00Z"
mcptest_version: "1.0.0"
tests:
  - id: "tools/list returns expected tools"
    p50_ms: 12.5
    p90_ms: 25.0
    samples: 20
    last_updated: "2026-05-16T12:00:00Z"
"#;
        let parsed: TimingBaseline = serde_yaml::from_str(legacy).expect("legacy baseline loads");
        assert!(parsed.server_fingerprint.is_none());
        assert_eq!(parsed.tests.len(), 1);
    }

    #[test]
    fn timing_only_baseline_omits_the_fingerprint_key_entirely() {
        // skip_serializing_if keeps the key out of a timing-only file so its
        // diff is byte-identical to a pre-fingerprint baseline.
        let baseline = sample_baseline();
        let yaml = serde_yaml::to_string(&baseline).unwrap();
        assert!(
            !yaml.contains("server_fingerprint"),
            "a None fingerprint must not serialize a key, got:\n{yaml}"
        );
    }

    #[test]
    fn baseline_round_trips_a_captured_server_fingerprint() {
        let mut baseline = sample_baseline();
        baseline.server_fingerprint = Some(ServerFingerprint::Stdio {
            command: "uvx".into(),
            resolved_path: Some("/usr/bin/uvx".into()),
            content_hash: Some("abcd1234".into()),
            args: vec!["mcp-server".into()],
        });
        let yaml = serde_yaml::to_string(&baseline).unwrap();
        assert!(yaml.contains("server_fingerprint"));
        let parsed: TimingBaseline = serde_yaml::from_str(&yaml).unwrap();
        assert_eq!(parsed, baseline);
    }

    #[test]
    fn fold_preserves_an_existing_server_fingerprint() {
        // A timing fold is not a re-capture; an approved fingerprint already on
        // the file must survive the fold so the SEC-065 anchor is not lost.
        let mut baseline = sample_baseline();
        baseline.server_fingerprint = Some(ServerFingerprint::Url {
            endpoint: "https://api.example.com/mcp".into(),
            host: Some("api.example.com".into()),
        });
        let run = run_with_tests(vec![passing_test("tools/list returns expected tools", 20)]);
        let updated = fold_run_into_baseline(&baseline, &run, 20, "2026-05-30T12:00:00Z", "1.0.0");
        assert_eq!(updated.server_fingerprint, baseline.server_fingerprint);
    }

    #[test]
    fn bootstrap_leaves_the_server_fingerprint_unset() {
        let run = run_with_tests(vec![passing_test("kept", 9)]);
        let baseline = bootstrap_from_run(&run, "2026-05-30T12:00:00Z", "1.0.0");
        assert!(baseline.server_fingerprint.is_none());
    }

    // === response_fingerprint (WOR-1468 response-shape drift baseline) ===

    #[test]
    fn entry_without_response_fingerprint_field_still_deserializes() {
        // Back-compat: a baseline written before the response-fingerprint
        // field existed has no key on its entries and must still load to None.
        let legacy = r#"
version: "0.1"
generated_at: "2026-05-16T12:00:00Z"
mcptest_version: "1.0.0"
tests:
  - id: "tools/list returns expected tools"
    p50_ms: 12.5
    p90_ms: 25.0
    samples: 20
    last_updated: "2026-05-16T12:00:00Z"
"#;
        let parsed: TimingBaseline = serde_yaml::from_str(legacy).expect("legacy baseline loads");
        assert!(parsed.tests[0].response_fingerprint.is_none());
    }

    #[test]
    fn timing_only_entry_omits_the_response_fingerprint_key() {
        // skip_serializing_if keeps the key out so a timing-only entry's diff
        // is byte-identical to a pre-fingerprint baseline.
        let baseline = sample_baseline();
        let yaml = serde_yaml::to_string(&baseline).unwrap();
        assert!(
            !yaml.contains("response_fingerprint"),
            "a None response fingerprint must not serialize a key, got:\n{yaml}"
        );
    }

    #[test]
    fn entry_round_trips_a_captured_response_fingerprint() {
        use crate::diff::fingerprint_response;
        let mut baseline = sample_baseline();
        let fp = fingerprint_response(&serde_json::json!({ "id": "abc", "items": [1, 2] }));
        baseline.tests[0] = baseline.tests[0].clone().with_response_fingerprint(fp);
        let yaml = serde_yaml::to_string(&baseline).unwrap();
        assert!(yaml.contains("response_fingerprint"));
        let parsed: TimingBaseline = serde_yaml::from_str(&yaml).unwrap();
        assert_eq!(parsed, baseline);
    }

    #[test]
    fn fold_preserves_an_existing_response_fingerprint() {
        // A timing fold mutates timing fields only; an entry's captured
        // response fingerprint must survive so the WOR-1468 anchor is not lost.
        use crate::diff::fingerprint_response;
        let mut baseline = sample_baseline();
        let fp = fingerprint_response(&serde_json::json!({ "id": "abc" }));
        baseline.tests[0] = baseline.tests[0]
            .clone()
            .with_response_fingerprint(fp.clone());
        let run = run_with_tests(vec![passing_test("tools/list returns expected tools", 20)]);
        let updated = fold_run_into_baseline(&baseline, &run, 20, "2026-05-30T12:00:00Z", "1.0.0");
        assert_eq!(updated.tests[0].response_fingerprint, Some(fp));
    }

    #[test]
    fn with_response_fingerprint_attaches_the_capture() {
        use crate::diff::fingerprint_response;
        let entry = sample_baseline().tests[0].clone();
        assert!(entry.response_fingerprint.is_none());
        let fp = fingerprint_response(&serde_json::json!({ "ok": true }));
        let updated = entry.with_response_fingerprint(fp.clone());
        assert_eq!(updated.response_fingerprint, Some(fp));
    }
}
