//! Single-file HTML renderer for a [`ComplianceScore`].
//!
//! Emits a self-contained document with inline CSS so the report opens
//! in any browser without network access. The color palette mirrors the
//! ADR-0012 badge colors so the HTML matches the pretty terminal output
//! when both are rendered side by side.

use std::fmt::Write;

use super::super::scoring::{ComplianceScore, Grade, Section, SectionScore};
use super::{ordered_sections, promotion_hint, RenderInputs};

fn grade_color(grade: Grade) -> &'static str {
    match grade {
        Grade::APlus => "#10b981", // emerald-500
        Grade::A => "#22c55e",     // green-500
        Grade::B => "#84cc16",     // lime-500
        Grade::C => "#eab308",     // yellow-500
        Grade::D => "#f97316",     // orange-500
        Grade::F => "#ef4444",     // red-500
    }
}

const NA_COLOR: &str = "#9ca3af"; // gray-400

/// HTML-escape the four characters that can break attribute or text content.
fn escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '&' => out.push_str("&amp;"),
            '"' => out.push_str("&quot;"),
            _ => out.push(c),
        }
    }
    out
}

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",
    }
}

/// Render the score as a self-contained HTML document.
pub fn render_html(inputs: &RenderInputs<'_>) -> String {
    let score = inputs.score;
    let mut body = String::new();
    write_header(&mut body, score, inputs.server_label);
    write_section_table(&mut body, score);
    write_failed_must(&mut body, score);
    write_hint(&mut body, score);

    let mut out = String::new();
    let _ = write!(
        out,
        "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
         <title>mcptest compliance: {server}</title>\n\
         <style>{css}</style>\n\
         </head><body>{body}</body></html>\n",
        server = escape(inputs.server_label),
        css = inline_css(),
        body = body,
    );
    out
}

fn inline_css() -> &'static str {
    // Compact, dependency-free CSS. Keep selectors specific to mcptest's
    // wrapper class so this snippet can be embedded in larger reports
    // without polluting outer styles.
    "body{font-family:system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem;color:#111}\
     .mt-grade{display:inline-block;padding:0.5rem 1rem;border-radius:0.5rem;color:#fff;font-weight:700;font-size:1.5rem}\
     table{border-collapse:collapse;width:100%;margin:1rem 0}\
     th,td{text-align:left;padding:0.4rem 0.6rem;border-bottom:1px solid #e5e7eb}\
     .mt-badge{display:inline-block;padding:0.1rem 0.4rem;border-radius:0.25rem;color:#fff;font-size:0.8rem;font-weight:600}\
     .mt-na{color:#9ca3af}\
     .mt-fail{background:#fef2f2;border:1px solid #fecaca;padding:0.5rem 0.75rem;border-radius:0.5rem;margin:1rem 0}\
     .mt-hint{background:#f3f4f6;border-left:3px solid #6b7280;padding:0.5rem 0.75rem;margin:1rem 0}\
     code{background:#f3f4f6;padding:0.1rem 0.3rem;border-radius:0.2rem;font-family:ui-monospace,monospace}"
}

fn write_header(out: &mut String, score: &ComplianceScore, server: &str) {
    let color = grade_color(score.grade);
    let _ = write!(
        out,
        "<h1>mcptest compliance</h1>\n\
         <p>Server: <code>{server}</code></p>\n\
         <p>Rubric: <code>{rubric}</code></p>\n\
         <p><span class=\"mt-grade\" style=\"background:{color}\">{grade}</span> \
         &nbsp;Score: <strong>{score:.1}/100</strong> &nbsp; Completeness: {complete:.0}%</p>\n",
        server = escape(server),
        rubric = escape(&score.rubric_version),
        color = color,
        grade = escape(score.grade.as_str()),
        score = score.numeric_score,
        complete = score.completeness_score * 100.0,
    );
}

fn write_section_table(out: &mut String, score: &ComplianceScore) {
    out.push_str("<h2>Section breakdown</h2>\n");
    out.push_str("<table><thead><tr><th>Section</th><th>Grade</th><th>MUST</th>");
    out.push_str("<th>SHOULD</th><th>MAY</th></tr></thead><tbody>\n");
    for section in ordered_sections() {
        let Some(row) = score.sections.get(section) else {
            continue;
        };
        write_section_row(out, *section, row);
    }
    out.push_str("</tbody></table>\n");
}

fn write_section_row(out: &mut String, section: Section, row: &SectionScore) {
    let label = section_label(section);
    if !row.applicable {
        let _ = writeln!(
            out,
            "<tr><td>{label}</td><td class=\"mt-na\">N/A</td>\
             <td class=\"mt-na\">-</td><td class=\"mt-na\">-</td><td class=\"mt-na\">-</td></tr>",
            label = escape(label),
        );
        let _ = NA_COLOR; // referenced symbol; CSS uses literal hex above.
        return;
    }
    let color = grade_color(row.grade);
    let _ = writeln!(
        out,
        "<tr><td>{label}</td>\
         <td><span class=\"mt-badge\" style=\"background:{color}\">{grade}</span></td>\
         <td>{mp}/{mt}</td><td>{sp}/{st}</td><td>{yp}/{yt}</td></tr>",
        label = escape(label),
        color = color,
        grade = escape(row.grade.as_str()),
        mp = row.must_passed,
        mt = row.must_total,
        sp = row.should_passed,
        st = row.should_total,
        yp = row.may_passed,
        yt = row.may_total,
    );
}

fn write_failed_must(out: &mut String, score: &ComplianceScore) {
    if score.failed_must_checks.is_empty() {
        return;
    }
    out.push_str("<div class=\"mt-fail\"><strong>Failed MUST checks</strong><ul>\n");
    for rule_id in &score.failed_must_checks {
        let _ = writeln!(out, "<li><code>{}</code></li>", escape(rule_id));
    }
    out.push_str("</ul></div>\n");
}

fn write_hint(out: &mut String, score: &ComplianceScore) {
    let hint = promotion_hint(score);
    if hint.is_empty() {
        return;
    }
    let _ = writeln!(out, "<div class=\"mt-hint\">{}</div>", escape(&hint));
}

#[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,
        }
    }

    fn fixture_score(rules: Vec<RuleMetadata>, results: Vec<CheckResult>) -> ComplianceScore {
        let reg = RuleRegistry { version: 1, rules };
        let caps = BTreeSet::from(["tools".into()]);
        let rubric = Rubric::new(&reg, caps);
        score_run(&results, &rubric)
    }

    #[test]
    fn html_starts_with_doctype() {
        let score = fixture_score(
            vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
            vec![check("PROTO-001", true)],
        );
        let out = render_html(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "stdio://x",
        });
        assert!(out.starts_with("<!DOCTYPE html>"));
    }

    #[test]
    fn html_escapes_server_label() {
        let score = fixture_score(
            vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
            vec![check("PROTO-001", true)],
        );
        let out = render_html(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "<script>alert('x')</script>",
        });
        assert!(!out.contains("<script>"));
        assert!(out.contains("&lt;script&gt;"));
    }

    #[test]
    fn html_shows_grade_with_color() {
        let score = fixture_score(
            vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
            vec![check("PROTO-001", true)],
        );
        let out = render_html(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "x",
        });
        assert!(out.contains("class=\"mt-grade\""));
        // No applicable MAY rules: grade lands at A+, color emerald.
        assert!(out.contains("#10b981"));
    }

    #[test]
    fn html_renders_failed_must_block_on_f() {
        let score = fixture_score(
            vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
            vec![check("PROTO-001", false)],
        );
        let out = render_html(&RenderInputs {
            score: &score,
            results: &[check("PROTO-001", false)],
            server_label: "x",
        });
        assert!(out.contains("Failed MUST checks"));
        assert!(out.contains("PROTO-001"));
    }

    #[test]
    fn html_emits_na_row_for_unscored_section() {
        let score = fixture_score(
            vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
            vec![check("PROTO-001", true)],
        );
        let out = render_html(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "x",
        });
        // Resources row should be N/A (capability not declared).
        assert!(out.contains("Resources"));
        assert!(out.contains("mt-na"));
    }

    #[test]
    fn html_inlines_css_with_no_external_links() {
        let score = fixture_score(
            vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
            vec![check("PROTO-001", true)],
        );
        let out = render_html(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "x",
        });
        assert!(out.contains("<style>"));
        assert!(!out.contains("<link"));
    }

    #[test]
    fn html_renders_grade_b_color() {
        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 mut results = vec![check("PROTO-001", true)];
        for i in 0..5 {
            results.push(check(&format!("PROTO-{i:03}-S"), i < 4));
        }
        let score = fixture_score(rules, results.clone());
        let out = render_html(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "x",
        });
        // Lime-500 hex per ADR-0012.
        assert!(out.contains("#84cc16"));
    }

    #[test]
    fn html_renders_grade_c_color() {
        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 mut results = vec![check("PROTO-001", true)];
        for i in 0..10 {
            results.push(check(&format!("PROTO-{i:03}-S"), i < 5));
        }
        let score = fixture_score(rules, results.clone());
        let out = render_html(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "x",
        });
        // Yellow-500 hex per ADR-0012.
        assert!(out.contains("#eab308"));
    }

    #[test]
    fn html_renders_grade_d_color() {
        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 mut results = vec![check("PROTO-001", true)];
        for i in 0..10 {
            results.push(check(&format!("PROTO-{i:03}-S"), i < 4));
        }
        let score = fixture_score(rules, results.clone());
        let out = render_html(&RenderInputs {
            score: &score,
            results: &results,
            server_label: "x",
        });
        // Orange-500 hex per ADR-0012.
        assert!(out.contains("#f97316"));
    }

    #[test]
    fn html_renders_grade_f_color() {
        let score = fixture_score(
            vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)],
            vec![check("PROTO-001", false)],
        );
        let out = render_html(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "x",
        });
        // Red-500 hex per ADR-0012.
        assert!(out.contains("#ef4444"));
    }

    #[test]
    fn html_renders_grade_a_color() {
        // Build a fixture that lands at A (not A+): one MAY exists but
        // fails so the run cannot promote past A.
        let score = fixture_score(
            vec![
                rule("PROTO-001", "lifecycle", RuleSeverity::Must),
                rule("PROTO-010", "lifecycle", RuleSeverity::May),
            ],
            vec![check("PROTO-001", true), check("PROTO-010", false)],
        );
        let out = render_html(&RenderInputs {
            score: &score,
            results: &[],
            server_label: "x",
        });
        // Green-500 hex per ADR-0012.
        assert!(out.contains("#22c55e"));
    }
}
