//! Data model for the tool-catalog diff.
//!
//! The change taxonomy ([`CatalogChange`], [`ChangeKind`], [`SchemaChange`]),
//! the [`DiffReport`] wrapper, and the [`DiffError`] type. The diff algorithm
//! that produces them lives in [`super`] (`diff::tool_catalog`); this file is
//! types only and is re-exported from there so external paths are unchanged.

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

/// One change between two tool-catalog snapshots.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CatalogChange {
    /// Affected tool name.
    pub tool: String,
    /// What changed.
    pub kind: ChangeKind,
    /// Whether the change is breaking for clients calling the old shape.
    pub breaking: bool,
    /// One-line human description.
    pub description: String,
}

/// Kind of change observed between two catalogs.
///
/// Tagged as `type` for stable JSON output so external tooling can match on
/// it without depending on the Rust variant index.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ChangeKind {
    /// Tool present in new but not in old.
    Added,
    /// Tool present in old but not in new.
    Removed,
    /// Tool description changed.
    DescriptionChanged {
        /// Previous description.
        old: String,
        /// New description.
        new: String,
    },
    /// `inputSchema` changed.
    InputSchemaChanged {
        /// Per-property breakdown of the schema delta.
        details: Vec<SchemaChange>,
    },
    /// `outputSchema` changed (if the server declares one).
    OutputSchemaChanged {
        /// Per-property breakdown of the schema delta.
        details: Vec<SchemaChange>,
    },
    /// Some non-schema, non-description metadata changed.
    AnnotationChanged {
        /// Top-level field name that changed (for example `annotations`).
        field: String,
        /// Previous value.
        old: Value,
        /// New value.
        new: Value,
    },
    /// A tool's display title changed. Carried as its own kind (rather
    /// than folded into an annotation) because the title moved to a
    /// first-class `title` field in the 2025-06-18 revision, and clients
    /// surface it to end users; never breaking, since it is presentation
    /// only.
    TitleChanged {
        /// Previous title.
        old: String,
        /// New title.
        new: String,
    },
    /// The server's top-level `instructions` text changed. Surfaced so a
    /// reviewer can eyeball a behavioral hint drift; never breaking,
    /// since instructions are advisory free text rather than a contract.
    InstructionsChanged {
        /// Previous instructions.
        old: String,
        /// New instructions.
        new: String,
    },
    /// A tool's captured response shape drifted even though its declared
    /// schema may not have. Carried as its own kind because it is a
    /// behavioral (runtime) signal rather than a catalog-declaration
    /// change: the value-tolerant structure hash of the tool's response, or
    /// the set of normalized error signatures it returns, differs between
    /// two snapshots that both captured responses. Breaking when the
    /// structure hash itself changed, since a caller parsing the old shape
    /// can break; a pure error-signature shift is reported but not breaking.
    ResponseShapeChanged {
        /// What specifically drifted (structure hash, error signatures, or
        /// both), rendered for the report line.
        detail: String,
    },
}

/// A single property-level change inside a JSON Schema object.
///
/// We deliberately keep this granular instead of returning a flat diff so the
/// renderer can highlight breaking vs non-breaking changes per property.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum SchemaChange {
    /// A required field was added (breaking for callers that did not send it).
    RequiredAdded(String),
    /// A required field was removed (non-breaking).
    RequiredRemoved(String),
    /// A property was added.
    PropertyAdded {
        /// JSON-pointer-style path inside the schema.
        path: String,
    },
    /// A property was removed (breaking).
    PropertyRemoved {
        /// JSON-pointer-style path inside the schema.
        path: String,
    },
    /// A property type changed (breaking).
    TypeChanged {
        /// JSON-pointer-style path inside the schema.
        path: String,
        /// Previous JSON-Schema type token.
        old: String,
        /// New JSON-Schema type token.
        new: String,
    },
    /// An enum value was removed (breaking for callers using it).
    EnumValueRemoved {
        /// JSON-pointer-style path inside the schema.
        path: String,
        /// The enum literal, rendered with `Value::to_string`.
        value: String,
    },
    /// An enum value was added (non-breaking).
    EnumValueAdded {
        /// JSON-pointer-style path inside the schema.
        path: String,
        /// The enum literal, rendered with `Value::to_string`.
        value: String,
    },
}

/// Result of diffing two catalogs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DiffReport {
    /// Every change observed, in catalog order then by tool name.
    pub changes: Vec<CatalogChange>,
    /// True when at least one change is breaking.
    pub has_breaking: bool,
    /// Human-readable notes about comparisons that were skipped, for
    /// example a field gated behind a protocol revision that one of the
    /// two snapshots predates. Empty (and omitted from JSON) when both
    /// snapshots speak the same feature set or no versions were supplied.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub notes: Vec<String>,
}

/// Errors raised by [`super::diff_tool_catalogs`].
#[derive(Debug, Error)]
pub enum DiffError {
    /// One of the inputs does not look like a `tools/list` response.
    #[error("malformed catalog on the {side} side: {reason}")]
    MalformedCatalog {
        /// Which input was malformed ("old" or "new").
        side: &'static str,
        /// Human-readable reason the input failed shape checks.
        reason: String,
    },
}
