//! JSON renderer for a [`super::super::scoring::ComplianceScore`].
//!
//! Emits a stable, schema-versioned wire shape so dashboards and
//! aggregators can consume the score without re-deriving it. We do not
//! use the `Serialize` impl on `ComplianceScore` directly because the
//! report carries an envelope (`schema`, `server`, `results`) that the
//! struct itself does not.

use serde::Serialize;

use super::super::official::CheckResult;
use super::super::scoring::{Section, SectionScore};
use super::{ordered_sections, promotion_hint, RenderInputs};

/// Wire envelope written by [`render_json`].
///
/// `schema` is a stable URL so consumers can validate the JSON shape.
/// Bump the version segment (`v1`, `v2`, ...) only on breaking changes.
#[derive(Debug, Clone, Serialize)]
pub struct JsonReport<'a> {
    /// Schema identifier (URL form).
    pub schema: &'static str,
    /// Server label echoed back from the inputs.
    pub server: &'a str,
    /// Letter grade, ASCII.
    pub grade: &'static str,
    /// Weighted percentage in `[0, 100]`.
    pub numeric: f64,
    /// Applicable-section fraction in `[0, 1]`.
    pub completeness: f64,
    /// Rubric version.
    pub rubric_version: &'a str,
    /// Per-section breakdown, ordered by display order.
    pub sections: Vec<SectionRow>,
    /// MUST rule IDs that failed.
    pub failed_must: &'a [String],
    /// Per-section list of MAY rules that, if passed, would promote to A+.
    pub missing_for_a_plus: Vec<MissingMay>,
    /// Free-form next-step hint.
    pub promotion_hint: String,
    /// Per-rule check results, mirrored for transparency.
    pub results: &'a [CheckResult],
}

/// One row of the per-section breakdown in the JSON envelope.
#[derive(Debug, Clone, Serialize)]
pub struct SectionRow {
    /// Section identifier, lowercase string.
    pub section: &'static str,
    /// Per-section letter grade.
    pub grade: &'static str,
    /// True when the section was scored.
    pub applicable: bool,
    /// MUSTs that passed.
    pub must_passed: usize,
    /// Applicable MUST count.
    pub must_total: usize,
    /// SHOULDs that passed.
    pub should_passed: usize,
    /// Applicable SHOULD count.
    pub should_total: usize,
    /// MAYs that passed.
    pub may_passed: usize,
    /// Applicable MAY count.
    pub may_total: usize,
}

/// One per-section "missing MAY" entry.
#[derive(Debug, Clone, Serialize)]
pub struct MissingMay {
    /// Section identifier.
    pub section: &'static str,
    /// MAY rule IDs in that section. Empty means the section has no
    /// MAY rules at all (vacuously satisfied for A+).
    pub rules: Vec<String>,
}

fn section_row(section: Section, row: &SectionScore) -> SectionRow {
    SectionRow {
        section: section.as_str(),
        grade: row.grade.as_str(),
        applicable: row.applicable,
        must_passed: row.must_passed,
        must_total: row.must_total,
        should_passed: row.should_passed,
        should_total: row.should_total,
        may_passed: row.may_passed,
        may_total: row.may_total,
    }
}

/// Render the score as a pretty-printed JSON string.
pub fn render_json(inputs: &RenderInputs<'_>) -> String {
    let score = inputs.score;
    let sections: Vec<SectionRow> = ordered_sections()
        .iter()
        .filter_map(|s| score.sections.get(s).map(|row| section_row(*s, row)))
        .collect();
    // Re-bucket the flat missing-MAY list per section by reading each
    // section's own `missing_may_ids` so the wire schema keeps its
    // section-keyed shape.
    let missing: Vec<MissingMay> = ordered_sections()
        .iter()
        .filter_map(|s| {
            let row = score.sections.get(s)?;
            if !row.applicable || row.missing_may_ids.is_empty() {
                return None;
            }
            Some(MissingMay {
                section: s.as_str(),
                rules: row.missing_may_ids.clone(),
            })
        })
        .collect();
    let report = JsonReport {
        schema: "https://mcptest.sh/schema/compliance-report-v1.json",
        server: inputs.server_label,
        grade: score.grade.as_str(),
        numeric: score.numeric_score,
        completeness: score.completeness_score,
        rubric_version: &score.rubric_version,
        sections,
        failed_must: &score.failed_must_checks,
        missing_for_a_plus: missing,
        promotion_hint: promotion_hint(score),
        results: inputs.results,
    };
    // The serializer cannot fail on a well-formed in-memory value;
    // matching on the result keeps `unwrap` out of `src/` (AGENTS.md).
    match serde_json::to_string_pretty(&report) {
        Ok(s) => s,
        Err(e) => format!("{{\"error\":\"serialize: {e}\"}}"),
    }
}

#[cfg(test)]
mod tests {
    use super::super::super::official::{CheckResult, CheckSource};
    use super::super::super::registry::{RuleMetadata, RuleRegistry, RuleSeverity};
    use super::super::super::scoring::{score_run, Rubric};
    use super::*;
    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()],
            spec_versions: vec!["v2024-11-05".into()],
            description: "x".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,
        }
    }

    #[test]
    fn json_round_trips_through_serde() {
        // One passing MUST rule with no MAY rules in the registry promotes
        // vacuously to A+ under the current rubric.
        let reg = RuleRegistry {
            version: 1,
            rules: vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
        };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let results = vec![check("PROTO-001", true)];
        let score = score_run(&results, &rubric);
        let out = render_json(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "stdio://x",
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["grade"], "A+");
        assert_eq!(parsed["server"], "stdio://x");
        assert!(parsed["schema"]
            .as_str()
            .unwrap()
            .contains("compliance-report-v1"));
    }

    #[test]
    fn json_includes_failed_must_list_on_f() {
        let reg = RuleRegistry {
            version: 1,
            rules: vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
        };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let results = vec![check("PROTO-001", false)];
        let score = score_run(&results, &rubric);
        let out = render_json(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "x",
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["grade"], "F");
        assert_eq!(parsed["failed_must"][0], "PROTO-001");
    }

    #[test]
    fn json_marks_section_applicability() {
        let reg = RuleRegistry {
            version: 1,
            rules: vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
        };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(&[check("PROTO-001", true)], &rubric);
        let out = render_json(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "x",
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        let resources = parsed["sections"]
            .as_array()
            .unwrap()
            .iter()
            .find(|s| s["section"] == "resources")
            .unwrap();
        assert_eq!(resources["applicable"], false);
    }

    #[test]
    fn json_records_missing_for_a_plus_when_run_is_a() {
        let reg = RuleRegistry {
            version: 1,
            rules: vec![
                rule("PROTO-001", "lifecycle", RuleSeverity::Must),
                rule("PROTO-010", "lifecycle", RuleSeverity::May),
            ],
        };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let results = vec![check("PROTO-001", true), check("PROTO-010", false)];
        let score = score_run(&results, &rubric);
        let out = render_json(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "x",
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["grade"], "A");
        let missing = parsed["missing_for_a_plus"].as_array().unwrap();
        assert!(missing.iter().any(|m| m["section"] == "lifecycle"));
    }

    #[test]
    fn json_handles_grade_with_no_may_rules() {
        // No MAY rules anywhere: every applicable section is vacuously
        // satisfied and the grade promotes to A+ once SHOULDs are clean.
        let reg = RuleRegistry {
            version: 1,
            rules: vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
        };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(&[check("PROTO-001", true)], &rubric);
        let out = render_json(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "x",
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["grade"], "A+");
        assert_eq!(parsed["missing_for_a_plus"].as_array().unwrap().len(), 0);
    }

    #[test]
    fn json_records_grade_b() {
        // 4 of 5 SHOULDs pass = 80% exactly = B.
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..5 {
            rules.push(rule(
                &format!("PROTO-{i:03}-S"),
                "lifecycle",
                RuleSeverity::Should,
            ));
        }
        let reg = RuleRegistry { version: 1, rules };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let mut results = vec![check("PROTO-001", true)];
        for i in 0..5 {
            results.push(check(&format!("PROTO-{i:03}-S"), i < 4));
        }
        let score = score_run(&results, &rubric);
        let out = render_json(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "x",
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["grade"], "B");
    }

    #[test]
    fn json_records_grade_c() {
        // 5 of 10 SHOULDs pass = 50% exactly = C.
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(
                &format!("PROTO-{i:03}-S"),
                "lifecycle",
                RuleSeverity::Should,
            ));
        }
        let reg = RuleRegistry { version: 1, rules };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let mut results = vec![check("PROTO-001", true)];
        for i in 0..10 {
            results.push(check(&format!("PROTO-{i:03}-S"), i < 5));
        }
        let score = score_run(&results, &rubric);
        let out = render_json(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "x",
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["grade"], "C");
    }

    #[test]
    fn json_records_grade_d() {
        // 4 of 10 SHOULDs pass = 40%, below 50% = D.
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(
                &format!("PROTO-{i:03}-S"),
                "lifecycle",
                RuleSeverity::Should,
            ));
        }
        let reg = RuleRegistry { version: 1, rules };
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let mut results = vec![check("PROTO-001", true)];
        for i in 0..10 {
            results.push(check(&format!("PROTO-{i:03}-S"), i < 4));
        }
        let score = score_run(&results, &rubric);
        let out = render_json(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "x",
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["grade"], "D");
    }
}
