//! Schema-evolution diff scorecard.
//!
//! Aggregates a [`DiffReport`] over two tool-catalog snapshots into a
//! letter-graded scorecard so a release engineer can answer "is this a
//! safe minor release, or did we narrow a contract?" in one glance.
//!
//! ## Grading rule (v1)
//!
//! - Any tool present in the old snapshot and missing from the new one
//!   is a removed tool, the most disruptive change a server can ship:
//!   grade **F**.
//! - Otherwise, the grade is driven by the number of changes the diff
//!   classified `breaking: true`:
//!   - 4 or more breaking changes: **D**
//!   - 2 or 3 breaking changes: **C**
//!   - exactly 1 breaking change: **B**
//!   - no breaking changes but at least one safe change: **A**
//!   - no changes at all: **A+**
//!
//! The thresholds intentionally match the spirit of the compliance
//! grade table from [`crate::compliance::Grade`]: a release
//! that did the work to keep callers compiling gets an A; A+ is a
//! perfectly clean release. The two scorecards share the `Grade` enum
//! so marketing material lines them up.
//!
//! v1 is bounded to the change taxonomy `crate::diff` already produces
//! (`Added`, `Removed`, `DescriptionChanged`, `InputSchemaChanged`,
//! `OutputSchemaChanged`, `AnnotationChanged`); finer-grained
//! callouts (a removed enum value vs a tightened required, say) keep
//! their existing per-schema-change breakdown on the [`DiffReport`]
//! and the scorecard summarizes from there.

use serde::{Deserialize, Serialize};

use super::catalog_types::{CatalogChange, ChangeKind, DiffReport};
use crate::compliance::Grade;

/// Per-release evaluation of two tool-catalog snapshots.
///
/// Held as plain data so reporters can render it pretty, JSON, or
/// markdown without re-walking the diff. Built by [`score_diff`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaEvolutionScorecard {
    /// Letter grade per the v1 rule documented on the module.
    pub grade: Grade,
    /// Every change the diff observed, breaking and safe combined.
    pub total_changes: usize,
    /// Subset of `total_changes` flagged breaking by the diff.
    pub breaking_changes: usize,
    /// Tools added between the two snapshots. Order-preserving.
    pub added_tools: Vec<String>,
    /// Tools removed between the two snapshots. Order-preserving.
    pub removed_tools: Vec<String>,
    /// Tools whose contract narrowed (any breaking change anchored
    /// against the tool, beyond plain removal). Order-preserving and
    /// de-duped.
    pub regressed_tools: Vec<String>,
}

/// Build the v1 scorecard from a diff report.
///
/// Pure function over `report`; no I/O, no clock dependency, so unit
/// tests pin every grade transition deterministically.
pub fn score_diff(report: &DiffReport) -> SchemaEvolutionScorecard {
    let mut added: Vec<String> = Vec::new();
    let mut removed: Vec<String> = Vec::new();
    let mut regressed: Vec<String> = Vec::new();
    for change in &report.changes {
        match &change.kind {
            ChangeKind::Added => push_once(&mut added, &change.tool),
            ChangeKind::Removed => push_once(&mut removed, &change.tool),
            _ if change.breaking => push_once(&mut regressed, &change.tool),
            _ => {}
        }
    }
    let breaking_changes = report.changes.iter().filter(|c| c.breaking).count();
    let grade = grade_for(&report.changes, &removed, breaking_changes);
    SchemaEvolutionScorecard {
        grade,
        total_changes: report.changes.len(),
        breaking_changes,
        added_tools: added,
        removed_tools: removed,
        regressed_tools: regressed,
    }
}

/// Decide the letter grade per the v1 rule. Pulled out so the rule
/// table reads as a vertical list and each transition is testable in
/// isolation.
fn grade_for(changes: &[CatalogChange], removed: &[String], breaking: usize) -> Grade {
    if !removed.is_empty() {
        // Tool removal is the most disruptive change a server can ship.
        // Every caller of the removed tool breaks, so the scorecard
        // floor is F regardless of how many other safe changes ride
        // along.
        return Grade::F;
    }
    match breaking {
        0 if changes.is_empty() => Grade::APlus,
        0 => Grade::A,
        1 => Grade::B,
        2..=3 => Grade::C,
        _ => Grade::D,
    }
}

/// Append `tool` to `list` only when it is not already the last entry
/// or earlier. Keeps the per-tool lists de-duped without sorting, so
/// the report preserves the diff's natural iteration order (tool name
/// in `mcptest-core::diff` today; alphabetic by sort).
fn push_once(list: &mut Vec<String>, tool: &str) {
    if !list.iter().any(|name| name == tool) {
        list.push(tool.to_string());
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::diff::catalog_types::{CatalogChange, ChangeKind};

    fn change(tool: &str, kind: ChangeKind, breaking: bool) -> CatalogChange {
        CatalogChange {
            tool: tool.to_string(),
            kind,
            breaking,
            description: String::from("fixture"),
        }
    }

    fn report(changes: Vec<CatalogChange>) -> DiffReport {
        let has_breaking = changes.iter().any(|c| c.breaking);
        DiffReport {
            changes,
            has_breaking,
            notes: Vec::new(),
        }
    }

    #[test]
    fn empty_diff_grades_a_plus() {
        let sc = score_diff(&report(Vec::new()));
        assert_eq!(sc.grade, Grade::APlus);
        assert_eq!(sc.total_changes, 0);
        assert_eq!(sc.breaking_changes, 0);
        assert!(sc.added_tools.is_empty());
        assert!(sc.removed_tools.is_empty());
        assert!(sc.regressed_tools.is_empty());
    }

    #[test]
    fn safe_only_changes_grade_a() {
        // One added tool plus one safe annotation change: nothing
        // breaking, so the grade is A but not A+.
        let sc = score_diff(&report(vec![
            change("new_tool", ChangeKind::Added, false),
            change(
                "old_tool",
                ChangeKind::AnnotationChanged {
                    field: "annotations".into(),
                    old: serde_json::json!(null),
                    new: serde_json::json!({"category": "search"}),
                },
                false,
            ),
        ]));
        assert_eq!(sc.grade, Grade::A);
        assert_eq!(sc.added_tools, vec!["new_tool".to_string()]);
        assert!(sc.removed_tools.is_empty());
        assert!(sc.regressed_tools.is_empty());
    }

    #[test]
    fn one_breaking_change_grades_b() {
        // A single breaking input-schema change against one tool drops
        // to B and records the tool as regressed.
        let sc = score_diff(&report(vec![change(
            "search",
            ChangeKind::InputSchemaChanged {
                details: Vec::new(),
            },
            true,
        )]));
        assert_eq!(sc.grade, Grade::B);
        assert_eq!(sc.breaking_changes, 1);
        assert_eq!(sc.regressed_tools, vec!["search".to_string()]);
    }

    #[test]
    fn two_or_three_breaking_grades_c() {
        let two_breaking = report(vec![
            change(
                "search",
                ChangeKind::InputSchemaChanged {
                    details: Vec::new(),
                },
                true,
            ),
            change(
                "fetch",
                ChangeKind::InputSchemaChanged {
                    details: Vec::new(),
                },
                true,
            ),
        ]);
        assert_eq!(score_diff(&two_breaking).grade, Grade::C);

        let three_breaking = report(vec![
            change(
                "a",
                ChangeKind::InputSchemaChanged {
                    details: Vec::new(),
                },
                true,
            ),
            change(
                "b",
                ChangeKind::InputSchemaChanged {
                    details: Vec::new(),
                },
                true,
            ),
            change(
                "c",
                ChangeKind::InputSchemaChanged {
                    details: Vec::new(),
                },
                true,
            ),
        ]);
        assert_eq!(score_diff(&three_breaking).grade, Grade::C);
    }

    #[test]
    fn four_or_more_breaking_grades_d() {
        let changes: Vec<CatalogChange> = (0..4)
            .map(|i| {
                change(
                    &format!("tool_{i}"),
                    ChangeKind::InputSchemaChanged {
                        details: Vec::new(),
                    },
                    true,
                )
            })
            .collect();
        assert_eq!(score_diff(&report(changes)).grade, Grade::D);
    }

    #[test]
    fn a_removed_tool_floors_the_grade_at_f_regardless_of_other_changes() {
        // Even a single removed tool drops to F, even when every other
        // change in the release is safe.
        let sc = score_diff(&report(vec![
            change("old_tool", ChangeKind::Removed, true),
            change("new_tool", ChangeKind::Added, false),
        ]));
        assert_eq!(sc.grade, Grade::F);
        assert_eq!(sc.removed_tools, vec!["old_tool".to_string()]);
        assert_eq!(sc.added_tools, vec!["new_tool".to_string()]);
    }

    #[test]
    fn regressed_tools_are_deduped_in_input_order() {
        // Two breaking changes against the same tool only list it once.
        let sc = score_diff(&report(vec![
            change(
                "search",
                ChangeKind::InputSchemaChanged {
                    details: Vec::new(),
                },
                true,
            ),
            change(
                "search",
                ChangeKind::OutputSchemaChanged {
                    details: Vec::new(),
                },
                true,
            ),
            change(
                "fetch",
                ChangeKind::InputSchemaChanged {
                    details: Vec::new(),
                },
                true,
            ),
        ]));
        assert_eq!(
            sc.regressed_tools,
            vec!["search".to_string(), "fetch".to_string()]
        );
        // The scorecard still counts both breaking edits against
        // `search`, only the regressed-tools roll-up dedupes.
        assert_eq!(sc.breaking_changes, 3);
    }

    #[test]
    fn scorecard_serializes_to_a_stable_json_shape() {
        // Lock the JSON shape so external tooling can match on it.
        let sc = SchemaEvolutionScorecard {
            grade: Grade::B,
            total_changes: 2,
            breaking_changes: 1,
            added_tools: vec!["new".to_string()],
            removed_tools: Vec::new(),
            regressed_tools: vec!["search".to_string()],
        };
        let json = serde_json::to_value(&sc).expect("serializes");
        assert_eq!(json["grade"], serde_json::json!("B"));
        assert_eq!(json["total_changes"], serde_json::json!(2));
        assert_eq!(json["breaking_changes"], serde_json::json!(1));
        assert_eq!(json["added_tools"], serde_json::json!(["new"]));
        assert_eq!(json["removed_tools"], serde_json::json!([]));
        assert_eq!(json["regressed_tools"], serde_json::json!(["search"]));
    }
}
