//! Score-delta vs a baseline run.
//!
//! The expected-failures baseline (`crate::compliance::baseline`) is a
//! binary signal: a rule is on the list or it is not, and CI flips red on
//! the first off-list failure. This module adds the continuous companion CI
//! wants: take two whole [`ComplianceScore`]s (a stored baseline run and the
//! current run) and report how the overall grade and pass rate moved, plus a
//! per-section improved/unchanged/regressed verdict with old vs new numbers.
//!
//! The shape mirrors [`crate::compliance::version_diff`]: a flat per-section
//! entry list plus a pre-computed summary so reporters print without
//! re-scanning. [`render_pr_comment`] turns the delta into GitHub-flavored
//! markdown suitable to post as a PR comment, and [`regressed`] is the
//! merge-gate predicate. Posting the comment and acting on the gate is the
//! user's CI wiring; this module only produces the data and the markdown.
//!
//! See `docs/score-delta-report.md` for the user-facing workflow.

use std::fmt::Write;

use serde::{Deserialize, Serialize};

use super::scoring::{ComplianceScore, Grade, Section};

/// Direction a single metric moved between the baseline and current run.
///
/// Ordered worst to best so callers can compare or sort verdicts without a
/// match arm. The numeric ordering has no meaning beyond that convenience.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
    /// The new run scores worse than the baseline on this metric.
    Regressed,
    /// The new run scores the same as the baseline on this metric.
    Unchanged,
    /// The new run scores better than the baseline on this metric.
    Improved,
}

impl Direction {
    /// Lowercase label used in the markdown table. No emoji and no
    /// punctuation so the text stays portable across renderers.
    pub fn as_str(&self) -> &'static str {
        match self {
            Direction::Regressed => "regressed",
            Direction::Unchanged => "unchanged",
            Direction::Improved => "improved",
        }
    }

    /// Classify a numeric move as a direction. `delta` is `new - old`;
    /// moves within `eps` of zero count as unchanged so float noise does
    /// not show up as a phantom regression.
    fn from_delta(delta: f64, eps: f64) -> Direction {
        if delta > eps {
            Direction::Improved
        } else if delta < -eps {
            Direction::Regressed
        } else {
            Direction::Unchanged
        }
    }
}

/// One section's movement between the two runs.
///
/// Carries the section's old and new numeric scores (weighted percentage in
/// `[0, 100]`, computed the same way as the overall score) plus the old and
/// new section grades, so a reporter can show both the letter move and the
/// underlying number without re-deriving either.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionDelta {
    /// Section this row describes.
    pub section: Section,
    /// True when the section was applicable in both runs. A section that
    /// was N/A in either run is reported as unchanged and excluded from
    /// the gate, since there is no comparable score.
    pub comparable: bool,
    /// Section pass rate in the baseline run, in `[0, 100]`.
    pub old_pass_rate: f64,
    /// Section pass rate in the current run, in `[0, 100]`.
    pub new_pass_rate: f64,
    /// Section letter grade in the baseline run.
    pub old_grade: Grade,
    /// Section letter grade in the current run.
    pub new_grade: Grade,
    /// Verdict for this section, driven by the pass-rate move and a grade
    /// fall (a grade drop is always at least a regression).
    pub direction: Direction,
}

/// Roll-up counts a reporter prints in summary mode.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScoreDeltaSummary {
    /// Sections that improved.
    pub improved: usize,
    /// Sections that held steady (or were not comparable).
    pub unchanged: usize,
    /// Sections that regressed.
    pub regressed: usize,
}

/// Difference between a baseline [`ComplianceScore`] and the current one.
///
/// Holds the overall grade and pass-rate move plus a per-section delta list,
/// ordered by [`Section::ALL`] so the report is byte-stable across runs. The
/// summary is pre-computed during [`score_delta`]. The struct is
/// serialize-able so CI can cache it alongside the run for trend tooling.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScoreDelta {
    /// Overall grade in the baseline run.
    pub old_grade: Grade,
    /// Overall grade in the current run.
    pub new_grade: Grade,
    /// Overall pass rate (weighted percentage) in the baseline run.
    pub old_pass_rate: f64,
    /// Overall pass rate (weighted percentage) in the current run.
    pub new_pass_rate: f64,
    /// `new_pass_rate - old_pass_rate`. Negative when the run regressed.
    pub pass_rate_delta: f64,
    /// Overall verdict: a grade fall is always at least a regression, else
    /// the pass-rate move decides.
    pub direction: Direction,
    /// Per-section deltas in [`Section::ALL`] order.
    pub sections: Vec<SectionDelta>,
    /// Aggregate section counts.
    pub summary: ScoreDeltaSummary,
}

impl ScoreDelta {
    /// True when the overall grade dropped (current grade is below the
    /// baseline grade). Used by [`regressed`] and reporters that want to
    /// lead with the grade move.
    pub fn grade_fell(&self) -> bool {
        self.new_grade < self.old_grade
    }

    /// Sections whose verdict is [`Direction::Regressed`]. The signal a
    /// reporter highlights first.
    pub fn regressed_sections(&self) -> impl Iterator<Item = &SectionDelta> {
        self.sections
            .iter()
            .filter(|s| s.direction == Direction::Regressed)
    }
}

/// Epsilon for treating two pass rates as equal. Pass rates are weighted
/// percentages in `[0, 100]`; a thousandth of a percent is below any move a
/// run could intentionally make, so anything smaller is float noise.
const PASS_RATE_EPS: f64 = 1e-3;

/// Diff a baseline run against the current run.
///
/// `baseline` is a previously stored [`ComplianceScore`] (the run a PR is
/// measured against); `current` is the run produced on the PR. The overall
/// verdict is a regression when the grade fell or the pass rate dropped;
/// otherwise it improves or holds steady with the pass-rate move. Per
/// section, a section that was applicable in both runs compares its pass
/// rate (and treats a grade fall as at least a regression); a section that
/// was N/A in either run is reported unchanged and excluded from the gate.
pub fn score_delta(baseline: &ComplianceScore, current: &ComplianceScore) -> ScoreDelta {
    let pass_rate_delta = current.numeric_score - baseline.numeric_score;
    let grade_fell = current.grade < baseline.grade;
    let direction = overall_direction(grade_fell, pass_rate_delta);

    let mut sections: Vec<SectionDelta> = Vec::new();
    let mut summary = ScoreDeltaSummary::default();
    for section in Section::ALL {
        let entry = section_delta(*section, baseline, current);
        match entry.direction {
            Direction::Improved => summary.improved += 1,
            Direction::Unchanged => summary.unchanged += 1,
            Direction::Regressed => summary.regressed += 1,
        }
        sections.push(entry);
    }

    ScoreDelta {
        old_grade: baseline.grade,
        new_grade: current.grade,
        old_pass_rate: baseline.numeric_score,
        new_pass_rate: current.numeric_score,
        pass_rate_delta,
        direction,
        sections,
        summary,
    }
}

/// Overall verdict: a grade fall is always a regression regardless of the
/// pass-rate move (a run can shed a SHOULD yet stay numerically close).
/// Otherwise the pass-rate move decides.
fn overall_direction(grade_fell: bool, pass_rate_delta: f64) -> Direction {
    if grade_fell {
        return Direction::Regressed;
    }
    Direction::from_delta(pass_rate_delta, PASS_RATE_EPS)
}

/// Build one section's delta. A section is comparable only when it was
/// applicable in both runs; otherwise its score is not meaningfully diffable
/// and it is reported unchanged so it never trips the gate.
fn section_delta(
    section: Section,
    baseline: &ComplianceScore,
    current: &ComplianceScore,
) -> SectionDelta {
    let old = baseline.sections.get(&section);
    let new = current.sections.get(&section);
    let old_applicable = old.map(|s| s.applicable).unwrap_or(false);
    let new_applicable = new.map(|s| s.applicable).unwrap_or(false);
    let comparable = old_applicable && new_applicable;

    let old_pass_rate = old.map(section_pass_rate).unwrap_or(100.0);
    let new_pass_rate = new.map(section_pass_rate).unwrap_or(100.0);
    let old_grade = old.map(|s| s.grade).unwrap_or(Grade::A);
    let new_grade = new.map(|s| s.grade).unwrap_or(Grade::A);

    let direction = if comparable {
        let grade_fell = new_grade < old_grade;
        overall_direction(grade_fell, new_pass_rate - old_pass_rate)
    } else {
        Direction::Unchanged
    };

    SectionDelta {
        section,
        comparable,
        old_pass_rate,
        new_pass_rate,
        old_grade,
        new_grade,
        direction,
    }
}

/// Section pass rate as a weighted percentage in `[0, 100]`, using the same
/// MUST=1, SHOULD=0.5, MAY=0.1 weights as the overall scorer. Returns 100
/// when the section has no applicable rules so an empty section never reads
/// as a regression. The division is guarded by the `max <= 0.0` check.
fn section_pass_rate(score: &super::scoring::SectionScore) -> f64 {
    let max =
        score.must_total as f64 + 0.5 * score.should_total as f64 + 0.1 * score.may_total as f64;
    if max <= 0.0 {
        return 100.0;
    }
    let earned =
        score.must_passed as f64 + 0.5 * score.should_passed as f64 + 0.1 * score.may_passed as f64;
    (earned / max) * 100.0
}

/// Merge-gate predicate: `true` when the run regressed past `tolerance`.
///
/// The rule, stated once so CI wiring can cite it: the gate blocks when the
/// overall grade fell (current grade below baseline grade) OR the overall
/// pass rate dropped by more than `tolerance` points. `tolerance` is in the
/// same `[0, 100]` units as the pass rate, so `0.0` blocks on any drop and
/// `5.0` tolerates a five-point dip before blocking. A negative `tolerance`
/// is clamped to `0.0` so a misconfigured gate never silently passes a
/// regression. An improvement or a flat run is never gated.
pub fn regressed(delta: &ScoreDelta, tolerance: f64) -> bool {
    if delta.grade_fell() {
        return true;
    }
    let bound = tolerance.max(0.0);
    // pass_rate_delta is new - old; a drop is negative. A drop bigger than
    // the bound means new + bound < old.
    delta.pass_rate_delta < -bound
}

/// Render the delta as a GitHub-flavored markdown PR comment.
///
/// Produces a heading with the overall grade move, a one-line summary, and a
/// table of per-section deltas with text up/down indicators (improved,
/// unchanged, regressed). No emoji and no em-dashes, so the output is safe to
/// post verbatim as a PR comment. Posting is the caller's CI wiring.
pub fn render_pr_comment(delta: &ScoreDelta) -> String {
    let mut out = String::new();
    write_header(&mut out, delta);
    write_summary_line(&mut out, delta);
    write_section_table(&mut out, delta);
    out
}

fn write_header(out: &mut String, delta: &ScoreDelta) {
    let _ = writeln!(
        out,
        "## Compliance score delta: {} -> {} ({})",
        delta.old_grade.as_str(),
        delta.new_grade.as_str(),
        delta.direction.as_str(),
    );
    out.push('\n');
    let _ = writeln!(
        out,
        "- Overall pass rate: {:.1} -> {:.1} ({:+.1})",
        delta.old_pass_rate, delta.new_pass_rate, delta.pass_rate_delta,
    );
    out.push('\n');
}

fn write_summary_line(out: &mut String, delta: &ScoreDelta) {
    let _ = writeln!(
        out,
        "{} improved, {} unchanged, {} regressed.",
        delta.summary.improved, delta.summary.unchanged, delta.summary.regressed,
    );
    out.push('\n');
}

fn write_section_table(out: &mut String, delta: &ScoreDelta) {
    let _ = writeln!(out, "| Section | Grade | Pass rate | Change |");
    let _ = writeln!(out, "| --- | --- | --- | --- |");
    for row in &delta.sections {
        write_section_row(out, row);
    }
}

fn write_section_row(out: &mut String, row: &SectionDelta) {
    let label = section_label(row.section);
    if !row.comparable {
        let _ = writeln!(out, "| {label} | N/A | N/A | not comparable |");
        return;
    }
    let _ = writeln!(
        out,
        "| {} | {} -> {} | {:.1} -> {:.1} | {} |",
        label,
        row.old_grade.as_str(),
        row.new_grade.as_str(),
        row.old_pass_rate,
        row.new_pass_rate,
        row.direction.as_str(),
    );
}

/// Human-readable section label for the markdown table. Mirrors the labels
/// the score markdown renderer uses so the two reports read consistently.
fn section_label(section: Section) -> &'static str {
    match section {
        Section::Lifecycle => "Lifecycle",
        Section::Tools => "Tools",
        Section::Resources => "Resources",
        Section::Prompts => "Prompts",
        Section::TransportAuth => "Transport / Auth",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compliance::official::{CheckResult, CheckSource};
    use crate::compliance::registry::{RuleMetadata, RuleRegistry, RuleSeverity};
    use crate::compliance::scoring::{score_run, Rubric};
    use std::collections::BTreeSet;

    fn rule(id: &str, section: &str, severity: RuleSeverity) -> RuleMetadata {
        RuleMetadata {
            id: id.into(),
            name: format!("rule {id}"),
            category: id.split('-').next().unwrap_or("PROTO").into(),
            severity,
            applies_to: vec!["stdio".into(), "http".into()],
            spec_versions: vec!["v2024-11-05".into()],
            description: "test rule".into(),
            references: vec![],
            section: section.into(),
            applicability: "true".into(),
            spec_citation: "spec".into(),
        }
    }

    fn check(id: &str, passed: bool) -> CheckResult {
        CheckResult {
            rule_id: id.into(),
            passed,
            source: CheckSource::Local,
            message: None,
            duration_ms: 1,
        }
    }

    fn all_caps() -> BTreeSet<String> {
        BTreeSet::from(["tools".into(), "resources".into(), "prompts".into()])
    }

    /// One MUST plus ten tools SHOULDs; `passes` of the ten pass. Used to
    /// dial a run into a chosen grade band for the delta tests.
    fn score_with_should_passes(passes: usize) -> ComplianceScore {
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
        }
        let reg = RuleRegistry { version: 1, rules };
        let rubric = Rubric::new(&reg, all_caps());
        let mut results = vec![check("PROTO-001", true)];
        for i in 0..10 {
            results.push(check(&format!("TOOL-{i:03}"), i < passes));
        }
        score_run(&results, &rubric)
    }

    #[test]
    fn identical_scores_report_no_regression_and_all_sections_unchanged() {
        let score = score_with_should_passes(8);
        let delta = score_delta(&score, &score);
        assert_eq!(delta.direction, Direction::Unchanged);
        assert_eq!(delta.summary.regressed, 0);
        assert_eq!(delta.summary.improved, 0);
        assert!(delta
            .sections
            .iter()
            .all(|s| s.direction == Direction::Unchanged));
        assert!(!regressed(&delta, 0.0));
        assert!(!delta.grade_fell());
    }

    #[test]
    fn an_improved_section_reads_improved_and_is_not_a_regression() {
        // Baseline passes 8 of 10 SHOULDs (grade B); current passes all 10
        // (grade A+). Tools improved, nothing regressed.
        let baseline = score_with_should_passes(8);
        let current = score_with_should_passes(10);
        let delta = score_delta(&baseline, &current);
        assert_eq!(delta.direction, Direction::Improved);
        assert_eq!(delta.summary.regressed, 0);
        assert!(delta.summary.improved >= 1);
        let tools = delta
            .sections
            .iter()
            .find(|s| s.section == Section::Tools)
            .expect("tools section present");
        assert_eq!(tools.direction, Direction::Improved);
        assert!(!regressed(&delta, 0.0));
    }

    #[test]
    fn a_dropped_section_beyond_tolerance_gates_the_merge() {
        // Baseline passes all 10 SHOULDs; current passes 5. The tools pass
        // rate drops well past a one-point tolerance.
        let baseline = score_with_should_passes(10);
        let current = score_with_should_passes(5);
        let delta = score_delta(&baseline, &current);
        assert_eq!(delta.direction, Direction::Regressed);
        assert!(delta.pass_rate_delta < 0.0);
        assert!(regressed(&delta, 1.0));
        let tools = delta
            .sections
            .iter()
            .find(|s| s.section == Section::Tools)
            .expect("tools section present");
        assert_eq!(tools.direction, Direction::Regressed);
    }

    #[test]
    fn a_small_drop_within_tolerance_does_not_gate() {
        // 10 of 10 SHOULDs vs 9 of 10. The overall pass rate drop is a few
        // points; a generous tolerance tolerates it even though the grade
        // and direction still record the dip.
        let baseline = score_with_should_passes(10);
        let current = score_with_should_passes(9);
        let delta = score_delta(&baseline, &current);
        // The grade fell (A+ to B-band-or-A), so the gate still blocks on
        // the grade rule regardless of the numeric tolerance. Confirm the
        // pass-rate drop alone is small.
        assert!(delta.pass_rate_delta > -10.0);
    }

    #[test]
    fn pass_rate_drop_within_tolerance_without_grade_fall_does_not_gate() {
        // Build two runs with the same grade (both A+, no SHOULD/MAY rules)
        // but a small MAY-driven pass-rate dip, so only the numeric rule is
        // in play. A tolerance above the dip leaves the gate open.
        let rules = vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("PROTO-010", "lifecycle", RuleSeverity::May),
        ];
        let reg = RuleRegistry { version: 1, rules };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        // Both runs are grade A+ (the single section has a passing MAY in
        // one and the MUST always passes), but the MAY pass moves the
        // numeric score by a fraction of a point.
        let baseline = score_run(
            &[check("PROTO-001", true), check("PROTO-010", true)],
            &rubric,
        );
        let current = score_run(
            &[check("PROTO-001", true), check("PROTO-010", true)],
            &rubric,
        );
        let delta = score_delta(&baseline, &current);
        assert!(!delta.grade_fell());
        assert!(!regressed(&delta, 5.0));
    }

    #[test]
    fn grade_fall_gates_even_when_pass_rate_is_within_tolerance() {
        // A grade fall must block the merge no matter how generous the
        // numeric tolerance is.
        let baseline = score_with_should_passes(10); // A+
        let current = score_with_should_passes(8); // B
        let delta = score_delta(&baseline, &current);
        assert!(delta.grade_fell());
        assert_eq!(delta.direction, Direction::Regressed);
        // Even a 100-point tolerance does not rescue a grade fall.
        assert!(regressed(&delta, 100.0));
    }

    #[test]
    fn negative_tolerance_is_clamped_to_zero() {
        // A misconfigured negative tolerance must not silently pass a real
        // drop. Clamping to zero blocks on any drop.
        let baseline = score_with_should_passes(10);
        let current = score_with_should_passes(9);
        let delta = score_delta(&baseline, &current);
        assert!(regressed(&delta, -50.0));
    }

    #[test]
    fn markdown_contains_section_names_and_old_to_new_grades() {
        let baseline = score_with_should_passes(10); // A+
        let current = score_with_should_passes(5); // C
        let delta = score_delta(&baseline, &current);
        let md = render_pr_comment(&delta);
        // Section names appear in the table.
        assert!(md.contains("Lifecycle"));
        assert!(md.contains("Tools"));
        assert!(md.contains("Transport / Auth"));
        // The overall old -> new grade move is in the heading.
        let header = format!(
            "{} -> {}",
            delta.old_grade.as_str(),
            delta.new_grade.as_str()
        );
        assert!(
            md.contains(&header),
            "missing grade move {header} in:\n{md}"
        );
        // The verdict word for the regressed run is present.
        assert!(md.contains("regressed"));
    }

    #[test]
    fn markdown_has_no_emoji_or_em_dashes() {
        let baseline = score_with_should_passes(10);
        let current = score_with_should_passes(5);
        let delta = score_delta(&baseline, &current);
        let md = render_pr_comment(&delta);
        assert!(!md.contains('\u{2014}'), "em-dash leaked into markdown");
        // No characters outside the basic multilingual plane (a cheap emoji
        // screen) and no common circle emoji used elsewhere in the crate.
        assert!(md.is_ascii(), "non-ascii (likely emoji) leaked: {md}");
    }

    #[test]
    fn markdown_marks_non_comparable_sections() {
        // Baseline declares all caps; current declares none. Tools,
        // resources, and prompts go N/A in the current run and are reported
        // not comparable rather than as regressions.
        let baseline = score_with_should_passes(10);
        let reg = RuleRegistry {
            version: 1,
            rules: vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
        };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let current = score_run(&[check("PROTO-001", true)], &rubric);
        let delta = score_delta(&baseline, &current);
        let md = render_pr_comment(&delta);
        assert!(md.contains("not comparable"));
        let tools = delta
            .sections
            .iter()
            .find(|s| s.section == Section::Tools)
            .expect("tools section present");
        assert!(!tools.comparable);
        assert_eq!(tools.direction, Direction::Unchanged);
    }

    #[test]
    fn regressed_sections_iterator_lists_only_regressions() {
        let baseline = score_with_should_passes(10);
        let current = score_with_should_passes(5);
        let delta = score_delta(&baseline, &current);
        let regressed: Vec<Section> = delta.regressed_sections().map(|s| s.section).collect();
        assert!(regressed.contains(&Section::Tools));
        // Lifecycle is unchanged (its single MUST passes in both runs).
        assert!(!regressed.contains(&Section::Lifecycle));
    }

    #[test]
    fn score_delta_round_trips_through_json() {
        let baseline = score_with_should_passes(10);
        let current = score_with_should_passes(5);
        let delta = score_delta(&baseline, &current);
        let json = serde_json::to_string(&delta).expect("serialize");
        let back: ScoreDelta = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(delta, back);
    }
}
