//! Diff two `tools/list` snapshots and classify each change.
//!
//! Why this exists: a server can update its tool catalog between releases in
//! ways that silently break clients. The diff library detects those changes
//! and tags each one as breaking or non-breaking so a CI job can fail loudly
//! when a release would break downstream callers.

use std::collections::{BTreeMap, BTreeSet};

use serde_json::Value;

use super::feature_flags::{shared_feature_flags, FeatureFlag};
use crate::protocol::ProtocolVersion;

// Re-export the data model from the sibling types module so the historical
// path `diff::tool_catalog::CatalogChange` (and the `diff::*` re-exports that
// build on it) keep resolving after the  split.
pub use super::catalog_types::{CatalogChange, ChangeKind, DiffError, DiffReport, SchemaChange};

/// Which protocol-gated fields may be compared, and the running list of
/// suppressed comparisons.
///
/// When `shared` is `None` the diff is version-agnostic and gates nothing,
/// preserving the historical [`diff_tool_catalogs`] behavior. When `Some`,
/// only flags in the list are comparable; any gated field outside it is
/// skipped and a one-line reason is appended to `notes`.
struct Gate {
    shared: Option<Vec<FeatureFlag>>,
    notes: Vec<String>,
}

impl Gate {
    /// A gate that compares everything (the no-version path).
    fn ungated() -> Self {
        Gate {
            shared: None,
            notes: Vec::new(),
        }
    }

    /// A gate derived from a version pair: compute the shared feature set
    /// so each gated field can be checked against it.
    fn from_versions(old: ProtocolVersion, new: ProtocolVersion) -> Self {
        Gate {
            shared: Some(shared_feature_flags(old, new)),
            notes: Vec::new(),
        }
    }

    /// True when `flag`'s field is comparable: always when ungated, else
    /// only when both snapshots' revisions support the flag.
    fn allows(&self, flag: FeatureFlag) -> bool {
        match &self.shared {
            None => true,
            Some(shared) => shared.contains(&flag),
        }
    }

    /// Record that `field` was not compared because `flag` is not shared
    /// by both protocol revisions. No-op in the ungated path.
    fn note_skipped(&mut self, field: &str) {
        if self.shared.is_some() {
            self.notes.push(format!(
                "{field} not compared (version-gated): one snapshot predates the revision that introduced it"
            ));
        }
    }
}

/// Diff two `tools/list` JSON responses. Each input should be the full
/// response value (the `result` object with a `tools` array).
///
/// ```no_run
/// use mcptest_core::diff::diff_tool_catalogs;
/// use serde_json::json;
///
/// let old = json!({ "tools": [] });
/// let new = json!({ "tools": [{ "name": "search", "description": "search" }] });
/// let report = diff_tool_catalogs(&old, &new).unwrap();
/// assert_eq!(report.changes.len(), 1);
/// assert!(!report.has_breaking);
/// ```
pub fn diff_tool_catalogs(old: &Value, new: &Value) -> Result<DiffReport, DiffError> {
    let mut gate = Gate::ungated();
    let changes = diff_tools(old, new, &mut gate)?;
    Ok(finish_report(changes, gate))
}

/// Diff two full server snapshots, version-aware.
///
/// Where [`diff_tool_catalogs`] only sees the `tools/list` body, this
/// entry point also diffs the server-level `instructions` text and the
/// `serverInfo.title`, then delegates the per-tool work. Pass `versions`
/// as `Some((old, new))` to gate protocol-revision-specific fields: a
/// field one snapshot's revision could not express is skipped (and
/// footnoted in `notes`) rather than reported as a spurious change. Pass
/// `None` to compare everything, matching [`diff_tool_catalogs`].
///
/// ```no_run
/// use mcptest_core::diff::diff_server_snapshots;
/// use serde_json::json;
///
/// let old = json!({ "tools": [], "instructions": "be terse" });
/// let new = json!({ "tools": [], "instructions": "be verbose" });
/// let report = diff_server_snapshots(&old, &new, None).unwrap();
/// assert_eq!(report.changes.len(), 1);
/// ```
pub fn diff_server_snapshots(
    old: &Value,
    new: &Value,
    versions: Option<(ProtocolVersion, ProtocolVersion)>,
) -> Result<DiffReport, DiffError> {
    let mut gate = match versions {
        Some((o, n)) => Gate::from_versions(o, n),
        None => Gate::ungated(),
    };
    let mut changes = Vec::new();
    diff_server_instructions(old, new, &mut gate, &mut changes);
    diff_server_title(old, new, &mut gate, &mut changes);
    changes.extend(diff_tools(old, new, &mut gate)?);
    Ok(finish_report(changes, gate))
}

/// Sentinel tool name for server-scoped changes (instructions, server
/// title) so they sort and render alongside per-tool rows without
/// colliding with a real tool named after them.
const SERVER_SCOPE: &str = "<server>";

/// Diff the per-tool catalog (added, removed, and reshaped tools) into a
/// fresh change list. Shared by both entry points; `gate` carries the
/// protocol-gating decisions and accumulates skip notes.
fn diff_tools(old: &Value, new: &Value, gate: &mut Gate) -> Result<Vec<CatalogChange>, DiffError> {
    let old_tools = extract_tools(old, "old")?;
    let new_tools = extract_tools(new, "new")?;

    let mut changes = Vec::new();

    // Added tools: in new, missing from old.
    for (name, tool) in &new_tools {
        if !old_tools.contains_key(name) {
            changes.push(CatalogChange {
                tool: name.clone(),
                kind: ChangeKind::Added,
                breaking: false,
                description: format!(
                    "tool `{}` added{}",
                    name,
                    summarize_description(tool).map_or(String::new(), |d| format!(" ({d})"))
                ),
            });
        }
    }

    // Removed tools: in old, missing from new.
    for name in old_tools.keys() {
        if !new_tools.contains_key(name) {
            changes.push(CatalogChange {
                tool: name.clone(),
                kind: ChangeKind::Removed,
                breaking: true,
                description: format!("tool `{name}` removed"),
            });
        }
    }

    // Tools in both: diff description, schemas, annotations, title.
    for (name, new_tool) in &new_tools {
        if let Some(old_tool) = old_tools.get(name) {
            diff_one_tool(name, old_tool, new_tool, gate, &mut changes);
        }
    }

    Ok(changes)
}

/// Sort the changes, compute the breaking flag, and fold the gate's
/// accumulated (de-duplicated, order-preserving) skip notes into the
/// report. Shared tail of both entry points.
fn finish_report(mut changes: Vec<CatalogChange>, gate: Gate) -> DiffReport {
    changes.sort_by(|a, b| {
        a.tool
            .cmp(&b.tool)
            .then_with(|| kind_order(&a.kind).cmp(&kind_order(&b.kind)))
    });
    let has_breaking = changes.iter().any(|c| c.breaking);
    let mut notes = Vec::new();
    for note in gate.notes {
        if !notes.contains(&note) {
            notes.push(note);
        }
    }
    DiffReport {
        changes,
        has_breaking,
        notes,
    }
}

/// Diff the server-level `instructions` free text (the top-level
/// snapshot field, since `ServerCapabilities` does not carry it). Gated
/// behind [`FeatureFlag::ServerInstructions`]; never breaking.
fn diff_server_instructions(
    old: &Value,
    new: &Value,
    gate: &mut Gate,
    out: &mut Vec<CatalogChange>,
) {
    if !gate.allows(FeatureFlag::ServerInstructions) {
        if old.get("instructions") != new.get("instructions") {
            gate.note_skipped("instructions");
        }
        return;
    }
    let old_text = old
        .get("instructions")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let new_text = new
        .get("instructions")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    if old_text != new_text {
        out.push(CatalogChange {
            tool: SERVER_SCOPE.to_owned(),
            kind: ChangeKind::InstructionsChanged {
                old: old_text.to_owned(),
                new: new_text.to_owned(),
            },
            breaking: false,
            description: "server instructions changed".to_owned(),
        });
    }
}

/// Diff the `serverInfo.title` display name. Gated behind
/// [`FeatureFlag::Titles`]; never breaking, since it is presentation only.
fn diff_server_title(old: &Value, new: &Value, gate: &mut Gate, out: &mut Vec<CatalogChange>) {
    let old_title = server_title(old);
    let new_title = server_title(new);
    if old_title == new_title {
        return;
    }
    if !gate.allows(FeatureFlag::Titles) {
        gate.note_skipped("serverInfo.title");
        return;
    }
    out.push(CatalogChange {
        tool: SERVER_SCOPE.to_owned(),
        kind: ChangeKind::TitleChanged {
            old: old_title,
            new: new_title,
        },
        breaking: false,
        description: "server title changed".to_owned(),
    });
}

/// Pull `serverInfo.title` out of a snapshot, returning `""` when absent.
fn server_title(snapshot: &Value) -> String {
    snapshot
        .get("serverInfo")
        .and_then(|s| s.get("title"))
        .and_then(|t| t.as_str())
        .unwrap_or("")
        .to_owned()
}

/// Pull `result.tools` out of a `tools/list` response shape and key it by
/// tool name. Accepts both the full `{ "result": { "tools": [...] } }`
/// envelope and the unwrapped `{ "tools": [...] }` body so users can paste
/// either form.
fn extract_tools(value: &Value, side: &'static str) -> Result<BTreeMap<String, Value>, DiffError> {
    let tools = if let Some(arr) = value.get("tools").and_then(|t| t.as_array()) {
        arr
    } else if let Some(arr) = value
        .get("result")
        .and_then(|r| r.get("tools"))
        .and_then(|t| t.as_array())
    {
        arr
    } else {
        return Err(DiffError::MalformedCatalog {
            side,
            reason: "expected a `tools` array (either at the top level or under `result`)"
                .to_owned(),
        });
    };
    let mut out = BTreeMap::new();
    for (idx, t) in tools.iter().enumerate() {
        let name = match t.get("name").and_then(|n| n.as_str()) {
            Some(n) => n.to_owned(),
            None => {
                return Err(DiffError::MalformedCatalog {
                    side,
                    reason: format!("tool at index {idx} is missing `name`"),
                });
            }
        };
        out.insert(name, t.clone());
    }
    Ok(out)
}

/// Order changes consistently when two changes hit the same tool. Removed
/// before Added before everything else keeps the rendered output readable.
fn kind_order(kind: &ChangeKind) -> u8 {
    match kind {
        ChangeKind::Removed => 0,
        ChangeKind::Added => 1,
        ChangeKind::DescriptionChanged { .. } => 2,
        ChangeKind::InputSchemaChanged { .. } => 3,
        ChangeKind::OutputSchemaChanged { .. } => 4,
        ChangeKind::AnnotationChanged { .. } => 5,
        ChangeKind::TitleChanged { .. } => 6,
        ChangeKind::InstructionsChanged { .. } => 7,
        ChangeKind::ResponseShapeChanged { .. } => 8,
    }
}

/// Lift a tool's description into a one-liner, trimming and clipping at
/// 60 chars so the pretty render stays single-line.
fn summarize_description(tool: &Value) -> Option<String> {
    let raw = tool.get("description").and_then(|d| d.as_str())?.trim();
    if raw.is_empty() {
        return None;
    }
    // Clip on a char boundary, not a byte offset: a server-supplied description
    // whose 58th byte lands mid-multibyte-char would panic a byte slice.
    if raw.chars().count() > 60 {
        let clipped: String = raw.chars().take(57).collect();
        Some(format!("{clipped}..."))
    } else {
        Some(raw.to_owned())
    }
}

/// Diff a single tool that exists on both sides. Pushes each observed change
/// into `out`. `gate` decides whether protocol-gated fields (outputSchema,
/// annotations, title) are comparable; description and inputSchema are
/// always compared.
fn diff_one_tool(
    name: &str,
    old: &Value,
    new: &Value,
    gate: &mut Gate,
    out: &mut Vec<CatalogChange>,
) {
    let old_desc = old
        .get("description")
        .and_then(|d| d.as_str())
        .unwrap_or("")
        .to_owned();
    let new_desc = new
        .get("description")
        .and_then(|d| d.as_str())
        .unwrap_or("")
        .to_owned();
    if old_desc != new_desc {
        out.push(CatalogChange {
            tool: name.to_owned(),
            kind: ChangeKind::DescriptionChanged {
                old: old_desc.clone(),
                new: new_desc.clone(),
            },
            breaking: false,
            description: format!("tool `{name}` description changed"),
        });
    }

    diff_schema_field(name, "inputSchema", old, new, out);
    if gate.allows(FeatureFlag::StructuredOutput) {
        diff_schema_field(name, "outputSchema", old, new, out);
    } else if old.get("outputSchema") != new.get("outputSchema") {
        gate.note_skipped("outputSchema");
    }
    diff_tool_title(name, old, new, gate, out);
    diff_annotations(name, old, new, gate, out);
}

/// Diff a tool's first-class display `title`. A legacy `annotations.title`
/// is left to the annotation diff so it is reported once, not twice.
/// Gated behind [`FeatureFlag::Titles`]; never breaking.
fn diff_tool_title(
    name: &str,
    old: &Value,
    new: &Value,
    gate: &mut Gate,
    out: &mut Vec<CatalogChange>,
) {
    let old_title = tool_title(old);
    let new_title = tool_title(new);
    if old_title == new_title {
        return;
    }
    if !gate.allows(FeatureFlag::Titles) {
        gate.note_skipped("title");
        return;
    }
    out.push(CatalogChange {
        tool: name.to_owned(),
        kind: ChangeKind::TitleChanged {
            old: old_title,
            new: new_title,
        },
        breaking: false,
        description: format!("tool `{name}` title changed"),
    });
}

/// Read a tool's first-class `title` field, returning `""` when absent.
///
/// Only the first-class field counts as a title here. A title carried in
/// the legacy `annotations.title` slot is reported through the annotation
/// diff instead, so a change to it is not double-counted as both a title
/// and an annotation change.
fn tool_title(tool: &Value) -> String {
    tool.get("title")
        .and_then(|t| t.as_str())
        .unwrap_or("")
        .to_owned()
}

/// Diff a single schema-shaped field (`inputSchema` or `outputSchema`).
///
/// When the field is absent on both sides this is a no-op. When it appears
/// on only one side we treat it as a structural change emitted through the
/// same details vec so the renderer has one path.
fn diff_schema_field(
    tool: &str,
    field: &str,
    old: &Value,
    new: &Value,
    out: &mut Vec<CatalogChange>,
) {
    let empty = Value::Null;
    let old_schema = old.get(field).unwrap_or(&empty);
    let new_schema = new.get(field).unwrap_or(&empty);
    if old_schema == new_schema {
        return;
    }

    let mut details = Vec::new();
    diff_schema(old_schema, new_schema, "", &mut details);
    if details.is_empty() {
        // Whole-schema swap with no property-level granularity (for example
        // a top-level `type` change with no properties). Encode it as a type
        // change at the root so the report still says something useful.
        let old_type = schema_type(old_schema);
        let new_type = schema_type(new_schema);
        if old_type != new_type {
            details.push(SchemaChange::TypeChanged {
                path: String::new(),
                old: old_type,
                new: new_type,
            });
        }
    }
    if details.is_empty() {
        return;
    }
    let breaking = details.iter().any(is_breaking_schema_change);
    let kind = if field == "inputSchema" {
        ChangeKind::InputSchemaChanged {
            details: details.clone(),
        }
    } else {
        ChangeKind::OutputSchemaChanged {
            details: details.clone(),
        }
    };
    let description = format!(
        "tool `{tool}` {field} changed ({} change{})",
        details.len(),
        if details.len() == 1 { "" } else { "s" }
    );
    out.push(CatalogChange {
        tool: tool.to_owned(),
        kind,
        breaking,
        description,
    });
}

/// True for schema deltas that break callers writing against the old shape.
fn is_breaking_schema_change(change: &SchemaChange) -> bool {
    matches!(
        change,
        SchemaChange::RequiredAdded(_)
            | SchemaChange::PropertyRemoved { .. }
            | SchemaChange::TypeChanged { .. }
            | SchemaChange::EnumValueRemoved { .. }
    )
}

/// Walk two schemas in parallel and collect property-level changes into
/// `out`. `path` is the JSON-pointer-style prefix for the current node.
///
/// The four phases (required, type, enum, properties) are each their own
/// helper so this entry point stays under the 60-line AGENTS cap.
fn diff_schema(old: &Value, new: &Value, path: &str, out: &mut Vec<SchemaChange>) {
    diff_required(old, new, path, out);
    diff_type(old, new, path, out);
    diff_enum_values(old, new, path, out);
    diff_properties(old, new, path, out);
}

/// Compare the `required` sets at the current level. Adds/removes apply at the
/// current path's `required` keyword.
fn diff_required(old: &Value, new: &Value, path: &str, out: &mut Vec<SchemaChange>) {
    let old_required = required_set(old);
    let new_required = required_set(new);
    for name in new_required.difference(&old_required) {
        out.push(SchemaChange::RequiredAdded(join_path(path, name)));
    }
    for name in old_required.difference(&new_required) {
        out.push(SchemaChange::RequiredRemoved(join_path(path, name)));
    }
}

/// Compare the `type` token at the current level. A change between two
/// non-empty tokens is breaking; an appearance/disappearance (one side empty)
/// is left to the property-level walk.
fn diff_type(old: &Value, new: &Value, path: &str, out: &mut Vec<SchemaChange>) {
    let old_type = schema_type(old);
    let new_type = schema_type(new);
    if old_type != new_type && !old_type.is_empty() && !new_type.is_empty() {
        out.push(SchemaChange::TypeChanged {
            path: path.to_owned(),
            old: old_type,
            new: new_type,
        });
    }
}

/// Compare the `enum` literals at the current level.
fn diff_enum_values(old: &Value, new: &Value, path: &str, out: &mut Vec<SchemaChange>) {
    let old_enum = enum_set(old);
    let new_enum = enum_set(new);
    for v in new_enum.difference(&old_enum) {
        out.push(SchemaChange::EnumValueAdded {
            path: path.to_owned(),
            value: v.clone(),
        });
    }
    for v in old_enum.difference(&new_enum) {
        out.push(SchemaChange::EnumValueRemoved {
            path: path.to_owned(),
            value: v.clone(),
        });
    }
}

/// Recurse into the `properties` map, recursing into shared keys and emitting
/// add/remove changes for the rest.
fn diff_properties(old: &Value, new: &Value, path: &str, out: &mut Vec<SchemaChange>) {
    let old_props = old.get("properties").and_then(|p| p.as_object());
    let new_props = new.get("properties").and_then(|p| p.as_object());
    match (old_props, new_props) {
        (Some(o), Some(n)) => {
            for (name, new_prop) in n {
                let child = join_path(path, name);
                if let Some(old_prop) = o.get(name) {
                    diff_schema(old_prop, new_prop, &child, out);
                } else {
                    out.push(SchemaChange::PropertyAdded { path: child });
                }
            }
            for name in o.keys() {
                if !n.contains_key(name) {
                    out.push(SchemaChange::PropertyRemoved {
                        path: join_path(path, name),
                    });
                }
            }
        }
        (Some(o), None) => {
            for name in o.keys() {
                out.push(SchemaChange::PropertyRemoved {
                    path: join_path(path, name),
                });
            }
        }
        (None, Some(n)) => {
            for name in n.keys() {
                out.push(SchemaChange::PropertyAdded {
                    path: join_path(path, name),
                });
            }
        }
        (None, None) => {}
    }
}

/// Build the set of `required` field names at this schema level. Returns an
/// empty set when the field is missing or malformed; that matches JSON
/// Schema's "no required keyword" semantics.
fn required_set(schema: &Value) -> BTreeSet<String> {
    let mut set = BTreeSet::new();
    let Some(arr) = schema.get("required").and_then(|r| r.as_array()) else {
        return set;
    };
    for item in arr {
        if let Some(name) = item.as_str() {
            set.insert(name.to_owned());
        }
    }
    set
}

/// Return the schema's `type` token, or `""` when absent. We deliberately
/// stringify whatever shape the user supplied: a list of types renders as a
/// canonical JSON array, which is good enough for the diff layer.
fn schema_type(schema: &Value) -> String {
    match schema.get("type") {
        Some(Value::String(s)) => s.clone(),
        Some(other) => other.to_string(),
        None => String::new(),
    }
}

/// Build the set of enum literals at this schema level, rendered as
/// stringified JSON so non-string enums (numbers, booleans) compare cleanly.
fn enum_set(schema: &Value) -> BTreeSet<String> {
    let mut set = BTreeSet::new();
    let Some(arr) = schema.get("enum").and_then(|e| e.as_array()) else {
        return set;
    };
    for item in arr {
        set.insert(item.to_string());
    }
    set
}

/// Join a parent JSON-pointer-style path with a child segment, escaping the
/// child's `/` and `~` per RFC 6901 so paths round-trip.
fn join_path(parent: &str, child: &str) -> String {
    let escaped = child.replace('~', "~0").replace('/', "~1");
    if parent.is_empty() {
        format!("/{escaped}")
    } else {
        format!("{parent}/{escaped}")
    }
}

/// Diff non-schema, non-description top-level annotations. MCP servers can
/// attach an `annotations` object (and similar metadata) on each tool; most
/// changes here are non-breaking, since clients should not depend on metadata
/// semantics. The exception is a behavioral *hint* (readOnlyHint,
/// destructiveHint, idempotentHint) flipping to a less safe value: a client
/// that trusted the safer hint can now be surprised, so we flag that as
/// breaking. Gated behind [`FeatureFlag::Annotations`] when versions differ.
fn diff_annotations(
    name: &str,
    old: &Value,
    new: &Value,
    gate: &mut Gate,
    out: &mut Vec<CatalogChange>,
) {
    let skip = [
        "name",
        "description",
        "inputSchema",
        "outputSchema",
        "title",
    ];
    let old_obj = old.as_object();
    let new_obj = new.as_object();
    let (Some(old_obj), Some(new_obj)) = (old_obj, new_obj) else {
        return;
    };
    let mut fields: BTreeSet<&str> = BTreeSet::new();
    fields.extend(old_obj.keys().map(String::as_str));
    fields.extend(new_obj.keys().map(String::as_str));
    for field in fields {
        if skip.contains(&field) {
            continue;
        }
        let old_v = old_obj.get(field).unwrap_or(&Value::Null);
        let new_v = new_obj.get(field).unwrap_or(&Value::Null);
        if old_v == new_v {
            continue;
        }
        // The `annotations` object is the only metadata MCP gates on a
        // protocol revision; a bare top-level metadata key is not gated.
        if field == "annotations" && !gate.allows(FeatureFlag::Annotations) {
            gate.note_skipped("annotations");
            continue;
        }
        let breaking = field == "annotations" && annotations_object_flips_unsafe(old_v, new_v);
        out.push(CatalogChange {
            tool: name.to_owned(),
            kind: ChangeKind::AnnotationChanged {
                field: field.to_owned(),
                old: old_v.clone(),
                new: new_v.clone(),
            },
            breaking,
            description: format!("tool `{name}` annotation `{field}` changed"),
        });
    }
}

/// True when any safety hint inside the `annotations` object moved to a
/// less safe value. Walks each shared hint key and consults
/// [`annotation_flip_is_breaking`]; non-hint keys (`title`, `openWorldHint`,
/// custom metadata) never make the change breaking.
fn annotations_object_flips_unsafe(old: &Value, new: &Value) -> bool {
    let (Some(old_obj), Some(new_obj)) = (old.as_object(), new.as_object()) else {
        return false;
    };
    let mut keys: BTreeSet<&str> = BTreeSet::new();
    keys.extend(old_obj.keys().map(String::as_str));
    keys.extend(new_obj.keys().map(String::as_str));
    for key in keys {
        let old_v = old_obj.get(key).unwrap_or(&Value::Null);
        let new_v = new_obj.get(key).unwrap_or(&Value::Null);
        if old_v != new_v && annotation_flip_is_breaking(key, old_v, new_v) {
            return true;
        }
    }
    false
}

/// True when a single annotation hint flipped to a less safe value.
///
/// The three safety hints encode a promise to the caller: `readOnlyHint`
/// true means "I will not mutate", `destructiveHint` false means "I will
/// not delete", `idempotentHint` true means "calling me twice is safe".
/// Each promise being withdrawn (true to false, or false to true for
/// destructive) can break a caller that relied on it, so it is breaking.
/// Every other key (`openWorldHint`, custom metadata) is informational.
fn annotation_flip_is_breaking(field: &str, old: &Value, new: &Value) -> bool {
    match field {
        "readOnlyHint" => old.as_bool() == Some(true) && new.as_bool() == Some(false),
        "destructiveHint" => old.as_bool() == Some(false) && new.as_bool() == Some(true),
        "idempotentHint" => old.as_bool() == Some(true) && new.as_bool() == Some(false),
        _ => false,
    }
}

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

    fn catalog(tools: Value) -> Value {
        json!({ "tools": tools })
    }

    #[test]
    fn summarize_description_clips_multibyte_on_a_char_boundary() {
        // 70 multibyte chars: a byte slice at 57 would land mid-char and panic.
        let long = "é".repeat(70);
        let tool = json!({ "name": "x", "description": long });
        let summary = summarize_description(&tool).unwrap();
        assert!(summary.ends_with("..."));
        // 57 retained chars plus the three-dot ellipsis.
        assert_eq!(summary.chars().count(), 60);
    }

    #[test]
    fn detects_added_tool_as_non_breaking() {
        let old = catalog(json!([]));
        let new = catalog(json!([{ "name": "search", "description": "find things" }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert_eq!(report.changes.len(), 1);
        assert_eq!(report.changes[0].tool, "search");
        assert!(matches!(report.changes[0].kind, ChangeKind::Added));
        assert!(!report.changes[0].breaking);
        assert!(!report.has_breaking);
    }

    #[test]
    fn detects_removed_tool_as_breaking() {
        let old = catalog(json!([{ "name": "search" }]));
        let new = catalog(json!([]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert_eq!(report.changes.len(), 1);
        assert!(matches!(report.changes[0].kind, ChangeKind::Removed));
        assert!(report.changes[0].breaking);
        assert!(report.has_breaking);
    }

    #[test]
    fn description_only_change_is_non_breaking() {
        let old = catalog(json!([{ "name": "search", "description": "old" }]));
        let new = catalog(json!([{ "name": "search", "description": "new" }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert_eq!(report.changes.len(), 1);
        assert!(matches!(
            report.changes[0].kind,
            ChangeKind::DescriptionChanged { .. }
        ));
        assert!(!report.changes[0].breaking);
    }

    #[test]
    fn required_field_added_is_breaking() {
        let old = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": { "q": { "type": "string" } }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "required": ["q"],
                "properties": { "q": { "type": "string" } }
            }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(report.has_breaking);
        let kind = &report.changes[0].kind;
        let ChangeKind::InputSchemaChanged { details } = kind else {
            panic!("unexpected kind: {kind:?}");
        };
        assert!(matches!(
            details.as_slice(),
            [SchemaChange::RequiredAdded(p)] if p == "/q"
        ));
    }

    #[test]
    fn required_field_removed_is_non_breaking() {
        let old = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "required": ["q"],
                "properties": { "q": { "type": "string" } }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": { "q": { "type": "string" } }
            }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(!report.has_breaking);
        let kind = &report.changes[0].kind;
        let ChangeKind::InputSchemaChanged { details } = kind else {
            panic!("unexpected kind: {kind:?}");
        };
        assert!(matches!(
            details.as_slice(),
            [SchemaChange::RequiredRemoved(p)] if p == "/q"
        ));
    }

    #[test]
    fn property_removed_is_breaking() {
        let old = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "q": { "type": "string" },
                    "page": { "type": "number" }
                }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": { "q": { "type": "string" } }
            }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(report.has_breaking);
        let ChangeKind::InputSchemaChanged { details } = &report.changes[0].kind else {
            panic!("not an input-schema change");
        };
        assert!(details
            .iter()
            .any(|d| matches!(d, SchemaChange::PropertyRemoved { path } if path == "/page")));
    }

    #[test]
    fn property_added_is_non_breaking() {
        let old = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": { "q": { "type": "string" } }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "q": { "type": "string" },
                    "page": { "type": "number" }
                }
            }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(!report.has_breaking);
    }

    #[test]
    fn type_change_string_to_number_is_breaking() {
        let old = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": { "page": { "type": "string" } }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": { "page": { "type": "number" } }
            }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(report.has_breaking);
        let ChangeKind::InputSchemaChanged { details } = &report.changes[0].kind else {
            panic!("not an input-schema change");
        };
        assert!(details.iter().any(|d| matches!(
            d,
            SchemaChange::TypeChanged { path, old, new }
                if path == "/page" && old == "string" && new == "number"
        )));
    }

    #[test]
    fn enum_value_removed_is_breaking() {
        let old = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "mode": { "type": "string", "enum": ["a", "b", "c"] }
                }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "mode": { "type": "string", "enum": ["a", "b"] }
                }
            }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(report.has_breaking);
    }

    #[test]
    fn enum_value_added_is_non_breaking() {
        let old = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "mode": { "type": "string", "enum": ["a"] }
                }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "mode": { "type": "string", "enum": ["a", "b"] }
                }
            }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(!report.has_breaking);
    }

    #[test]
    fn identical_catalogs_yield_zero_changes() {
        let cat = catalog(json!([{
            "name": "search",
            "description": "find",
            "inputSchema": {
                "type": "object",
                "required": ["q"],
                "properties": { "q": { "type": "string" } }
            }
        }]));
        let report = diff_tool_catalogs(&cat, &cat).unwrap();
        assert!(report.changes.is_empty());
        assert!(!report.has_breaking);
    }

    #[test]
    fn empty_catalogs_yield_zero_changes() {
        let old = catalog(json!([]));
        let new = catalog(json!([]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(report.changes.is_empty());
    }

    #[test]
    fn malformed_input_returns_diff_error() {
        let old = json!({ "not_tools": [] });
        let new = catalog(json!([]));
        let err = diff_tool_catalogs(&old, &new).unwrap_err();
        match err {
            DiffError::MalformedCatalog { side, .. } => assert_eq!(side, "old"),
        }
    }

    #[test]
    fn unwrapped_result_envelope_is_accepted() {
        let old = json!({ "result": { "tools": [] } });
        let new = catalog(json!([{ "name": "search" }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert_eq!(report.changes.len(), 1);
    }

    #[test]
    fn output_schema_change_detected() {
        let old = catalog(json!([{
            "name": "search",
            "outputSchema": {
                "type": "object",
                "properties": { "hits": { "type": "number" } }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "outputSchema": {
                "type": "object",
                "properties": { "hits": { "type": "string" } }
            }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert!(report.has_breaking);
        assert!(matches!(
            report.changes[0].kind,
            ChangeKind::OutputSchemaChanged { .. }
        ));
    }

    #[test]
    fn annotation_change_is_non_breaking() {
        let old = catalog(json!([{ "name": "search", "annotations": { "title": "Old" } }]));
        let new = catalog(json!([{ "name": "search", "annotations": { "title": "New" } }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert_eq!(report.changes.len(), 1);
        assert!(matches!(
            report.changes[0].kind,
            ChangeKind::AnnotationChanged { .. }
        ));
        assert!(!report.changes[0].breaking);
    }

    #[test]
    fn annotation_readonly_flip_to_false_is_breaking() {
        // A tool that advertised `readOnlyHint: true` (a promise not to
        // mutate) flipping to `false` can surprise a caller that trusted
        // the safer hint, so the change is breaking.
        let old = catalog(json!([{
            "name": "search",
            "annotations": { "readOnlyHint": true }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "annotations": { "readOnlyHint": false }
        }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert_eq!(report.changes.len(), 1);
        assert!(matches!(
            report.changes[0].kind,
            ChangeKind::AnnotationChanged { .. }
        ));
        assert!(report.changes[0].breaking);
        assert!(report.has_breaking);
    }

    #[test]
    fn tool_title_change_is_detected_and_non_breaking() {
        let old = catalog(json!([{ "name": "search", "title": "Old Title" }]));
        let new = catalog(json!([{ "name": "search", "title": "New Title" }]));
        let report = diff_tool_catalogs(&old, &new).unwrap();
        assert_eq!(report.changes.len(), 1);
        let ChangeKind::TitleChanged { old, new } = &report.changes[0].kind else {
            panic!("unexpected kind: {:?}", report.changes[0].kind);
        };
        assert_eq!(old, "Old Title");
        assert_eq!(new, "New Title");
        assert!(!report.changes[0].breaking);
    }

    #[test]
    fn server_instructions_drift_is_detected() {
        let old = json!({ "tools": [], "instructions": "be terse" });
        let new = json!({ "tools": [], "instructions": "be verbose" });
        let report = diff_server_snapshots(&old, &new, None).unwrap();
        assert_eq!(report.changes.len(), 1);
        let change = &report.changes[0];
        assert_eq!(change.tool, SERVER_SCOPE);
        let ChangeKind::InstructionsChanged { old, new } = &change.kind else {
            panic!("unexpected kind: {:?}", change.kind);
        };
        assert_eq!(old, "be terse");
        assert_eq!(new, "be verbose");
        assert!(!change.breaking);
    }

    #[test]
    fn version_gated_outputschema_not_reported_when_versions_differ() {
        // The old snapshot speaks 2024-11-05, which predates structured
        // output (2025-06-18). A new `outputSchema` is the server adopting
        // a feature the old revision could not express, not a real change,
        // so the diff suppresses it and footnotes the skip.
        let old = catalog(json!([{ "name": "search" }]));
        let new = catalog(json!([{
            "name": "search",
            "outputSchema": {
                "type": "object",
                "properties": { "hits": { "type": "number" } }
            }
        }]));
        let report = diff_server_snapshots(
            &old,
            &new,
            Some((ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_06_18)),
        )
        .unwrap();
        assert!(report
            .changes
            .iter()
            .all(|c| !matches!(c.kind, ChangeKind::OutputSchemaChanged { .. })));
        assert!(report.notes.iter().any(|n| n.contains("outputSchema")));
    }

    #[test]
    fn same_version_outputschema_change_is_reported() {
        // When both snapshots speak a revision that supports structured
        // output, the outputSchema delta is a real change again.
        let old = catalog(json!([{
            "name": "search",
            "outputSchema": {
                "type": "object",
                "properties": { "hits": { "type": "number" } }
            }
        }]));
        let new = catalog(json!([{
            "name": "search",
            "outputSchema": {
                "type": "object",
                "properties": { "hits": { "type": "string" } }
            }
        }]));
        let report = diff_server_snapshots(
            &old,
            &new,
            Some((ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18)),
        )
        .unwrap();
        assert!(report
            .changes
            .iter()
            .any(|c| matches!(c.kind, ChangeKind::OutputSchemaChanged { .. })));
        assert!(report.notes.is_empty());
    }
}
