//! Value-tolerant response-shape and error-signature fingerprints.
//!
//! Why this exists: a tool's `tools/list` schema can stay byte-identical
//! while the tool's actual responses drift in ways a schema diff never
//! sees, a field changes type, an array starts coming back empty, an error
//! that used to be a clean `not found` turns into a stack trace. This
//! module fingerprints a response *structurally* (which keys exist, what
//! types their values are, how deep the nesting goes) while ignoring the
//! concrete values, so `{"id":"abc"}` and `{"id":"xyz"}` hash the same but
//! `{"id":123}` differs. It also normalizes an error body into a stable
//! signature by stripping the volatile tokens (UUIDs, paths, line numbers,
//! timestamps, bare integers) that make two copies of the same error look
//! different. The diff layer compares these fingerprints across baselines
//! to flag behavioral drift the catalog diff cannot.

use std::collections::{hash_map::DefaultHasher, BTreeMap, BTreeSet};
use std::hash::{Hash, Hasher};

use serde::{Deserialize, Serialize};
use serde_json::Value;

use super::catalog_types::{CatalogChange, ChangeKind};

/// Coarse size class for a response, so a fingerprint notices a response
/// collapsing to empty or ballooning without being sensitive to one extra
/// element. Bucketing (rather than a raw count) keeps the fingerprint
/// value-tolerant: a 12-element list and a 15-element list are "the same
/// shape" but an empty list is a real signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SizeBucket {
    /// No structural elements (an empty object, empty array, or scalar).
    Empty,
    /// 1 to 8 structural elements.
    Small,
    /// 9 to 64 structural elements.
    Medium,
    /// More than 64 structural elements.
    Large,
}

impl SizeBucket {
    /// Classify a raw element count into a bucket. Boundaries are powers of
    /// two so the buckets stay stable as payloads grow over a release.
    fn from_count(count: usize) -> Self {
        match count {
            0 => SizeBucket::Empty,
            1..=8 => SizeBucket::Small,
            9..=64 => SizeBucket::Medium,
            _ => SizeBucket::Large,
        }
    }
}

/// Structural fingerprint of one tool response.
///
/// Two responses with the same `structure_hash` have the same keys, the
/// same value types at every level, and homogeneous arrays where the
/// original did; their concrete values may differ entirely. Carried as
/// plain data so a baseline file can store it and a later run can compare
/// against it without re-deriving the hash logic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResponseFingerprint {
    /// Stable hash over the response's key/type/nesting skeleton, rendered
    /// as a lowercase hex string. Equal hashes mean equal shape.
    pub structure_hash: String,
    /// The MCP content type when the response is an MCP tool result with a
    /// single dominant content block (`text`, `image`, ...). `None` for a
    /// bare JSON value with no recognizable content envelope.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content_type: Option<String>,
    /// Coarse size class of the response, so a collapse to empty or a
    /// blow-up in cardinality registers as drift.
    pub size_bucket: SizeBucket,
}

/// Normalized signature of one error response.
///
/// Built by stripping volatile tokens from the error text so two errors
/// that differ only in a UUID, a file path, or a line number collapse to
/// the same `pattern_hash`. The `category` is a coarse bucket derived from
/// the error's status or shape, so a `not-found` error and a `timeout`
/// stay distinguishable even when their patterns are similar.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorSignature {
    /// Coarse error class (`not-found`, `timeout`, `auth`, `rate-limit`,
    /// `server-error`, `bad-request`, or `generic`).
    pub category: String,
    /// Stable hash over the volatility-stripped error pattern, rendered as
    /// lowercase hex. Equal hashes mean the same error template.
    pub pattern_hash: String,
}

/// Fingerprint a tool response by its structure, ignoring concrete values.
///
/// The skeleton string folds in each key name, each value's JSON type, the
/// nesting depth, and whether arrays are homogeneous; the concrete scalar
/// values are deliberately excluded so a changing id or timestamp does not
/// move the hash. Object keys are visited in sorted order so the hash is
/// deterministic regardless of serialization order.
pub fn fingerprint_response(value: &Value) -> ResponseFingerprint {
    let skeleton = structure_skeleton(value);
    ResponseFingerprint {
        structure_hash: hash_hex(&skeleton),
        content_type: dominant_content_type(value),
        size_bucket: SizeBucket::from_count(structural_count(value)),
    }
}

/// Build the deterministic skeleton string that the structure hash folds.
///
/// Records the JSON type at each node, the sorted key set for objects, and
/// for arrays the item homogeneity plus the shared item skeleton (or each
/// distinct one when heterogeneous), never the scalar values themselves.
fn structure_skeleton(value: &Value) -> String {
    match value {
        Value::Null => "null".to_owned(),
        Value::Bool(_) => "bool".to_owned(),
        Value::Number(_) => "number".to_owned(),
        Value::String(_) => "string".to_owned(),
        Value::Array(items) => array_skeleton(items),
        Value::Object(map) => {
            let mut keys: Vec<&String> = map.keys().collect();
            keys.sort();
            let parts: Vec<String> = keys
                .into_iter()
                .map(|k| format!("{k}:{}", structure_skeleton(&map[k])))
                .collect();
            format!("obj{{{}}}", parts.join(","))
        }
    }
}

/// Skeleton for an array: empty arrays are their own shape, a homogeneous
/// array collapses to one item skeleton (so length never moves the hash),
/// and a heterogeneous array lists each distinct item skeleton in sorted
/// order so element order does not move the hash either.
fn array_skeleton(items: &[Value]) -> String {
    if items.is_empty() {
        return "arr[]".to_owned();
    }
    let mut shapes: Vec<String> = items.iter().map(structure_skeleton).collect();
    let first = &shapes[0];
    if shapes.iter().all(|s| s == first) {
        return format!("arr<{first}>");
    }
    shapes.sort();
    shapes.dedup();
    format!("arr[{}]", shapes.join("|"))
}

/// Count the structural elements that feed the size bucket: object entries
/// and array items summed recursively. Scalars count as zero so a bare
/// scalar response buckets as [`SizeBucket::Empty`].
fn structural_count(value: &Value) -> usize {
    match value {
        Value::Array(items) => items.len() + items.iter().map(structural_count).sum::<usize>(),
        Value::Object(map) => map.len() + map.values().map(structural_count).sum::<usize>(),
        _ => 0,
    }
}

/// Pull the dominant MCP content type out of a tool-result envelope.
///
/// An MCP tool result is `{"content": [{"type": "text", ...}, ...]}`; we
/// report the first block's `type` as the dominant content type. Anything
/// that is not that envelope (a bare value, a structured-content object)
/// yields `None` so the field stays meaningful.
fn dominant_content_type(value: &Value) -> Option<String> {
    let first = value
        .get("content")
        .and_then(Value::as_array)
        .and_then(|blocks| blocks.first())?;
    first.get("type").and_then(Value::as_str).map(str::to_owned)
}

/// Normalize an error body into a stable signature.
///
/// Strips the volatile tokens that make two copies of one error look
/// different (UUIDs, file paths, line numbers, timestamps, bare integers,
/// hex blobs), hashes the normalized pattern, and classifies the error
/// into a coarse category from recognizable keywords. The original string
/// is never stored, only the category and the pattern hash.
pub fn error_signature(body: &str) -> ErrorSignature {
    let normalized = normalize_error_text(body);
    ErrorSignature {
        category: classify_error(body),
        pattern_hash: hash_hex(&normalized),
    }
}

/// Replace volatile substrings with fixed placeholders so two structurally
/// identical errors normalize to the same string. Order matters: paths and
/// timestamps are matched before the bare-integer pass so their embedded
/// digits are already masked.
fn normalize_error_text(body: &str) -> String {
    let lowered = body.to_lowercase();
    let mut out = String::with_capacity(lowered.len());
    for token in lowered.split(|c: char| c.is_whitespace()) {
        if !out.is_empty() {
            out.push(' ');
        }
        out.push_str(&normalize_token(token));
    }
    out
}

/// Normalize one whitespace-delimited token to a placeholder when it looks
/// volatile, else return it unchanged. Pulled out so each rule is a single
/// readable line and the dispatcher stays under the length budget.
fn normalize_token(token: &str) -> String {
    let trimmed = token.trim_matches(|c: char| !c.is_alphanumeric());
    if trimmed.is_empty() {
        return "<sym>".to_owned();
    }
    if looks_like_uuid(trimmed) {
        return "<uuid>".to_owned();
    }
    if looks_like_path(token) {
        return "<path>".to_owned();
    }
    if looks_like_timestamp(trimmed) {
        return "<ts>".to_owned();
    }
    if looks_like_hex_blob(trimmed) {
        return "<hex>".to_owned();
    }
    if trimmed.chars().all(|c| c.is_ascii_digit()) {
        return "<num>".to_owned();
    }
    mask_trailing_number(trimmed)
}

/// Mask a `name:line` or `key=42` style trailing number so `line:12` and
/// `line:99` collapse, while a pure word passes through untouched.
fn mask_trailing_number(token: &str) -> String {
    if let Some(pos) = token.rfind([':', '=', '#']) {
        let (head, tail) = token.split_at(pos + 1);
        if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
            return format!("{head}<num>");
        }
    }
    token.to_owned()
}

/// True for a canonical 8-4-4-4-12 hyphenated UUID.
fn looks_like_uuid(token: &str) -> bool {
    let parts: Vec<&str> = token.split('-').collect();
    let lengths = [8, 4, 4, 4, 12];
    parts.len() == 5
        && parts
            .iter()
            .zip(lengths)
            .all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()))
}

/// True when a token carries a filesystem path separator, so `/var/run/x`
/// and `C:\\tmp\\y` both normalize regardless of the leaf name.
fn looks_like_path(token: &str) -> bool {
    (token.contains('/') && token.len() > 1) || token.contains('\\')
}

/// True for an ISO-8601-ish timestamp (a date with a `t` separator or a
/// trailing `z`), so two errors that differ only by when they fired match.
fn looks_like_timestamp(token: &str) -> bool {
    let digits = token.chars().filter(char::is_ascii_digit).count();
    digits >= 6 && (token.contains('t') || token.ends_with('z') || token.matches('-').count() >= 2)
}

/// True for a long all-hex token (a hash, request id, or pointer) so a
/// rotating correlation id does not split one error into many patterns.
fn looks_like_hex_blob(token: &str) -> bool {
    token.len() >= 8
        && token.chars().all(|c| c.is_ascii_hexdigit())
        && token.chars().any(|c| c.is_ascii_alphabetic())
}

/// Classify an error body into a coarse category from keyword matches.
///
/// Order is by specificity: a `429`/`rate limit` body should not be
/// swallowed by the generic `server-error` 5xx bucket, so the rate-limit
/// and auth checks precede the status-code sweep.
fn classify_error(body: &str) -> String {
    let lower = body.to_lowercase();
    if lower.contains("not found") || lower.contains("404") {
        "not-found".to_owned()
    } else if lower.contains("timed out") || lower.contains("timeout") {
        "timeout".to_owned()
    } else if lower.contains("rate limit") || lower.contains("429") || lower.contains("too many") {
        "rate-limit".to_owned()
    } else if lower.contains("unauthorized") || lower.contains("forbidden") || auth_status(&lower) {
        "auth".to_owned()
    } else if lower.contains("invalid") || lower.contains("400") || lower.contains("bad request") {
        "bad-request".to_owned()
    } else if server_status(&lower) || lower.contains("internal") {
        "server-error".to_owned()
    } else {
        "generic".to_owned()
    }
}

/// True when the body names a 401/403 auth status code as a standalone word.
fn auth_status(lower: &str) -> bool {
    has_status_word(lower, &["401", "403"])
}

/// True when the body names a 5xx status code as a standalone word.
fn server_status(lower: &str) -> bool {
    has_status_word(lower, &["500", "502", "503", "504"])
}

/// True when any of `codes` appears as a whitespace-delimited word, so a
/// `503` status matches but an id like `15035` does not.
fn has_status_word(lower: &str, codes: &[&str]) -> bool {
    lower
        .split(|c: char| !c.is_ascii_digit())
        .any(|word| codes.contains(&word))
}

/// Hash an arbitrary string to a stable lowercase hex digest. Uses the
/// standard-library hasher; the diff only needs determinism within a build,
/// not a cryptographic guarantee.
fn hash_hex(input: &str) -> String {
    let mut hasher = DefaultHasher::new();
    input.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

/// One tool's captured runtime behavior, the unit the response-shape drift
/// diff compares across baselines.
///
/// Holds the value-tolerant structure fingerprint of the tool's last
/// observed success response and the set of normalized error signatures it
/// has returned. Either side may be absent: a tool that never errored has
/// an empty `errors` set, and a tool whose response was not captured has no
/// `success`. Carried as plain data so a baseline file can store it and a
/// later run can diff against it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolResponseSample {
    /// Fingerprint of the most recent successful response, when captured.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub success: Option<ResponseFingerprint>,
    /// Normalized signatures of every distinct error the tool returned.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub errors: Vec<ErrorSignature>,
}

/// Diff two snapshots of per-tool response behavior and emit a change per
/// tool that drifted.
///
/// Compares only tools present on both sides (an added or removed tool is
/// the catalog diff's job, not this one's) and only when both sides
/// captured a response, so a tool the baseline never exercised never
/// produces a spurious change. A structure-hash change is breaking, since a
/// caller parsing the old shape can break; a pure error-signature shift is
/// reported but not breaking. Returns the changes sorted by tool name so
/// the output is deterministic.
pub fn diff_response_fingerprints(
    old: &BTreeMap<String, ToolResponseSample>,
    new: &BTreeMap<String, ToolResponseSample>,
) -> Vec<CatalogChange> {
    let mut out = Vec::new();
    for (tool, new_sample) in new {
        let Some(old_sample) = old.get(tool) else {
            continue;
        };
        if let Some(change) = compare_samples(tool, old_sample, new_sample) {
            out.push(change);
        }
    }
    out.sort_by(|a, b| a.tool.cmp(&b.tool));
    out
}

/// Compare one tool's old and new sample, returning a change when its
/// success shape or error-signature set drifted. `None` when nothing moved
/// or neither side captured anything comparable.
fn compare_samples(
    tool: &str,
    old: &ToolResponseSample,
    new: &ToolResponseSample,
) -> Option<CatalogChange> {
    let structure_drift = structure_hash_drift(old, new);
    let errors_drifted = error_set(old) != error_set(new);
    if structure_drift.is_none() && !errors_drifted {
        return None;
    }
    let mut details: Vec<String> = Vec::new();
    if let Some(drift) = &structure_drift {
        details.push(drift.clone());
    }
    if errors_drifted {
        details.push("error-signature set changed".to_owned());
    }
    Some(CatalogChange {
        tool: tool.to_owned(),
        kind: ChangeKind::ResponseShapeChanged {
            detail: details.join("; "),
        },
        // Only a structure-hash change can break a caller parsing the old
        // shape; an error-signature shift is informational drift.
        breaking: structure_drift.is_some(),
        description: format!(
            "tool `{tool}` response shape drifted ({})",
            details.join("; ")
        ),
    })
}

/// Describe a structure-hash drift between two samples, or `None` when both
/// sides captured a success response with the same hash (or one side has no
/// success fingerprint, which is not a comparable change).
fn structure_hash_drift(old: &ToolResponseSample, new: &ToolResponseSample) -> Option<String> {
    let (Some(old_fp), Some(new_fp)) = (&old.success, &new.success) else {
        return None;
    };
    if old_fp.structure_hash == new_fp.structure_hash {
        return None;
    }
    Some(format!(
        "structure hash {} -> {}",
        old_fp.structure_hash, new_fp.structure_hash
    ))
}

/// The set of error pattern hashes for one sample, so two samples compare
/// by which errors they can return rather than by ordering.
fn error_set(sample: &ToolResponseSample) -> BTreeSet<&str> {
    sample
        .errors
        .iter()
        .map(|e| e.pattern_hash.as_str())
        .collect()
}

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

    #[test]
    fn different_id_values_hash_the_same_shape() {
        let a = fingerprint_response(&json!({ "id": "abc", "ok": true }));
        let b = fingerprint_response(&json!({ "id": "xyz", "ok": false }));
        assert_eq!(a.structure_hash, b.structure_hash);
    }

    #[test]
    fn a_type_change_on_a_field_changes_the_hash() {
        let str_id = fingerprint_response(&json!({ "id": "abc" }));
        let num_id = fingerprint_response(&json!({ "id": 123 }));
        assert_ne!(str_id.structure_hash, num_id.structure_hash);
    }

    #[test]
    fn key_order_does_not_change_the_hash() {
        let a = fingerprint_response(&json!({ "a": 1, "b": "x" }));
        let b = fingerprint_response(&json!({ "b": "y", "a": 9 }));
        assert_eq!(a.structure_hash, b.structure_hash);
    }

    #[test]
    fn an_added_key_changes_the_hash() {
        let a = fingerprint_response(&json!({ "id": "abc" }));
        let b = fingerprint_response(&json!({ "id": "abc", "extra": true }));
        assert_ne!(a.structure_hash, b.structure_hash);
    }

    #[test]
    fn homogeneous_array_length_does_not_change_the_hash() {
        let short = fingerprint_response(&json!({ "items": [{ "n": 1 }] }));
        let long = fingerprint_response(&json!({ "items": [{ "n": 1 }, { "n": 2 }, { "n": 3 }] }));
        assert_eq!(short.structure_hash, long.structure_hash);
    }

    #[test]
    fn array_item_type_change_changes_the_hash() {
        let nums = fingerprint_response(&json!({ "items": [1, 2, 3] }));
        let strs = fingerprint_response(&json!({ "items": ["a", "b"] }));
        assert_ne!(nums.structure_hash, strs.structure_hash);
    }

    #[test]
    fn empty_array_differs_from_populated_array() {
        let empty = fingerprint_response(&json!({ "items": [] }));
        let full = fingerprint_response(&json!({ "items": [{ "n": 1 }] }));
        assert_ne!(empty.structure_hash, full.structure_hash);
    }

    #[test]
    fn nesting_depth_is_part_of_the_shape() {
        let flat = fingerprint_response(&json!({ "a": "x" }));
        let nested = fingerprint_response(&json!({ "a": { "b": "x" } }));
        assert_ne!(flat.structure_hash, nested.structure_hash);
    }

    #[test]
    fn dominant_content_type_is_read_from_an_mcp_envelope() {
        let fp = fingerprint_response(&json!({
            "content": [{ "type": "text", "text": "hello" }]
        }));
        assert_eq!(fp.content_type.as_deref(), Some("text"));
    }

    #[test]
    fn bare_value_has_no_content_type() {
        let fp = fingerprint_response(&json!({ "id": "abc" }));
        assert!(fp.content_type.is_none());
    }

    #[test]
    fn empty_object_buckets_as_empty() {
        let fp = fingerprint_response(&json!({}));
        assert_eq!(fp.size_bucket, SizeBucket::Empty);
    }

    #[test]
    fn a_large_response_buckets_as_large() {
        let items: Vec<Value> = (0..100).map(|i| json!({ "n": i })).collect();
        let fp = fingerprint_response(&json!({ "items": items }));
        assert_eq!(fp.size_bucket, SizeBucket::Large);
    }

    #[test]
    fn fingerprint_round_trips_through_json() {
        let fp = fingerprint_response(&json!({ "id": "abc", "items": [1, 2] }));
        let wire = serde_json::to_string(&fp).expect("serializes");
        let parsed: ResponseFingerprint = serde_json::from_str(&wire).expect("deserializes");
        assert_eq!(parsed, fp);
    }

    #[test]
    fn errors_differing_only_by_uuid_share_a_pattern_hash() {
        let a = error_signature("session 550e8400-e29b-41d4-a716-446655440000 not found");
        let b = error_signature("session 7c9e6679-7425-40de-944b-e07fc1f90ae7 not found");
        assert_eq!(a.pattern_hash, b.pattern_hash);
        assert_eq!(a.category, "not-found");
    }

    #[test]
    fn errors_differing_only_by_path_share_a_pattern_hash() {
        let a = error_signature("cannot open /var/run/server/abc.sock");
        let b = error_signature("cannot open /var/run/server/xyz.sock");
        assert_eq!(a.pattern_hash, b.pattern_hash);
    }

    #[test]
    fn errors_differing_only_by_line_number_share_a_pattern_hash() {
        let a = error_signature("parse error at line:12");
        let b = error_signature("parse error at line:4096");
        assert_eq!(a.pattern_hash, b.pattern_hash);
    }

    #[test]
    fn errors_differing_only_by_timestamp_share_a_pattern_hash() {
        let a = error_signature("request failed at 2026-06-18T12:00:00Z");
        let b = error_signature("request failed at 2026-06-20T09:30:15Z");
        assert_eq!(a.pattern_hash, b.pattern_hash);
    }

    #[test]
    fn errors_differing_only_by_a_bare_integer_share_a_pattern_hash() {
        let a = error_signature("retried 3 times");
        let b = error_signature("retried 17 times");
        assert_eq!(a.pattern_hash, b.pattern_hash);
    }

    #[test]
    fn genuinely_different_errors_have_different_pattern_hashes() {
        let a = error_signature("connection refused");
        let b = error_signature("permission denied");
        assert_ne!(a.pattern_hash, b.pattern_hash);
    }

    #[test]
    fn category_distinguishes_timeout_from_not_found() {
        assert_eq!(error_signature("operation timed out").category, "timeout");
        assert_eq!(error_signature("record not found").category, "not-found");
    }

    #[test]
    fn category_reads_rate_limit_before_server_error() {
        // A 429 body must not fall through to the 5xx server-error bucket.
        assert_eq!(
            error_signature("429 too many requests").category,
            "rate-limit"
        );
    }

    #[test]
    fn category_reads_auth_status_codes() {
        assert_eq!(error_signature("got 401 from upstream").category, "auth");
        assert_eq!(
            error_signature("server returned 500").category,
            "server-error"
        );
    }

    #[test]
    fn a_bare_id_digit_run_does_not_register_as_a_status_code() {
        // `15035` contains `503` as a substring; the status sweep must only
        // match standalone digit words, so this stays generic.
        assert_eq!(error_signature("widget 15035 broke").category, "generic");
    }

    #[test]
    fn error_signature_round_trips_through_json() {
        let sig = error_signature("not found: /tmp/x");
        let wire = serde_json::to_string(&sig).expect("serializes");
        let parsed: ErrorSignature = serde_json::from_str(&wire).expect("deserializes");
        assert_eq!(parsed, sig);
    }

    fn sample(response: &Value, errors: &[&str]) -> ToolResponseSample {
        ToolResponseSample {
            success: Some(fingerprint_response(response)),
            errors: errors.iter().copied().map(error_signature).collect(),
        }
    }

    fn one_tool(name: &str, sample: ToolResponseSample) -> BTreeMap<String, ToolResponseSample> {
        let mut map = BTreeMap::new();
        map.insert(name.to_owned(), sample);
        map
    }

    #[test]
    fn value_only_response_drift_yields_no_change() {
        // The id value changed but the shape did not, so there is no drift.
        let old = one_tool("search", sample(&json!({ "id": "abc" }), &[]));
        let new = one_tool("search", sample(&json!({ "id": "xyz" }), &[]));
        let changes = diff_response_fingerprints(&old, &new);
        assert!(changes.is_empty(), "got {changes:?}");
    }

    #[test]
    fn a_structure_hash_drift_is_a_breaking_response_shape_change() {
        let old = one_tool("search", sample(&json!({ "id": "abc" }), &[]));
        let new = one_tool("search", sample(&json!({ "id": 123 }), &[]));
        let changes = diff_response_fingerprints(&old, &new);
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].tool, "search");
        assert!(changes[0].breaking);
        assert!(matches!(
            changes[0].kind,
            ChangeKind::ResponseShapeChanged { .. }
        ));
    }

    #[test]
    fn an_error_signature_set_change_is_reported_but_not_breaking() {
        let old = one_tool("search", sample(&json!({ "id": "abc" }), &["not found"]));
        let new = one_tool(
            "search",
            sample(&json!({ "id": "abc" }), &["not found", "timed out"]),
        );
        let changes = diff_response_fingerprints(&old, &new);
        assert_eq!(changes.len(), 1);
        assert!(!changes[0].breaking);
        let ChangeKind::ResponseShapeChanged { detail } = &changes[0].kind else {
            panic!("unexpected kind: {:?}", changes[0].kind);
        };
        assert!(detail.contains("error-signature set changed"));
    }

    #[test]
    fn error_set_ignores_volatile_tokens_so_a_rotating_id_is_not_drift() {
        // The two runs return the same error template with a different
        // session uuid; the normalized error set must match, so no change.
        let old = one_tool(
            "search",
            sample(
                &json!({ "id": "abc" }),
                &["session 550e8400-e29b-41d4-a716-446655440000 not found"],
            ),
        );
        let new = one_tool(
            "search",
            sample(
                &json!({ "id": "abc" }),
                &["session 7c9e6679-7425-40de-944b-e07fc1f90ae7 not found"],
            ),
        );
        let changes = diff_response_fingerprints(&old, &new);
        assert!(changes.is_empty(), "got {changes:?}");
    }

    #[test]
    fn a_tool_only_in_one_snapshot_is_left_to_the_catalog_diff() {
        let old = one_tool("search", sample(&json!({ "id": "abc" }), &[]));
        let new = one_tool("fetch", sample(&json!({ "id": 1 }), &[]));
        let changes = diff_response_fingerprints(&old, &new);
        assert!(changes.is_empty(), "got {changes:?}");
    }

    #[test]
    fn an_uncaptured_success_side_produces_no_structure_change() {
        // The baseline captured a response but the new run did not; with no
        // comparable success fingerprint we report no structure drift.
        let old = one_tool("search", sample(&json!({ "id": "abc" }), &[]));
        let new = one_tool(
            "search",
            ToolResponseSample {
                success: None,
                errors: Vec::new(),
            },
        );
        let changes = diff_response_fingerprints(&old, &new);
        assert!(changes.is_empty(), "got {changes:?}");
    }

    #[test]
    fn changes_are_sorted_by_tool_name() {
        let mut old = BTreeMap::new();
        old.insert("zebra".to_owned(), sample(&json!({ "id": "a" }), &[]));
        old.insert("alpha".to_owned(), sample(&json!({ "id": "a" }), &[]));
        let mut new = BTreeMap::new();
        new.insert("zebra".to_owned(), sample(&json!({ "id": 1 }), &[]));
        new.insert("alpha".to_owned(), sample(&json!({ "id": 1 }), &[]));
        let changes = diff_response_fingerprints(&old, &new);
        assert_eq!(changes.len(), 2);
        assert_eq!(changes[0].tool, "alpha");
        assert_eq!(changes[1].tool, "zebra");
    }

    #[test]
    fn tool_response_sample_round_trips_through_json() {
        let s = sample(&json!({ "id": "abc", "items": [1, 2] }), &["not found"]);
        let wire = serde_json::to_string(&s).expect("serializes");
        let parsed: ToolResponseSample = serde_json::from_str(&wire).expect("deserializes");
        assert_eq!(parsed, s);
    }
}
