//! Human and SARIF rendering for the config scan report.

use serde_json::{json, Value};

use crate::security::SecuritySeverity;

use super::model::ConfigScanReport;

/// Render the report as a human-friendly summary with per-server risk banding.
pub fn render_pretty(report: &ConfigScanReport) -> String {
    let mut out = String::new();
    out.push_str("MCP config security scan\n\n");
    out.push_str(&format!("  source: {}\n", report.source));
    out.push_str(&format!(
        "  scanned {} server(s), {} finding(s)\n\n",
        report.servers.len(),
        report.findings.len()
    ));
    for s in &report.servers {
        out.push_str(&format!(
            "  [{}] {} (risk {}/10, {} finding(s))\n",
            s.band.as_str(),
            s.server,
            s.score,
            s.finding_count
        ));
    }
    out.push('\n');
    if report.findings.is_empty() {
        out.push_str("  no findings\n");
        return out;
    }
    for f in &report.findings {
        out.push_str(&format!(
            "  {:<8} {:<8} {} :: {}\n",
            f.severity.as_str(),
            f.rule_id,
            f.server,
            f.message
        ));
        out.push_str(&format!("           evidence: {}\n", f.evidence));
        out.push_str(&format!("           fix:      {}\n", f.remediation));
    }
    out
}

/// Render the report as a SARIF 2.1.0 document.
///
/// Mirrors the source-proof reporter so config-scan and source-proof findings
/// flow through the same downstream pipeline (GitHub code scanning, etc.).
pub fn to_sarif(report: &ConfigScanReport) -> Value {
    let results: Vec<Value> = report
        .findings
        .iter()
        .map(|f| {
            json!({
                "ruleId": f.rule_id,
                "level": sarif_level(f.severity),
                "message": { "text": format!("{} {}", f.message, f.remediation) },
                "locations": [{
                    "physicalLocation": {
                        "artifactLocation": { "uri": report.source }
                    }
                }],
                "properties": {
                    "server": f.server,
                    "severity": f.severity.as_str(),
                    "name": f.name,
                    "evidence": f.evidence
                }
            })
        })
        .collect();

    json!({
        "version": "2.1.0",
        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
        "runs": [{
            "tool": { "driver": {
                "name": "mcptest-config-scan",
                "informationUri": "https://mcptest.sh",
                "rules": []
            }},
            "results": results
        }]
    })
}

/// Map the security severity onto the three SARIF levels.
fn sarif_level(severity: SecuritySeverity) -> &'static str {
    match severity {
        SecuritySeverity::Critical | SecuritySeverity::High => "error",
        SecuritySeverity::Medium | SecuritySeverity::Low => "warning",
        SecuritySeverity::Info => "note",
    }
}

/// Inline stylesheet for the self-contained HTML report. Kept terse so the
/// whole report stays one openable file with no external assets.
const HTML_CSS: &str = "\
body{font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;margin:2rem;color:#1a1a1a;}
h1{font-size:1.4rem;margin:0 0 .25rem;}
.meta{color:#666;margin:0 0 1.25rem;}
.servers{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1.5rem;}
.card{display:flex;flex-direction:column;padding:.6rem .9rem;border-radius:8px;min-width:120px;border:1px solid rgba(0,0,0,.1);}
.card .score{font-size:1.3rem;font-weight:700;}
.card .name{font-weight:600;}
.card .count{color:#555;font-size:.85rem;}
.band-green{background:#e6f4ea;}
.band-yellow{background:#fef7e0;}
.band-red{background:#fce8e6;}
table{border-collapse:collapse;width:100%;}
th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid #eee;vertical-align:top;}
th{background:#fafafa;font-weight:600;}
code{background:#f3f3f3;padding:.05rem .3rem;border-radius:4px;font-size:.85em;}
.sev{font-weight:700;text-transform:uppercase;font-size:.75rem;}
.sev-critical{color:#b00020;}
.sev-high{color:#d93025;}
.sev-medium{color:#b06000;}
.sev-low{color:#666;}
.clean{color:#137333;font-weight:600;}
footer{margin-top:2rem;color:#999;font-size:.8rem;}
";

/// Render the report as a self-contained HTML document with inline CSS.
///
/// One file, no external assets, so it can be published as a CI artifact or
/// opened straight from disk. Per-server risk uses the same green/yellow/red
/// banding as the terminal output, and evidence is the already-redacted string.
pub fn to_html(report: &ConfigScanReport) -> String {
    let mut h = String::new();
    h.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
    h.push_str("<title>mcptest config security scan</title>\n<style>\n");
    h.push_str(HTML_CSS);
    h.push_str("</style>\n</head>\n<body>\n<h1>MCP config security scan</h1>\n");
    h.push_str(&format!(
        "<p class=\"meta\">source: <code>{}</code> &middot; {} server(s) &middot; {} finding(s)</p>\n",
        html_escape(&report.source),
        report.servers.len(),
        report.findings.len()
    ));

    h.push_str("<div class=\"servers\">\n");
    for s in &report.servers {
        h.push_str(&format!(
            "<div class=\"card band-{}\"><span class=\"score\">{}/10</span>\
             <span class=\"name\">{}</span>\
             <span class=\"count\">{} finding(s)</span></div>\n",
            s.band.as_str(),
            s.score,
            html_escape(&s.server),
            s.finding_count
        ));
    }
    h.push_str("</div>\n");

    if report.findings.is_empty() {
        h.push_str("<p class=\"clean\">No findings.</p>\n");
    } else {
        h.push_str(
            "<table>\n<thead><tr><th>Severity</th><th>Rule</th><th>Server</th>\
             <th>Finding</th><th>Evidence</th><th>Fix</th></tr></thead>\n<tbody>\n",
        );
        for f in &report.findings {
            h.push_str(&format!(
                "<tr><td><span class=\"sev sev-{}\">{}</span></td><td>{}</td><td>{}</td>\
                 <td>{}</td><td><code>{}</code></td><td>{}</td></tr>\n",
                f.severity.as_str(),
                f.severity.as_str(),
                html_escape(&f.rule_id),
                html_escape(&f.server),
                html_escape(&f.message),
                html_escape(&f.evidence),
                html_escape(&f.remediation),
            ));
        }
        h.push_str("</tbody>\n</table>\n");
    }
    h.push_str("<footer>Generated by <a href=\"https://mcptest.sh\">mcptest</a> scan.</footer>\n");
    h.push_str("</body>\n</html>\n");
    h
}

/// Escape the five HTML-significant characters for safe inline rendering.
fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config_scan::model::{ConfigFinding, RiskBand, ServerRisk};

    fn sample() -> ConfigScanReport {
        ConfigScanReport {
            source: "mcp.json".into(),
            servers: vec![ServerRisk {
                server: "gh".into(),
                score: 8,
                band: RiskBand::Red,
                finding_count: 1,
            }],
            findings: vec![ConfigFinding {
                rule_id: "CFG-001".into(),
                name: "hardcoded-secret".into(),
                severity: SecuritySeverity::Critical,
                server: "gh".into(),
                message: "env var `T` holds a hardcoded secret".into(),
                evidence: "T=[REDACTED:token]".into(),
                remediation: "use a reference".into(),
            }],
        }
    }

    #[test]
    fn pretty_shows_band_score_and_finding() {
        let text = render_pretty(&sample());
        assert!(text.contains("[red] gh (risk 8/10"));
        assert!(text.contains("CFG-001"));
        assert!(text.contains("evidence: T=[REDACTED:token]"));
    }

    #[test]
    fn pretty_reports_clean_when_no_findings() {
        let mut report = sample();
        report.findings.clear();
        assert!(render_pretty(&report).contains("no findings"));
    }

    #[test]
    fn sarif_maps_critical_to_error_level() {
        let doc = to_sarif(&sample());
        let result = &doc["runs"][0]["results"][0];
        assert_eq!(result["ruleId"], "CFG-001");
        assert_eq!(result["level"], "error");
        assert_eq!(doc["version"], "2.1.0");
    }

    #[test]
    fn html_is_self_contained_with_inline_css_and_findings() {
        let doc = to_html(&sample());
        assert!(doc.starts_with("<!DOCTYPE html>"));
        // Inline CSS, no external asset reference.
        assert!(doc.contains("<style>"));
        assert!(!doc.contains("<link"));
        assert!(doc.contains("band-red"));
        assert!(doc.contains("CFG-001"));
        assert!(doc.contains("T=[REDACTED:token]"));
        assert!(doc.trim_end().ends_with("</html>"));
    }

    #[test]
    fn html_reports_clean_when_no_findings() {
        let mut report = sample();
        report.findings.clear();
        let doc = to_html(&report);
        assert!(doc.contains("No findings."));
        assert!(!doc.contains("<tbody>"));
    }

    #[test]
    fn html_escapes_special_characters() {
        let mut report = sample();
        report.findings[0].message = "drop <table> & run".into();
        let doc = to_html(&report);
        assert!(doc.contains("drop &lt;table&gt; &amp; run"));
        assert!(!doc.contains("drop <table> & run"));
    }
}
