//! External-scanner supplement primitive (Layer D OSS, ADR 0041).
//!
//! mcptest's bundled checks are the authoritative deterministic lane. Layer D
//! lets a team fold in findings from a scanner they already run (a SARIF or
//! JSON emitter), without mcptest bundling or endorsing any vendor. This is the
//! security analog of the `exec` scorer in ADR 0038: a generic wrapper around a
//! local tool, not a managed integration. Managed vendor adapters are
//! enterprise.
//!
//! The primitive does three things and nothing more: normalize a scanner result
//! into the security finding vocabulary, record where each finding came from,
//! and mark the ones that overlap a bundled catalog rule so a combined report
//! counts them once. Findings from an external LLM judge are tagged advisory and
//! never set a grade, consistent with the authority hierarchy in ADR 0041.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use super::SecuritySeverity;

/// Where a supplement finding came from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "engine", rename_all = "snake_case")]
pub enum FindingSource {
    /// One of mcptest's own bundled deterministic checks.
    Bundled,
    /// An external scanner, named by its tool string (for example `semgrep`).
    External {
        /// The scanner's name, used for provenance in a combined report.
        tool: String,
    },
}

/// A finding ingested from an external scanner and normalized into the security
/// finding vocabulary, carried alongside the bundled findings.
///
/// It deliberately does not reuse [`SecurityFinding`](crate::security::SecurityFinding):
/// that type is keyed to an MCP tool, prompt, or resource definition, and an
/// external scanner's findings are not. This type keeps the shared severity
/// axis and adds the provenance and advisory metadata Layer D needs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SupplementFinding {
    /// Rule ID as the source reported it. Not necessarily a `SEC` id.
    pub rule_id: String,
    /// Short rule name, falling back to the rule ID when the source omits one.
    pub name: String,
    /// Severity on the shared security axis, mapped from the source.
    pub severity: SecuritySeverity,
    /// Human-readable message from the source.
    pub message: String,
    /// Concrete evidence (a location URI, a snippet, or a value). Empty when
    /// the source carried none.
    pub evidence: String,
    /// Which engine produced the finding.
    pub source: FindingSource,
    /// Advisory findings never set or flip a grade (ADR 0041). Set true for a
    /// scanner whose verdicts come from an external LLM judge.
    pub advisory: bool,
    /// When this overlaps a bundled catalog rule, the `SEC` id it duplicates.
    /// `None` means it is a signal the bundled engine did not raise.
    pub duplicate_of: Option<String>,
}

impl SupplementFinding {
    /// Whether this finding adds an authoritative, gradeable signal.
    ///
    /// Advisory findings never count (ADR 0041), and a finding that duplicates
    /// a bundled rule is already counted by the bundled engine, so it does not
    /// count a second time.
    pub fn counts_toward_grade(&self) -> bool {
        !self.advisory && self.duplicate_of.is_none()
    }
}

/// Normalize a SARIF 2.1.0 log into supplement findings.
///
/// `tool` names the scanner for provenance. `advisory` marks the whole run as
/// advisory; set it for LLM-judge-based scanners. Every `result` across every
/// `run` becomes one finding. Severity comes from a recognized
/// `properties.severity` label when the source provides one (so a round-trip of
/// mcptest's own SARIF recovers `critical` versus `high`), otherwise from the
/// SARIF `level`. Missing `runs` or `results` arrays yield no findings rather
/// than an error, so a clean scan and a non-SARIF document both read as empty.
pub fn normalize_sarif(sarif: &Value, tool: &str, advisory: bool) -> Vec<SupplementFinding> {
    let mut out = Vec::new();
    let Some(runs) = sarif.get("runs").and_then(|v| v.as_array()) else {
        return out;
    };
    for run in runs {
        let Some(results) = run.get("results").and_then(|v| v.as_array()) else {
            continue;
        };
        for result in results {
            out.push(finding_from_sarif_result(result, tool, advisory));
        }
    }
    out
}

fn finding_from_sarif_result(result: &Value, tool: &str, advisory: bool) -> SupplementFinding {
    let rule_id = result
        .get("ruleId")
        .and_then(|v| v.as_str())
        .unwrap_or("UNKNOWN")
        .to_string();
    let message = result
        .get("message")
        .and_then(|m| m.get("text"))
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    SupplementFinding {
        name: rule_id.clone(),
        severity: severity_from_sarif(result),
        message,
        evidence: sarif_evidence(result),
        source: FindingSource::External {
            tool: tool.to_string(),
        },
        advisory,
        duplicate_of: None,
        rule_id,
    }
}

/// Pull a single evidence string from a SARIF result: a region snippet if the
/// scanner included one, otherwise the artifact URI, otherwise empty.
fn sarif_evidence(result: &Value) -> String {
    let Some(physical) = result
        .get("locations")
        .and_then(|v| v.as_array())
        .and_then(|locs| locs.first())
        .and_then(|loc| loc.get("physicalLocation"))
    else {
        return String::new();
    };
    if let Some(snippet) = physical
        .get("region")
        .and_then(|r| r.get("snippet"))
        .and_then(|s| s.get("text"))
        .and_then(|v| v.as_str())
    {
        return snippet.to_string();
    }
    physical
        .get("artifactLocation")
        .and_then(|a| a.get("uri"))
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string()
}

/// Map a SARIF result to a severity: an explicit `properties.severity` label
/// wins, then the SARIF `level` (`error` to high, `warning` to medium, `note`
/// to low, anything else to info).
fn severity_from_sarif(result: &Value) -> SecuritySeverity {
    if let Some(sev) = result
        .get("properties")
        .and_then(|p| p.get("severity"))
        .and_then(|v| v.as_str())
        .and_then(SecuritySeverity::parse)
    {
        return sev;
    }
    match result.get("level").and_then(|v| v.as_str()) {
        Some("error") => SecuritySeverity::High,
        Some("warning") => SecuritySeverity::Medium,
        Some("note") => SecuritySeverity::Low,
        _ => SecuritySeverity::Info,
    }
}

/// Normalize a generic JSON scanner result into supplement findings.
///
/// Accepts either a top-level array of findings or an object with a `findings`
/// array. Each item reads `rule_id` (or `id`), an optional `name`, a `severity`
/// label, a `message` (or `description`), and an optional `evidence`. Items
/// without a recognized severity fall back to info. This is the escape hatch for
/// a scanner that does not speak SARIF.
pub fn normalize_generic_json(doc: &Value, tool: &str, advisory: bool) -> Vec<SupplementFinding> {
    let items = doc
        .as_array()
        .or_else(|| doc.get("findings").and_then(|v| v.as_array()));
    let Some(items) = items else {
        return Vec::new();
    };
    items
        .iter()
        .map(|item| finding_from_generic(item, tool, advisory))
        .collect()
}

fn finding_from_generic(item: &Value, tool: &str, advisory: bool) -> SupplementFinding {
    let str_field = |keys: &[&str]| -> String {
        for key in keys {
            if let Some(s) = item.get(*key).and_then(|v| v.as_str()) {
                return s.to_string();
            }
        }
        String::new()
    };
    let rule_id = {
        let id = str_field(&["rule_id", "id"]);
        if id.is_empty() {
            "UNKNOWN".to_string()
        } else {
            id
        }
    };
    let name = {
        let n = str_field(&["name"]);
        if n.is_empty() {
            rule_id.clone()
        } else {
            n
        }
    };
    let severity = item
        .get("severity")
        .and_then(|v| v.as_str())
        .and_then(SecuritySeverity::parse)
        .unwrap_or(SecuritySeverity::Info);
    SupplementFinding {
        rule_id,
        name,
        severity,
        message: str_field(&["message", "description"]),
        evidence: str_field(&["evidence"]),
        source: FindingSource::External {
            tool: tool.to_string(),
        },
        advisory,
        duplicate_of: None,
    }
}

/// Normalize a Snyk agent-scan (formerly Invariant `mcp-scan`) JSON result into
/// supplement findings.
///
/// Snyk emits a `ScanPathResult` object with an `issues` array, where each issue
/// carries a `code` (for example `E006`), a `severity` label, a `title`, and a
/// `message`, which is a different vocabulary from the generic reader's
/// `rule_id`/`message` keys. Rather than duplicate the normalization, this is a
/// thin adapter: it rewrites each issue into the generic item shape and hands
/// the result to [`normalize_generic_json`], so there is one normalization path.
/// A document without an `issues` array yields no findings.
pub fn normalize_snyk(doc: &Value, tool: &str, advisory: bool) -> Vec<SupplementFinding> {
    let Some(issues) = doc.get("issues").and_then(|v| v.as_array()) else {
        return Vec::new();
    };
    let str_or = |issue: &Value, key: &str| -> String {
        issue
            .get(key)
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string()
    };
    let findings: Vec<Value> = issues
        .iter()
        .map(|issue| {
            let title = str_or(issue, "title");
            let message = {
                let m = str_or(issue, "message");
                if m.is_empty() {
                    title.clone()
                } else {
                    m
                }
            };
            json!({
                "rule_id": str_or(issue, "code"),
                "name": title,
                "severity": str_or(issue, "severity"),
                "message": message,
                "evidence": str_or(issue, "evidence"),
            })
        })
        .collect();
    normalize_generic_json(&json!({ "findings": findings }), tool, advisory)
}

/// Mark supplement findings that duplicate a bundled catalog rule.
///
/// `catalog_rule_ids` is the set of `SEC` ids the bundled engine can raise. A
/// caller derives it from the engine and the relational lanes, for example the
/// surface ids via `SecurityEngine::checks()`. A supplement whose `rule_id`
/// matches one of them is tagged `duplicate_of`, so a combined report counts it
/// once and a reader can see the overlap. Idempotent: running it twice is the
/// same as running it once.
pub fn dedup_against_catalog(findings: &mut [SupplementFinding], catalog_rule_ids: &[&str]) {
    for finding in findings.iter_mut() {
        if catalog_rule_ids.contains(&finding.rule_id.as_str()) {
            finding.duplicate_of = Some(finding.rule_id.clone());
        }
    }
}

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

    fn sample_sarif() -> Value {
        json!({
            "version": "2.1.0",
            "runs": [{
                "tool": { "driver": { "name": "semgrep" } },
                "results": [
                    {
                        "ruleId": "ext.tool-poisoning",
                        "level": "error",
                        "message": { "text": "imperative aimed at the model" },
                        "locations": [{ "physicalLocation": {
                            "artifactLocation": { "uri": "tools/search" }
                        }}]
                    },
                    {
                        "ruleId": "SEC-001",
                        "level": "warning",
                        "message": { "text": "description injection" }
                    }
                ]
            }]
        })
    }

    #[test]
    fn normalize_sarif_maps_results_and_records_provenance() {
        let findings = normalize_sarif(&sample_sarif(), "semgrep", false);
        assert_eq!(findings.len(), 2);
        let first = &findings[0];
        assert_eq!(first.rule_id, "ext.tool-poisoning");
        assert_eq!(first.severity, SecuritySeverity::High);
        assert_eq!(first.evidence, "tools/search");
        assert_eq!(
            first.source,
            FindingSource::External {
                tool: "semgrep".to_string()
            }
        );
        assert!(!first.advisory);
    }

    #[test]
    fn explicit_severity_label_beats_sarif_level() {
        // properties.severity says critical even though level is note.
        let sarif = json!({
            "runs": [{ "results": [{
                "ruleId": "x",
                "level": "note",
                "message": { "text": "m" },
                "properties": { "severity": "critical" }
            }]}]
        });
        let findings = normalize_sarif(&sarif, "scanner", false);
        assert_eq!(findings[0].severity, SecuritySeverity::Critical);
    }

    #[test]
    fn non_sarif_document_yields_no_findings() {
        assert!(normalize_sarif(&json!({ "not": "sarif" }), "x", false).is_empty());
        assert!(normalize_sarif(&json!([1, 2, 3]), "x", false).is_empty());
    }

    #[test]
    fn dedup_marks_overlap_with_bundled_catalog() {
        let mut findings = normalize_sarif(&sample_sarif(), "semgrep", false);
        dedup_against_catalog(&mut findings, &["SEC-001", "SEC-002"]);
        // The ext rule is novel; the SEC-001 result duplicates the catalog.
        assert_eq!(findings[0].duplicate_of, None);
        assert_eq!(findings[1].duplicate_of, Some("SEC-001".to_string()));
    }

    #[test]
    fn counts_toward_grade_excludes_advisory_and_duplicates() {
        let mut findings = normalize_sarif(&sample_sarif(), "semgrep", false);
        dedup_against_catalog(&mut findings, &["SEC-001"]);
        // Novel, non-advisory finding counts.
        assert!(findings[0].counts_toward_grade());
        // Duplicate of a bundled rule does not double-count.
        assert!(!findings[1].counts_toward_grade());

        let advisory = normalize_sarif(&sample_sarif(), "llm-judge", true);
        assert!(!advisory[0].counts_toward_grade());
    }

    #[test]
    fn normalize_generic_json_reads_array_and_object_forms() {
        let array = json!([
            { "rule_id": "g1", "severity": "medium", "message": "a" },
            { "id": "g2", "severity": "bogus", "description": "b", "evidence": "x" }
        ]);
        let from_array = normalize_generic_json(&array, "tool", false);
        assert_eq!(from_array.len(), 2);
        assert_eq!(from_array[0].severity, SecuritySeverity::Medium);
        // Unrecognized severity falls back to info; description maps to message.
        assert_eq!(from_array[1].severity, SecuritySeverity::Info);
        assert_eq!(from_array[1].message, "b");
        assert_eq!(from_array[1].evidence, "x");

        let object = json!({ "findings": [{ "rule_id": "g3", "severity": "high" }] });
        let from_object = normalize_generic_json(&object, "tool", false);
        assert_eq!(from_object.len(), 1);
        assert_eq!(from_object[0].severity, SecuritySeverity::High);
    }

    #[test]
    fn normalize_snyk_maps_issue_code_and_severity() {
        // A Snyk agent-scan ScanPathResult: an `issues` array of coded findings.
        let scan = json!({
            "servers": [{ "name": "weather" }],
            "issues": [
                {
                    "code": "E006",
                    "title": "tool-poisoning",
                    "severity": "high",
                    "message": "tool description carries an imperative",
                    "evidence": "ignore all previous"
                },
                { "code": "E001", "title": "untrusted-output", "severity": "medium" }
            ],
            "summary": { "high": 1, "medium": 1 }
        });
        let findings = normalize_snyk(&scan, "snyk", false);
        assert_eq!(findings.len(), 2);
        assert_eq!(findings[0].rule_id, "E006");
        assert_eq!(findings[0].name, "tool-poisoning");
        assert_eq!(findings[0].severity, SecuritySeverity::High);
        assert_eq!(findings[0].evidence, "ignore all previous");
        assert_eq!(
            findings[0].source,
            FindingSource::External {
                tool: "snyk".to_string()
            }
        );
        // The second issue carries no message, so the title becomes the message.
        assert_eq!(findings[1].severity, SecuritySeverity::Medium);
        assert_eq!(findings[1].message, "untrusted-output");
    }

    #[test]
    fn normalize_snyk_without_issues_array_is_empty() {
        assert!(normalize_snyk(&json!({ "servers": [] }), "snyk", false).is_empty());
    }

    #[test]
    fn supplement_finding_round_trips_through_json() {
        let findings = normalize_sarif(&sample_sarif(), "semgrep", true);
        let encoded = serde_json::to_string(&findings[0]).expect("serialize");
        let decoded: SupplementFinding = serde_json::from_str(&encoded).expect("deserialize");
        assert_eq!(decoded, findings[0]);
    }
}
