//! SARIF 2.1.0 renderer for security findings.
//!
//! Security findings drop into code scanning (GitHub, GitLab, Azure DevOps) the
//! same way the compliance and lint results do. The rule set is derived from
//! the checks that ran, so a consumer can pivot from a result to its rule
//! definition and references.

use std::collections::BTreeSet;

use serde_json::{json, Value};

use super::{FindingSource, SecurityCheck, SecurityReport, SecuritySeverity};

/// SARIF level for a severity. Critical and high are errors; medium is a
/// warning; low and info are notes. This mirrors how the compliance reporter
/// maps MUST/SHOULD/MAY.
fn level(severity: SecuritySeverity) -> &'static str {
    match severity {
        SecuritySeverity::Critical | SecuritySeverity::High => "error",
        SecuritySeverity::Medium => "warning",
        SecuritySeverity::Low | SecuritySeverity::Info => "note",
    }
}

/// Render the report and the checks that produced it as a SARIF 2.1.0 log.
///
/// Imported external-scanner findings on the report are re-emitted as further
/// results, each carrying its scanner provenance and whether it counts toward
/// the grade, so a round-trip through `security import --format sarif` preserves
/// the supplement metadata.
pub fn render_sarif(
    checks: &[Box<dyn SecurityCheck>],
    report: &SecurityReport,
) -> serde_json::Result<String> {
    let mut rules: Vec<Value> = checks
        .iter()
        .map(|c| {
            json!({
                "id": c.id(),
                "name": c.name(),
                "helpUri": format!("https://mcptest.sh/security/{}", c.id()),
                "properties": { "category": c.category().as_str(), "references": c.references() },
            })
        })
        .collect();

    let mut results: Vec<Value> = report
        .findings
        .iter()
        .map(|f| {
            json!({
                "ruleId": f.rule_id,
                "level": level(f.severity),
                "message": { "text": format!("{} ({})", f.message, f.definition) },
                "properties": {
                    "severity": f.severity.as_str(),
                    "definition": f.definition,
                    "kind": serde_json::to_value(f.kind).unwrap_or(Value::Null),
                    "evidence": f.evidence,
                },
            })
        })
        .collect();

    // One rule definition per distinct imported rule id, so a SARIF consumer can
    // still pivot from an imported result to a rule. BTreeSet keeps the order
    // stable across runs.
    let imported_rule_ids: BTreeSet<&str> = report
        .supplement
        .iter()
        .map(|f| f.rule_id.as_str())
        .collect();
    for id in imported_rule_ids {
        rules.push(json!({
            "id": id,
            "properties": { "category": "external" },
        }));
    }
    for f in &report.supplement {
        let tool = match &f.source {
            FindingSource::External { tool } => tool.as_str(),
            FindingSource::Bundled => "bundled",
        };
        results.push(json!({
            "ruleId": f.rule_id,
            "level": level(f.severity),
            "message": { "text": format!("{} [{}]", f.message, tool) },
            "properties": {
                "severity": f.severity.as_str(),
                "source": tool,
                "advisory": f.advisory,
                "duplicate_of": f.duplicate_of,
                "counts_toward_grade": f.counts_toward_grade(),
                "evidence": f.evidence,
            },
        }));
    }

    let log = json!({
        "version": "2.1.0",
        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
        "runs": [{
            "tool": { "driver": {
                "name": "mcptest-security",
                "informationUri": "https://mcptest.sh",
                "rules": rules,
            }},
            "results": results,
        }],
    });
    serde_json::to_string_pretty(&log)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::security::{
        CheckCategory, DefinitionKind, SecurityEngine, SecurityFinding, SecurityReport,
        SecuritySeverity,
    };

    #[test]
    fn level_maps_severity_to_sarif() {
        assert_eq!(level(SecuritySeverity::Critical), "error");
        assert_eq!(level(SecuritySeverity::High), "error");
        assert_eq!(level(SecuritySeverity::Medium), "warning");
        assert_eq!(level(SecuritySeverity::Info), "note");
    }

    #[test]
    fn sarif_carries_rules_and_results() {
        let report = SecurityReport {
            findings: vec![SecurityFinding {
                rule_id: "SEC-005".into(),
                name: "hidden-unicode".into(),
                severity: SecuritySeverity::High,
                category: CheckCategory::Surface,
                definition: "search".into(),
                kind: DefinitionKind::Tool,
                message: "invisible character".into(),
                evidence: "U+200B".into(),
            }],
            definitions_checked: 1,
            supplement: Vec::new(),
        };
        let engine = SecurityEngine::with_bundled_checks();
        let sarif = render_sarif(engine.checks(), &report).expect("render");
        let parsed: Value = serde_json::from_str(&sarif).expect("valid json");
        assert_eq!(parsed["version"], "2.1.0");
        assert_eq!(parsed["runs"][0]["results"][0]["ruleId"], "SEC-005");
        assert_eq!(parsed["runs"][0]["results"][0]["level"], "error");
        assert!(parsed["runs"][0]["tool"]["driver"]["rules"]
            .as_array()
            .is_some_and(|r| !r.is_empty()));
    }

    #[test]
    fn sarif_reemits_imported_findings_with_provenance() {
        use crate::security::{normalize_generic_json, run_import, SecurityRunOptions};
        let doc = serde_json::json!([
            { "rule_id": "ext.poison", "severity": "high", "message": "imperative" }
        ]);
        let supplements = normalize_generic_json(&doc, "semgrep", false);
        let run = run_import(None, &SecurityRunOptions::default(), supplements);
        let engine = SecurityEngine::with_bundled_checks();
        let sarif = render_sarif(engine.checks(), &run.report).expect("render");
        let parsed: Value = serde_json::from_str(&sarif).expect("valid json");
        let results = parsed["runs"][0]["results"]
            .as_array()
            .expect("results array");
        let imported = results
            .iter()
            .find(|r| r["ruleId"] == "ext.poison")
            .expect("the imported result is present");
        assert_eq!(imported["level"], "error");
        assert_eq!(imported["properties"]["source"], "semgrep");
        assert_eq!(imported["properties"]["counts_toward_grade"], true);
    }
}
