// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne
// SPDX-FileCopyrightText: Author: Pierre Gronau <Pierre.Gronau@ndaal.eu>

//! Typed read-only views over a SARIF log.
//!
//! The raw [`serde_json::Value`] is the source of truth (it round-trips back
//! out on export, byte-for-byte equivalent). On top of it we extract a small
//! set of display structures — runs, results, severities — used by the web
//! viewer and the Markdown/ODT/PDF exporters. Extraction is entirely
//! best-effort and never panics: a missing or mistyped field degrades to a
//! sensible default rather than an error.

use serde_json::Value;

/// The SARIF schema version a document declares.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SarifVersion {
    /// SARIF 2.1.0 — the current OASIS standard.
    V2_1_0,
    /// SARIF 2.2 — the OASIS working draft (additive over 2.1.0).
    V2_2,
    /// An unrecognised or missing version.
    Other,
}

impl SarifVersion {
    /// Detect the version from a SARIF log's `version` field.
    #[must_use]
    pub fn detect(doc: &Value) -> Self {
        match doc.get("version").and_then(Value::as_str) {
            Some("2.1.0") => Self::V2_1_0,
            Some(s) if s.starts_with("2.2") => Self::V2_2,
            _ => Self::Other,
        }
    }

    /// A short human label.
    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::V2_1_0 => "SARIF 2.1.0",
            Self::V2_2 => "SARIF 2.2 (draft)",
            Self::Other => "SARIF (unknown version)",
        }
    }
}

/// One analysis result, flattened for display.
#[derive(Debug, Clone)]
pub struct ResultView {
    /// The rule identifier (`ruleId`, or the indexed rule's id, or `—`).
    pub rule_id: String,
    /// The rule's human name, when the driver defines one.
    pub rule_name: Option<String>,
    /// The effective severity: `error` / `warning` / `note` / `none`.
    pub level: String,
    /// The result kind (`fail` / `pass` / `open` / …), when present.
    pub kind: Option<String>,
    /// The result message text.
    pub message: String,
    /// Human location strings (`uri:line`), one per location.
    pub locations: Vec<String>,
}

/// One run within a SARIF log.
#[derive(Debug, Clone)]
pub struct RunView {
    /// The driving tool's name (`tool.driver.name`).
    pub tool_name: String,
    /// The tool version, when present.
    pub tool_version: Option<String>,
    /// The publishing organization, when present.
    pub organization: Option<String>,
    /// Number of rule definitions the driver declares.
    pub rules_count: usize,
    /// The run's results.
    pub results: Vec<ResultView>,
}

/// Per-severity counts across a log or run.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LevelCounts {
    /// `level == "error"`.
    pub error: usize,
    /// `level == "warning"`.
    pub warning: usize,
    /// `level == "note"`.
    pub note: usize,
    /// `level == "none"`.
    pub none: usize,
}

impl LevelCounts {
    /// Total results counted.
    #[must_use]
    pub const fn total(self) -> usize {
        self.error
            .saturating_add(self.warning)
            .saturating_add(self.note)
            .saturating_add(self.none)
    }

    /// Add one result's effective level into the tally.
    fn add(&mut self, level: &str) {
        match level {
            "error" => self.error = self.error.saturating_add(1),
            "note" => self.note = self.note.saturating_add(1),
            "none" => self.none = self.none.saturating_add(1),
            _ => self.warning = self.warning.saturating_add(1),
        }
    }
}

/// Read a nested `obj.a.b.c` string path.
fn nested_str<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
    let mut cursor = value;
    for key in path {
        cursor = cursor.get(key)?;
    }
    cursor.as_str()
}

/// Extract a result's message text from any of the SARIF message shapes.
fn message_text(result: &Value) -> String {
    nested_str(result, &["message", "text"])
        .or_else(|| nested_str(result, &["message", "markdown"]))
        .unwrap_or("")
        .to_owned()
}

/// Build one `uri:line` string from a SARIF location object.
fn location_string(location: &Value) -> Option<String> {
    let phys = location.get("physicalLocation")?;
    let uri = nested_str(phys, &["artifactLocation", "uri"])
        .or_else(|| nested_str(location, &["logicalLocations", "0", "fullyQualifiedName"]))?;
    let line = phys
        .get("region")
        .and_then(|r| r.get("startLine"))
        .and_then(Value::as_u64);
    Some(line.map_or_else(|| uri.to_owned(), |l| format!("{uri}:{l}")))
}

/// Resolve the display name for a rule, given the driver's rule list and the
/// result's `ruleIndex` / `ruleId`.
fn rule_name(rules: &[Value], result: &Value) -> Option<String> {
    let index = result.get("ruleIndex").and_then(Value::as_u64);
    let by_index = index
        .and_then(|i| usize::try_from(i).ok())
        .and_then(|i| rules.get(i));
    let rule = by_index.or_else(|| {
        let id = result.get("ruleId").and_then(Value::as_str)?;
        rules
            .iter()
            .find(|r| r.get("id").and_then(Value::as_str) == Some(id))
    })?;
    rule.get("name")
        .and_then(Value::as_str)
        .or_else(|| nested_str(rule, &["shortDescription", "text"]))
        .map(ToOwned::to_owned)
}

/// The effective severity of a result: its own `level`, else the indexed
/// rule's `defaultConfiguration.level`, else SARIF's `warning` default.
fn effective_level(rules: &[Value], result: &Value) -> String {
    if let Some(level) = result.get("level").and_then(Value::as_str) {
        return level.to_owned();
    }
    let index = result
        .get("ruleIndex")
        .and_then(Value::as_u64)
        .and_then(|i| usize::try_from(i).ok());
    if let Some(rule) = index.and_then(|i| rules.get(i)) {
        if let Some(level) = nested_str(rule, &["defaultConfiguration", "level"]) {
            return level.to_owned();
        }
    }
    "warning".to_owned()
}

/// Build a [`ResultView`] from a raw result object and its run's rule list.
fn result_view(rules: &[Value], result: &Value) -> ResultView {
    let rule_id = result
        .get("ruleId")
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .or_else(|| rule_name(rules, result))
        .unwrap_or_else(|| "—".to_owned());
    let locations = result
        .get("locations")
        .and_then(Value::as_array)
        .map(|arr| arr.iter().filter_map(location_string).collect())
        .unwrap_or_default();
    ResultView {
        rule_id,
        rule_name: rule_name(rules, result),
        level: effective_level(rules, result),
        kind: result
            .get("kind")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
        message: message_text(result),
        locations,
    }
}

/// Build a [`RunView`] from a raw run object.
fn run_view(run: &Value) -> RunView {
    let empty: Vec<Value> = Vec::new();
    let rules = run
        .get("tool")
        .and_then(|t| t.get("driver"))
        .and_then(|d| d.get("rules"))
        .and_then(Value::as_array)
        .unwrap_or(&empty);
    let results = run
        .get("results")
        .and_then(Value::as_array)
        .map(|arr| arr.iter().map(|r| result_view(rules, r)).collect())
        .unwrap_or_default();
    RunView {
        tool_name: nested_str(run, &["tool", "driver", "name"])
            .unwrap_or("(unnamed tool)")
            .to_owned(),
        tool_version: nested_str(run, &["tool", "driver", "version"]).map(ToOwned::to_owned),
        organization: nested_str(run, &["tool", "driver", "organization"]).map(ToOwned::to_owned),
        rules_count: rules.len(),
        results,
    }
}

/// Extract every run's view from a SARIF log.
#[must_use]
pub fn runs_of(doc: &Value) -> Vec<RunView> {
    doc.get("runs")
        .and_then(Value::as_array)
        .map(|arr| arr.iter().map(run_view).collect())
        .unwrap_or_default()
}

/// Tally severities across a slice of runs.
#[must_use]
pub fn count_levels(runs: &[RunView]) -> LevelCounts {
    let mut counts = LevelCounts::default();
    for run in runs {
        for result in &run.results {
            counts.add(&result.level);
        }
    }
    counts
}

#[cfg(test)]
mod tests {
    // Tests index fixed-shape fixtures; the production-code denial of
    // indexing/arithmetic does not apply here.
    #![allow(clippy::indexing_slicing)]

    use super::{count_levels, runs_of, SarifVersion};

    fn sample() -> serde_json::Value {
        serde_json::json!({
            "version": "2.1.0",
            "runs": [{
                "tool": { "driver": {
                    "name": "demo", "version": "1.0",
                    "rules": [{ "id": "R1", "name": "RuleOne",
                                "defaultConfiguration": { "level": "error" } }]
                }},
                "results": [
                    { "ruleId": "R1", "ruleIndex": 0,
                      "message": { "text": "boom" },
                      "locations": [{ "physicalLocation": {
                          "artifactLocation": { "uri": "src/a.rs" },
                          "region": { "startLine": 42 } } }] },
                    { "ruleId": "R2", "level": "note", "message": { "text": "fyi" } }
                ]
            }]
        })
    }

    #[test]
    fn detects_version() {
        assert_eq!(SarifVersion::detect(&sample()), SarifVersion::V2_1_0);
        let two_two = serde_json::json!({ "version": "2.2", "runs": [] });
        assert_eq!(SarifVersion::detect(&two_two), SarifVersion::V2_2);
    }

    #[test]
    fn extracts_runs_results_levels_locations() {
        let runs = runs_of(&sample());
        assert_eq!(runs.len(), 1);
        let run = &runs[0];
        assert_eq!(run.tool_name, "demo");
        assert_eq!(run.results.len(), 2);
        assert_eq!(run.results[0].level, "error"); // from defaultConfiguration
        assert_eq!(run.results[0].rule_name.as_deref(), Some("RuleOne"));
        assert_eq!(run.results[0].locations, vec!["src/a.rs:42".to_owned()]);
        assert_eq!(run.results[1].level, "note");
        let counts = count_levels(&runs);
        assert_eq!(counts.error, 1);
        assert_eq!(counts.note, 1);
        assert_eq!(counts.total(), 2);
    }
}
