//! Diffing two evidence packs (WOR-1412).
//!
//! `mcptest evidence diff <old> <new>` answers "what changed since the last
//! approved pack?" It compares the flat [`PackSignals`] of two packs and
//! classifies each changed field as an improvement, a regression, or a neutral
//! change, so a release gate can fail closed on a regression. Mirrors the
//! shape of `mcptest diff` and `model-compat diff`.

use serde::{Deserialize, Serialize};

use crate::evidence_pack::{extract_signals, EvidencePack, PackSignals};

/// Which way a changed signal moved, in governance terms.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeDirection {
    /// The change is a quality improvement (more passing, fewer findings).
    Improved,
    /// The change is a quality regression (more failing, more findings).
    Regressed,
    /// The field changed but neither better nor worse on its own (cost, tier
    /// that cannot be ranked).
    Neutral,
}

/// One changed signal between two packs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PackDiffEntry {
    /// The signal that changed (for example `tests_failed`).
    pub field: String,
    /// The old value, rendered.
    pub old: String,
    /// The new value, rendered.
    pub new: String,
    /// Which way it moved.
    pub direction: ChangeDirection,
}

/// Counts across a diff.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct PackDiffSummary {
    /// Number of regressed fields.
    pub regressions: usize,
    /// Number of improved fields.
    pub improvements: usize,
    /// Number of neutral changes.
    pub neutral: usize,
}

/// The full diff between two packs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvidencePackDiff {
    /// Every changed signal.
    pub entries: Vec<PackDiffEntry>,
    /// Roll-up counts.
    pub summary: PackDiffSummary,
}

impl EvidencePackDiff {
    /// Whether any field regressed. Drives the `--fail-on-regression` gate.
    pub fn has_regression(&self) -> bool {
        self.summary.regressions > 0
    }
}

/// Diff two packs field by field.
pub fn diff_packs(old: &EvidencePack, new: &EvidencePack) -> EvidencePackDiff {
    let a = extract_signals(old);
    let b = extract_signals(new);
    let mut entries = Vec::new();

    // Higher-is-better counts.
    push_usize(
        &mut entries,
        "tests_passed",
        a.tests_passed,
        b.tests_passed,
        true,
    );
    // Higher-is-worse counts.
    push_usize(
        &mut entries,
        "tests_failed",
        a.tests_failed,
        b.tests_failed,
        false,
    );
    push_usize(
        &mut entries,
        "security_findings",
        a.security_total,
        b.security_total,
        false,
    );
    push_usize(
        &mut entries,
        "security_high_critical",
        a.security_high,
        b.security_high,
        false,
    );
    // Higher-is-better percentages.
    push_pct(
        &mut entries,
        "coverage_pct",
        a.coverage_pct,
        b.coverage_pct,
        true,
    );
    push_pct(
        &mut entries,
        "fault_coverage_pct",
        a.fault_coverage_pct,
        b.fault_coverage_pct,
        true,
    );
    // Tier and cost.
    push_tier(&mut entries, &a, &b);
    push_cost(&mut entries, &a, &b);

    let summary = summarize(&entries);
    EvidencePackDiff { entries, summary }
}

/// Append an entry for a changed `usize`, where rising is good or bad.
fn push_usize(
    entries: &mut Vec<PackDiffEntry>,
    field: &str,
    old: usize,
    new: usize,
    higher_is_better: bool,
) {
    if old == new {
        return;
    }
    let direction = direction_for(new > old, higher_is_better);
    entries.push(PackDiffEntry {
        field: field.to_string(),
        old: old.to_string(),
        new: new.to_string(),
        direction,
    });
}

/// Append an entry for a changed optional percentage.
fn push_pct(
    entries: &mut Vec<PackDiffEntry>,
    field: &str,
    old: Option<f64>,
    new: Option<f64>,
    higher_is_better: bool,
) {
    let (Some(o), Some(n)) = (old, new) else {
        return;
    };
    if (o - n).abs() < f64::EPSILON {
        return;
    }
    let direction = direction_for(n > o, higher_is_better);
    entries.push(PackDiffEntry {
        field: field.to_string(),
        old: format!("{o:.0}%"),
        new: format!("{n:.0}%"),
        direction,
    });
}

/// Direction for a numeric move given whether the value rose and whether rising
/// is the good direction.
fn direction_for(rose: bool, higher_is_better: bool) -> ChangeDirection {
    if rose == higher_is_better {
        ChangeDirection::Improved
    } else {
        ChangeDirection::Regressed
    }
}

/// Append a compliance-tier change, ranked so a drop reads as a regression.
fn push_tier(entries: &mut Vec<PackDiffEntry>, a: &PackSignals, b: &PackSignals) {
    if a.compliance_tier == b.compliance_tier {
        return;
    }
    let direction = match (tier_rank(&a.compliance_tier), tier_rank(&b.compliance_tier)) {
        (Some(old), Some(new)) if new < old => ChangeDirection::Improved,
        (Some(old), Some(new)) if new > old => ChangeDirection::Regressed,
        // One side absent or unrankable: report it, do not judge it.
        _ => ChangeDirection::Neutral,
    };
    entries.push(PackDiffEntry {
        field: "compliance_tier".to_string(),
        old: a
            .compliance_tier
            .clone()
            .unwrap_or_else(|| "none".to_string()),
        new: b
            .compliance_tier
            .clone()
            .unwrap_or_else(|| "none".to_string()),
        direction,
    });
}

/// Rank a tier label so a smaller number is a better grade. Unknown labels are
/// unrankable (`None`), which the caller treats as neutral.
fn tier_rank(tier: &Option<String>) -> Option<usize> {
    let order = [
        "A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D", "F", "Fail",
    ];
    let label = tier.as_deref()?;
    order.iter().position(|t| *t == label)
}

/// Append a cost change. A cost move is informational, never a pass/fail gate.
fn push_cost(entries: &mut Vec<PackDiffEntry>, a: &PackSignals, b: &PackSignals) {
    let (Some(o), Some(n)) = (a.cost_usd, b.cost_usd) else {
        return;
    };
    if (o - n).abs() < f64::EPSILON {
        return;
    }
    entries.push(PackDiffEntry {
        field: "cost_usd".to_string(),
        old: format!("${o:.4}"),
        new: format!("${n:.4}"),
        direction: ChangeDirection::Neutral,
    });
}

/// Tally the entries by direction.
fn summarize(entries: &[PackDiffEntry]) -> PackDiffSummary {
    let mut s = PackDiffSummary::default();
    for entry in entries {
        match entry.direction {
            ChangeDirection::Improved => s.improvements += 1,
            ChangeDirection::Regressed => s.regressions += 1,
            ChangeDirection::Neutral => s.neutral += 1,
        }
    }
    s
}

/// Render the diff as Markdown for a PR comment or review.
pub fn render_diff_markdown(diff: &EvidencePackDiff) -> String {
    let mut out = String::from("# Evidence pack diff\n\n");
    if diff.entries.is_empty() {
        out.push_str("No tracked signal changed between the two packs.\n");
        return out;
    }
    out.push_str("| Signal | Old | New | Change |\n| --- | --- | --- | --- |\n");
    for e in &diff.entries {
        out.push_str(&format!(
            "| {} | {} | {} | {} |\n",
            e.field,
            e.old,
            e.new,
            direction_label(e.direction)
        ));
    }
    out.push_str(&format!(
        "\n{} regression(s), {} improvement(s), {} neutral change(s).\n",
        diff.summary.regressions, diff.summary.improvements, diff.summary.neutral
    ));
    out
}

/// Render the diff as a plain-text table.
pub fn render_diff_text(diff: &EvidencePackDiff) -> String {
    let mut out = String::from("evidence pack diff\n");
    if diff.entries.is_empty() {
        out.push_str("  no tracked signal changed\n");
        return out;
    }
    for e in &diff.entries {
        out.push_str(&format!(
            "  {:<22} {} -> {} ({})\n",
            e.field,
            e.old,
            e.new,
            direction_label(e.direction)
        ));
    }
    out.push_str(&format!(
        "  {} regression(s), {} improvement(s), {} neutral\n",
        diff.summary.regressions, diff.summary.improvements, diff.summary.neutral
    ));
    out
}

/// Human label for a direction.
fn direction_label(direction: ChangeDirection) -> &'static str {
    match direction {
        ChangeDirection::Improved => "improved",
        ChangeDirection::Regressed => "REGRESSED",
        ChangeDirection::Neutral => "changed",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::evidence::{EvidenceArtifact, EvidenceGrades, EVIDENCE_SCHEMA_VERSION};
    use crate::evidence_pack::{build_pack, EvidencePackSections};

    fn pack_with(passed: usize, failed: usize, tier: &str) -> EvidencePack {
        let artifact = EvidenceArtifact {
            schema_version: EVIDENCE_SCHEMA_VERSION.to_string(),
            server_identity: vec![],
            spec_version: None,
            corpus_version: None,
            source: None,
            generated_at: Some("2026-06-21T00:00:00Z".to_string()),
            cassette_hash: None,
            grades: EvidenceGrades {
                tests_total: passed + failed,
                tests_passed: passed,
                tests_failed: failed,
                security_severity_counts: None,
                compliance_tier: Some(tier.to_string()),
                tdqs_mean_tool_score: None,
                tdqs_min_tool_score: None,
            },
            reproducible: false,
            unverifiable_origin: true,
        };
        build_pack(
            artifact,
            EvidencePackSections::default(),
            "2026-06-21T00:00:00Z".to_string(),
        )
    }

    #[test]
    fn new_failures_read_as_a_regression() {
        let diff = diff_packs(&pack_with(10, 0, "A"), &pack_with(8, 2, "A"));
        assert!(diff.has_regression());
        let failed = diff
            .entries
            .iter()
            .find(|e| e.field == "tests_failed")
            .unwrap();
        assert_eq!(failed.direction, ChangeDirection::Regressed);
    }

    #[test]
    fn a_dropped_tier_is_a_regression() {
        let diff = diff_packs(&pack_with(10, 0, "A"), &pack_with(10, 0, "B"));
        let tier = diff
            .entries
            .iter()
            .find(|e| e.field == "compliance_tier")
            .unwrap();
        assert_eq!(tier.direction, ChangeDirection::Regressed);
        assert!(diff.has_regression());
    }

    #[test]
    fn an_improved_tier_is_not_a_regression() {
        let diff = diff_packs(&pack_with(10, 0, "B"), &pack_with(10, 0, "A"));
        assert!(!diff.has_regression());
        assert_eq!(diff.summary.improvements, 1);
    }

    #[test]
    fn identical_packs_show_no_changes() {
        let diff = diff_packs(&pack_with(10, 0, "A"), &pack_with(10, 0, "A"));
        assert!(diff.entries.is_empty());
        assert!(!diff.has_regression());
    }
}
