//! CI status badges and an aggregate health score.
//!
//! Emits a shields.io endpoint JSON (`{schemaVersion,label,message,color}`) and
//! a self-contained SVG, plus a 0-100 health score that rolls up the per-lane
//! grades a run already produces (compliance numeric score, conformance tier,
//! security pass rate) into one number for a README badge.

use serde::Serialize;

/// A shields.io endpoint-format badge.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ShieldsBadge {
    /// Always 1: the shields endpoint schema version.
    #[serde(rename = "schemaVersion")]
    pub schema_version: u32,
    /// Left-side label text.
    pub label: String,
    /// Right-side message text.
    pub message: String,
    /// Right-side color (a shields named color).
    pub color: String,
}

impl ShieldsBadge {
    /// Build a badge with the shields schema version pinned to 1.
    pub fn new(
        label: impl Into<String>,
        message: impl Into<String>,
        color: impl Into<String>,
    ) -> Self {
        ShieldsBadge {
            schema_version: 1,
            label: label.into(),
            message: message.into(),
            color: color.into(),
        }
    }
}

/// Shields named color for a 0-100 score.
pub fn color_for_score(score: u8) -> &'static str {
    match score {
        90..=u8::MAX => "brightgreen",
        80..=89 => "green",
        70..=79 => "yellowgreen",
        60..=69 => "yellow",
        40..=59 => "orange",
        _ => "red",
    }
}

/// Aggregate a 0-100 health score from whatever lanes ran.
///
/// Each present lane contributes a 0-100 sub-score and the result is their mean,
/// rounded. Conformance tiers map T1=100, T2=85, T3=70, anything else=0. With no
/// inputs the score is 0: nothing was verified, so nothing is vouched for.
pub fn health_score(
    compliance: Option<f64>,
    conformance_tier: Option<&str>,
    security_pass_rate: Option<f64>,
) -> u8 {
    let mut parts: Vec<f64> = Vec::new();
    if let Some(c) = compliance {
        parts.push(c.clamp(0.0, 100.0));
    }
    if let Some(t) = conformance_tier {
        parts.push(match t {
            "T1" => 100.0,
            "T2" => 85.0,
            "T3" => 70.0,
            _ => 0.0,
        });
    }
    if let Some(s) = security_pass_rate {
        parts.push(s.clamp(0.0, 1.0) * 100.0);
    }
    if parts.is_empty() {
        return 0;
    }
    (parts.iter().sum::<f64>() / parts.len() as f64).round() as u8
}

/// Render a badge as a minimal, self-contained flat SVG.
pub fn render_svg(badge: &ShieldsBadge) -> String {
    // Rough width: ~7px per char plus padding. Good enough for a README badge
    // without pulling in a font-metrics dependency.
    let lw = 10 + badge.label.len() * 7;
    let mw = 10 + badge.message.len() * 7;
    let total = lw + mw;
    let color = svg_color(&badge.color);
    let label = xml_escape(&badge.label);
    let message = xml_escape(&badge.message);
    let mx = lw + 5;
    format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total}\" height=\"20\" role=\"img\" \
aria-label=\"{label}: {message}\">\
<rect width=\"{lw}\" height=\"20\" fill=\"#555\"/>\
<rect x=\"{lw}\" width=\"{mw}\" height=\"20\" fill=\"{color}\"/>\
<g fill=\"#fff\" font-family=\"Verdana,Geneva,sans-serif\" font-size=\"11\">\
<text x=\"5\" y=\"14\">{label}</text>\
<text x=\"{mx}\" y=\"14\">{message}</text>\
</g></svg>\n"
    )
}

/// Map a shields named color to a hex value for the SVG fill.
fn svg_color(name: &str) -> &'static str {
    match name {
        "brightgreen" => "#4c1",
        "green" => "#97ca00",
        "yellowgreen" => "#a4a61d",
        "yellow" => "#dfb317",
        "orange" => "#fe7d37",
        "red" => "#e05d44",
        _ => "#9f9f9f",
    }
}

/// Escape the XML-significant characters for safe SVG text.
fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn color_bands_follow_the_score() {
        assert_eq!(color_for_score(100), "brightgreen");
        assert_eq!(color_for_score(85), "green");
        assert_eq!(color_for_score(72), "yellowgreen");
        assert_eq!(color_for_score(65), "yellow");
        assert_eq!(color_for_score(50), "orange");
        assert_eq!(color_for_score(10), "red");
    }

    #[test]
    fn health_score_averages_present_lanes() {
        // compliance 90 + conformance T2 (85) + security 1.0 (100) -> mean 91.7 -> 92
        let s = health_score(Some(90.0), Some("T2"), Some(1.0));
        assert_eq!(s, 92);
    }

    #[test]
    fn health_score_ignores_absent_lanes() {
        assert_eq!(health_score(Some(80.0), None, None), 80);
        assert_eq!(health_score(None, Some("T1"), None), 100);
    }

    #[test]
    fn health_score_is_zero_with_no_inputs() {
        assert_eq!(health_score(None, None, None), 0);
    }

    #[test]
    fn badge_serializes_to_shields_endpoint_shape() {
        let b = ShieldsBadge::new("mcptest", "92/100", color_for_score(92));
        let json = serde_json::to_string(&b).unwrap();
        assert!(json.contains("\"schemaVersion\":1"));
        assert!(json.contains("\"label\":\"mcptest\""));
        assert!(json.contains("\"message\":\"92/100\""));
        assert!(json.contains("\"color\":\"brightgreen\""));
    }

    #[test]
    fn svg_is_self_contained_and_escapes() {
        let b = ShieldsBadge::new("a<b", "ok", "green");
        let svg = render_svg(&b);
        assert!(svg.starts_with("<svg"));
        assert!(svg.contains("a&lt;b"));
        assert!(svg.trim_end().ends_with("</svg>"));
    }
}
