//! Cross-spec-version corpus diff.
//!
//! When a server author upgrades the spec version their suite is pinned to
//! (the `spec_version:` pin parsed in `mcptest-config`), the expected
//! behavior of shared rules can change underneath them. This module diffs
//! two [`VersionCorpus`] snapshots and reports which rules changed their
//! expectations, which rules are new in the target version, and which were
//! removed.
//!
//! The report shape mirrors the model-compat baseline diff
//! (`crate::model_compat::diff::BaselineDiff`): a flat entry list plus a
//! pre-computed summary so reporters print without re-scanning. The
//! classification here is simpler (no PASS/DRIFT/FAIL severity rubric):
//! a shared rule is either `Unchanged` or `Changed`, and an unshared rule
//! is `Added` or `Removed`.

use serde::{Deserialize, Serialize};

use crate::compliance::corpus::VersionCorpus;

/// How a single rule differs between the two spec versions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VersionChange {
    /// Present in both versions with identical `expect` blocks.
    Unchanged,
    /// Present in both versions but the `expect` blocks differ. This is
    /// the upgrade-breakage signal: a suite authored against the old
    /// version may fail against the new one.
    Changed,
    /// Present only in the target (new) version. Newly testable behavior.
    Added,
    /// Present only in the source (old) version. The rule no longer
    /// applies to the target version's corpus.
    Removed,
}

/// One rule's entry in a [`VersionDiff`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionDiffEntry {
    /// Stable rule identifier.
    pub rule_id: String,
    /// Classification of the change.
    pub change: VersionChange,
    /// The rule's human-readable name. Taken from whichever version
    /// carries it (the target version wins when both do, since that is
    /// the name the author upgrades into).
    pub name: String,
}

/// Roll-up counts a reporter prints in summary mode.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionDiffSummary {
    /// Rules shared and identical.
    pub unchanged: usize,
    /// Rules shared but with altered expectations (the breakage signal).
    pub changed: usize,
    /// Rules new in the target version.
    pub added: usize,
    /// Rules dropped from the target version.
    pub removed: usize,
}

/// Diff between two spec versions' corpora.
///
/// Entries are sorted by `rule_id` so the report is byte-stable across
/// runs. The summary is pre-computed during [`diff_versions`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionDiff {
    /// Source (old) spec version name.
    pub from: String,
    /// Target (new) spec version name.
    pub to: String,
    /// Per-rule entries, ordered by `rule_id`.
    pub entries: Vec<VersionDiffEntry>,
    /// Aggregate counts.
    pub summary: VersionDiffSummary,
}

impl VersionDiff {
    /// True when no shared rule changed its expectation. A clean upgrade
    /// path: added and removed rules are fine, only `changed` rules
    /// signal that an existing suite may break.
    pub fn is_clean(&self) -> bool {
        self.summary.changed == 0
    }

    /// Rules whose expected behavior changed between the two versions.
    /// The breakage signal a reporter highlights first.
    pub fn changed_rules(&self) -> impl Iterator<Item = &VersionDiffEntry> {
        self.entries
            .iter()
            .filter(|e| e.change == VersionChange::Changed)
    }

    /// Render a plain-text report. Pass `color = false` for CI logs.
    pub fn render_pretty(&self, color: bool) -> String {
        let bold = if color { "\x1b[1m" } else { "" };
        let reset = if color { "\x1b[0m" } else { "" };
        let mut out = String::new();
        out.push_str(&format!(
            "{bold}Spec-version diff{reset} {} -> {}: {} changed, {} added, {} removed, {} unchanged\n",
            self.from,
            self.to,
            self.summary.changed,
            self.summary.added,
            self.summary.removed,
            self.summary.unchanged,
        ));
        for entry in &self.entries {
            let tag = match entry.change {
                VersionChange::Unchanged => paint("unchanged", "\x1b[32m", color),
                VersionChange::Changed => paint("CHANGED", "\x1b[31m", color),
                VersionChange::Added => paint("added", "\x1b[36m", color),
                VersionChange::Removed => paint("removed", "\x1b[33m", color),
            };
            out.push_str(&format!("  [{tag}] {} {}\n", entry.rule_id, entry.name));
        }
        out
    }
}

fn paint(text: &str, code: &str, color: bool) -> String {
    if color {
        format!("{code}{text}\x1b[0m")
    } else {
        text.to_string()
    }
}

/// Diff two spec versions' corpora keyed by `rule_id`.
///
/// `from` is the version a suite is currently pinned to; `to` is the
/// version it is upgrading into. A rule shared by both is `Changed` when
/// its `expect` blocks differ and `Unchanged` otherwise; a rule present in
/// only one side is `Added` (only in `to`) or `Removed` (only in `from`).
pub fn diff_versions(from: &VersionCorpus, to: &VersionCorpus) -> VersionDiff {
    let mut diff = VersionDiff {
        from: from.spec_version.clone(),
        to: to.spec_version.clone(),
        ..VersionDiff::default()
    };

    let mut ids: Vec<&str> = from
        .rules
        .keys()
        .chain(to.rules.keys())
        .map(String::as_str)
        .collect();
    ids.sort_unstable();
    ids.dedup();

    for id in ids {
        let entry = match (from.rules.get(id), to.rules.get(id)) {
            (Some(old), Some(new)) => {
                let change = if old.expects == new.expects {
                    diff.summary.unchanged += 1;
                    VersionChange::Unchanged
                } else {
                    diff.summary.changed += 1;
                    VersionChange::Changed
                };
                VersionDiffEntry {
                    rule_id: id.to_string(),
                    change,
                    name: new.name.clone(),
                }
            }
            (None, Some(new)) => {
                diff.summary.added += 1;
                VersionDiffEntry {
                    rule_id: id.to_string(),
                    change: VersionChange::Added,
                    name: new.name.clone(),
                }
            }
            (Some(old), None) => {
                diff.summary.removed += 1;
                VersionDiffEntry {
                    rule_id: id.to_string(),
                    change: VersionChange::Removed,
                    name: old.name.clone(),
                }
            }
            // `ids` is the union of both key sets, so this arm is
            // unreachable; default to a removed entry rather than panic.
            (None, None) => continue,
        };
        diff.entries.push(entry);
    }

    diff
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;
    use std::path::Path;

    use crate::compliance::corpus::RuleExpectations;

    fn rule(id: &str, expect_yaml: &str) -> RuleExpectations {
        let value: serde_yaml::Value = serde_yaml::from_str(expect_yaml).expect("yaml");
        RuleExpectations {
            rule_id: id.to_string(),
            name: format!("{id} name"),
            expects: vec![value],
        }
    }

    fn corpus(version: &str, rules: Vec<RuleExpectations>) -> VersionCorpus {
        let mut map: BTreeMap<String, RuleExpectations> = BTreeMap::new();
        for r in rules {
            map.insert(r.rule_id.clone(), r);
        }
        VersionCorpus {
            spec_version: version.to_string(),
            rules: map,
        }
    }

    fn repo_compliance_dir() -> std::path::PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(|p| p.parent())
            .map(|root| root.join("compliance"))
            .expect("workspace root above crates/mcptest-core")
    }

    #[test]
    fn identical_expectations_report_no_drift() {
        let from = corpus("v1", vec![rule("PROTO-001", "error:\n  code: -32600")]);
        let to = corpus("v2", vec![rule("PROTO-001", "error:\n  code: -32600")]);
        let diff = diff_versions(&from, &to);
        assert_eq!(diff.summary.changed, 0);
        assert_eq!(diff.summary.unchanged, 1);
        assert!(diff.is_clean());
    }

    #[test]
    fn altered_expect_is_flagged_as_changed() {
        let from = corpus(
            "v1",
            vec![rule("PROTO-002", "schema:\n  required: [capabilities]")],
        );
        let to = corpus(
            "v2",
            vec![rule(
                "PROTO-002",
                "schema:\n  required: [capabilities, transport]",
            )],
        );
        let diff = diff_versions(&from, &to);
        assert_eq!(diff.summary.changed, 1);
        assert!(!diff.is_clean());
        let changed: Vec<&str> = diff.changed_rules().map(|e| e.rule_id.as_str()).collect();
        assert_eq!(changed, vec!["PROTO-002"]);
    }

    #[test]
    fn rule_only_in_target_is_added() {
        let from = corpus("v1", vec![]);
        let to = corpus("v2", vec![rule("ELICIT-001", "schema:\n  type: object")]);
        let diff = diff_versions(&from, &to);
        assert_eq!(diff.summary.added, 1);
        assert_eq!(diff.entries[0].change, VersionChange::Added);
    }

    #[test]
    fn rule_only_in_source_is_removed() {
        let from = corpus(
            "v1",
            vec![rule("TRANSPORT-001", "transport_observation: sse")],
        );
        let to = corpus("v2", vec![]);
        let diff = diff_versions(&from, &to);
        assert_eq!(diff.summary.removed, 1);
        assert_eq!(diff.entries[0].change, VersionChange::Removed);
    }

    #[test]
    fn entries_are_sorted_by_rule_id() {
        let from = corpus(
            "v1",
            vec![
                rule("PROTO-002", "a: 1"),
                rule("EDGE-001", "b: 2"),
                rule("TOOL-001", "c: 3"),
            ],
        );
        let to = from.clone();
        let diff = diff_versions(&from, &to);
        let ids: Vec<&str> = diff.entries.iter().map(|e| e.rule_id.as_str()).collect();
        assert_eq!(ids, vec!["EDGE-001", "PROTO-002", "TOOL-001"]);
    }

    #[test]
    fn key_order_in_expect_does_not_count_as_a_change() {
        // serde_yaml mapping equality ignores key order, so two
        // expectations that differ only in key order are unchanged.
        let from = corpus("v1", vec![rule("PROTO-001", "code: -1\nmessage: x")]);
        let to = corpus("v2", vec![rule("PROTO-001", "message: x\ncode: -1")]);
        let diff = diff_versions(&from, &to);
        assert_eq!(diff.summary.changed, 0);
        assert_eq!(diff.summary.unchanged, 1);
    }

    #[test]
    fn render_pretty_emits_summary_header() {
        let from = corpus("v1", vec![rule("PROTO-002", "a: 1")]);
        let to = corpus("v2", vec![rule("PROTO-002", "a: 2")]);
        let diff = diff_versions(&from, &to);
        let s = diff.render_pretty(false);
        assert!(s.contains("Spec-version diff"));
        assert!(s.contains("CHANGED"));
        assert!(s.contains("v1 -> v2"));
    }

    #[test]
    fn real_corpus_proto_008_is_unchanged_v2024_to_draft() {
        // PROTO-008 is shared by v2024-11-05 and draft with an identical
        // expect block (transport_observation: line_delimited).
        let dir = repo_compliance_dir();
        let v2024 = VersionCorpus::load_dir("v2024-11-05", &dir.join("v2024-11-05")).expect("load");
        let draft = VersionCorpus::load_dir("draft", &dir.join("draft")).expect("load");
        let diff = diff_versions(&v2024, &draft);
        let proto8 = diff
            .entries
            .iter()
            .find(|e| e.rule_id == "PROTO-008")
            .expect("PROTO-008 present in both");
        assert_eq!(proto8.change, VersionChange::Unchanged);
    }

    #[test]
    fn real_corpus_proto_002_changed_v2024_to_v2025_06_18() {
        // PROTO-002 adds an optional `transport` enum in v2025-06-18, so
        // its expect block differs from v2024-11-05.
        let dir = repo_compliance_dir();
        let v2024 = VersionCorpus::load_dir("v2024-11-05", &dir.join("v2024-11-05")).expect("load");
        let v2025 = VersionCorpus::load_dir("v2025-06-18", &dir.join("v2025-06-18")).expect("load");
        let diff = diff_versions(&v2024, &v2025);
        let proto2 = diff
            .entries
            .iter()
            .find(|e| e.rule_id == "PROTO-002")
            .expect("PROTO-002 present in both");
        assert_eq!(proto2.change, VersionChange::Changed);
        // v2025-06-18 has lifecycle-only PROTO-002 plus several rules not
        // in v2024-11-05, so there must be added rules too.
        assert!(diff.summary.added > 0);
        // v2024-11-05's many PROTO/TOOL/etc rules are absent from the
        // small v2025-06-18 delta corpus, so there are removed rules.
        assert!(diff.summary.removed > 0);
    }
}
