//! SARIF 2.1.0 reporter.
//!
//! SARIF lets static analysis output ride existing GitHub code-scanning,
//! Azure DevOps, and GitLab Ultimate pipelines. Each failed test becomes
//! one `result` with `level` derived from RFC 2119 severity (MUST -> error,
//! SHOULD -> warning, MAY -> note) pointing at the YAML file and line that
//! authored the test. Per-rule definitions land in
//! `runs[0].tool.driver.rules[]` so SARIF consumers can render a stable
//! rule index across runs.

use std::collections::BTreeSet;
use std::io::Write;
use std::sync::Arc;

use serde_json::{json, Map, Value};

use crate::compliance::RuleSeverity;
use crate::redaction::Redactor;
use crate::report::{Report, Reporter, TestResult, TestStatus};

const SARIF_SCHEMA: &str =
    "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
const HELP_URI_BASE: &str = "https://mcptest.sh/compliance/";
const GENERIC_RULE_ID: &str = "mcptest.assertion.failed";

/// SARIF 2.1.0 reporter (see module docs).
///
/// Accepts an optional [`Redactor`] via [`SarifReporter::with_redactor`].
/// When set, the report is redacted before SARIF entries are built.
/// Prefer [`Report::redacted`] at the dispatch site when redacting many
/// reporters at once.
#[derive(Default)]
pub struct SarifReporter {
    redactor: Option<Arc<Redactor>>,
}

impl SarifReporter {
    /// Attach a redactor so user-supplied strings render through it.
    pub fn with_redactor(mut self, redactor: Arc<Redactor>) -> Self {
        self.redactor = Some(redactor);
        self
    }
}

impl Reporter for SarifReporter {
    fn name(&self) -> &'static str {
        "sarif"
    }

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        let redacted;
        let view: &Report = match &self.redactor {
            Some(r) => {
                redacted = report.redacted(r);
                &redacted
            }
            None => report,
        };
        let failing: Vec<&TestResult> = view
            .tests
            .iter()
            .filter(|t| t.status == TestStatus::Fail)
            .collect();
        let results: Vec<Value> = failing.iter().copied().map(build_result).collect();
        let rules = collect_rules(view);
        // SARIF allows custom `properties` blocks on the run object. We use
        // it to carry run-level timing so dashboards can chart it
        // without re-parsing the JSON model.
        let mut timing = Map::new();
        timing.insert(
            "total_duration_ms".into(),
            Value::from(view.summary.duration_ms),
        );
        timing.insert("cache_hits".into(), Value::from(view.summary.cache_hits));
        if let Some(started) = &view.summary.started_at {
            timing.insert("started_at".into(), Value::String(started.clone()));
        }
        if let Some(ended) = &view.summary.ended_at {
            timing.insert("ended_at".into(), Value::String(ended.clone()));
        }
        let doc = json!({
            "$schema": SARIF_SCHEMA,
            "version": "2.1.0",
            "runs": [{
                "tool": {
                    "driver": {
                        "name": "mcptest",
                        "version": view.mcptest_version,
                        "informationUri": "https://mcptest.sh",
                        "rules": rules,
                    }
                },
                "results": results,
                "properties": {
                    "timing": Value::Object(timing),
                },
            }],
        });
        serde_json::to_writer_pretty(&mut *out, &doc).map_err(std::io::Error::other)?;
        writeln!(out)?;
        Ok(())
    }
}

/// Walk the report once and emit a stable SARIF rule definition for every
/// distinct `rule_id` seen. Tests with no `rule_id` collapse into a single
/// generic entry so the driver's rules array is never empty. Sorting by
/// id keeps the output byte-stable across runs.
fn collect_rules(report: &Report) -> Vec<Value> {
    let mut seen: BTreeSet<String> = BTreeSet::new();
    let mut rules: Vec<Value> = Vec::new();
    let mut generic_needed = false;
    for test in &report.tests {
        match &test.rule_id {
            Some(id) if seen.insert(id.clone()) => {
                rules.push(rule_definition(test, id));
            }
            Some(_) => {}
            None => {
                generic_needed = true;
            }
        }
    }
    if generic_needed {
        rules.push(json!({
            "id": GENERIC_RULE_ID,
            "shortDescription": { "text": "mcptest assertion failure" },
            "fullDescription": {
                "text": "A non-compliance assertion authored in YAML failed at run time.",
            },
            "helpUri": "https://mcptest.sh/docs/yaml-reference",
            "defaultConfiguration": { "level": "error" },
        }));
    }
    rules
}

fn rule_definition(test: &TestResult, id: &str) -> Value {
    let level = sarif_level_for(test);
    json!({
        "id": id,
        "shortDescription": { "text": short_description(test, id) },
        "fullDescription": { "text": full_description(test) },
        "helpUri": format!("{HELP_URI_BASE}{id}"),
        "defaultConfiguration": { "level": level },
    })
}

fn short_description(test: &TestResult, id: &str) -> String {
    // Strip a leading "ID: " prefix from the test name so the short
    // description reads cleanly when both the rule definition and the
    // result point at it. Falls back to the raw name otherwise.
    let prefix = format!("{id}: ");
    test.name
        .strip_prefix(&prefix)
        .unwrap_or(&test.name)
        .to_string()
}

fn full_description(test: &TestResult) -> String {
    // Use the failure message when available so the rule definition still
    // carries actionable text on a SARIF consumer that does not render
    // per-result messages.
    test.failure
        .as_ref()
        .map(|f| f.message.clone())
        .unwrap_or_else(|| test.name.clone())
}

fn sarif_level_for(test: &TestResult) -> &'static str {
    match (test.status, test.severity) {
        (TestStatus::Pass, _) => "none",
        (TestStatus::Skip, _) => "note",
        (_, Some(RuleSeverity::Must)) => "error",
        (_, Some(RuleSeverity::Should)) => "warning",
        (_, Some(RuleSeverity::May)) => "note",
        (_, None) => "error",
    }
}

fn build_result(test: &TestResult) -> Value {
    let message = test
        .failure
        .as_ref()
        .map(|f| f.message.clone())
        .unwrap_or_else(|| "test failed".to_owned());
    let mut location = Map::new();
    if let Some(file) = &test.file {
        let mut physical = Map::new();
        let artifact = json!({ "uri": file });
        physical.insert("artifactLocation".to_owned(), artifact);
        if let Some(line) = test.line {
            physical.insert("region".to_owned(), json!({ "startLine": line }));
        }
        location.insert("physicalLocation".to_owned(), Value::Object(physical));
    }
    let rule_id = test
        .rule_id
        .clone()
        .unwrap_or_else(|| GENERIC_RULE_ID.to_owned());
    let level = sarif_level_for(test);
    let mut entry = Map::new();
    entry.insert("ruleId".to_owned(), Value::String(rule_id));
    entry.insert("level".to_owned(), Value::String(level.to_owned()));
    entry.insert(
        "message".to_owned(),
        json!({ "text": format!("{}: {}", test.name, message) }),
    );
    if !location.is_empty() {
        entry.insert(
            "locations".to_owned(),
            Value::Array(vec![Value::Object(location)]),
        );
    }
    // Per-result custom properties carry timing fields. Static
    // analyzers ignore unknown properties, so this is safe to add even
    // for tools that only read `level` and `message`.
    let mut props = Map::new();
    props.insert("duration_ms".into(), Value::from(test.duration_ms));
    props.insert("cache_hit".into(), Value::Bool(test.cache_hit));
    if let Some(started) = &test.started_at {
        props.insert("started_at".into(), Value::String(started.clone()));
    }
    if let Some(ended) = &test.ended_at {
        props.insert("ended_at".into(), Value::String(ended.clone()));
    }
    if let Some(sev) = test.severity {
        props.insert(
            "severity".into(),
            Value::String(severity_label(sev).to_owned()),
        );
    }
    entry.insert("properties".into(), Value::Object(props));
    Value::Object(entry)
}

fn severity_label(sev: RuleSeverity) -> &'static str {
    match sev {
        RuleSeverity::Must => "MUST",
        RuleSeverity::Should => "SHOULD",
        RuleSeverity::May => "MAY",
    }
}

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

    #[test]
    fn sarif_reporter_emits_valid_2_1_0_envelope() {
        let r = Report::sample();
        let mut buf = Vec::new();
        SarifReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        let parsed: Value = serde_json::from_str(&s).unwrap();
        assert!(parsed.get("$schema").is_some());
        assert_eq!(parsed["version"], "2.1.0");
        assert_eq!(parsed["runs"][0]["tool"]["driver"]["name"], "mcptest");
    }

    #[test]
    fn sarif_reporter_emits_one_result_per_failure() {
        let r = Report::sample();
        let mut buf = Vec::new();
        SarifReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let results = parsed["runs"][0]["results"].as_array().unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0]["level"], "error");
        let loc = &results[0]["locations"][0]["physicalLocation"];
        assert_eq!(loc["artifactLocation"]["uri"], "tests/create_issue.yml");
        assert_eq!(loc["region"]["startLine"], 22);
    }

    #[test]
    fn sarif_reporter_handles_zero_failure_report() {
        let r = Report::sample_all_pass();
        let mut buf = Vec::new();
        SarifReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let results = parsed["runs"][0]["results"].as_array().unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn sarif_reporter_name_is_stable() {
        assert_eq!(SarifReporter::default().name(), "sarif");
    }

    #[test]
    fn sarif_reporter_attaches_timing_properties_to_each_result() {
        let r = Report::sample();
        let mut buf = Vec::new();
        SarifReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let results = parsed["runs"][0]["results"].as_array().unwrap();
        let first = &results[0];
        assert!(first["properties"]["duration_ms"].is_number());
        assert!(first["properties"]["cache_hit"].is_boolean());
    }

    #[test]
    fn sarif_reporter_emits_run_level_timing_block() {
        let r = Report::sample();
        let mut buf = Vec::new();
        SarifReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let timing = &parsed["runs"][0]["properties"]["timing"];
        assert_eq!(timing["total_duration_ms"], 250);
        assert_eq!(timing["cache_hits"], 1);
        assert_eq!(timing["started_at"], "2026-05-15T22:14:03Z");
    }

    #[test]
    fn sarif_reporter_redacts_token_shaped_test_name() {
        let mut r = Report::sample();
        // The failing test carries the token so it surfaces in results.
        r.tests[2].name = "ghp_abcdefghijklmnop1234".into();
        let reporter = SarifReporter::default().with_redactor(Arc::new(Redactor::new()));
        let mut buf = Vec::new();
        reporter.render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("ghp_abcdefghijklmnop1234"));
        assert!(s.contains("[REDACTED:token]"));
    }

    #[test]
    fn sarif_reporter_emits_a_rule_definition_per_distinct_rule_id() {
        let r = Report::sample_full();
        let mut buf = Vec::new();
        SarifReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
            .as_array()
            .expect("rules array present");
        let ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
        assert!(ids.contains(&"PROTO-001"));
        assert!(ids.contains(&"EDGE-005"));
        assert!(ids.contains(&"RES-001"));
        assert!(ids.contains(&"TOOL-001"));
        // Every entry has shortDescription, fullDescription, helpUri so a
        // consumer's rule index renders complete.
        for entry in rules {
            assert!(entry["shortDescription"]["text"].is_string());
            assert!(entry["fullDescription"]["text"].is_string());
            let help = entry["helpUri"].as_str().unwrap();
            assert!(help.starts_with("https://mcptest.sh/"));
        }
    }

    #[test]
    fn sarif_reporter_maps_must_should_may_to_error_warning_note() {
        let r = Report::sample_full();
        let mut buf = Vec::new();
        SarifReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let results = parsed["runs"][0]["results"].as_array().unwrap();
        let lookup = |rule_id: &str| -> &str {
            results
                .iter()
                .find(|r| r["ruleId"] == rule_id)
                .unwrap_or_else(|| panic!("missing rule {rule_id}"))["level"]
                .as_str()
                .unwrap()
        };
        assert_eq!(lookup("PROTO-001"), "error");
        assert_eq!(lookup("EDGE-005"), "warning");
        assert_eq!(lookup("RES-001"), "note");
    }

    #[test]
    fn sarif_reporter_uses_rule_id_when_provided_on_result() {
        let r = Report::sample_full();
        let mut buf = Vec::new();
        SarifReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let results = parsed["runs"][0]["results"].as_array().unwrap();
        let ids: Vec<&str> = results
            .iter()
            .map(|r| r["ruleId"].as_str().unwrap())
            .collect();
        assert!(ids.contains(&"PROTO-001"));
        assert!(ids.contains(&"EDGE-005"));
        assert!(ids.contains(&"RES-001"));
    }
}
