// 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>

//! SARIF validation — enforced on every import and every export.
//!
//! Two layers run on each document:
//!
//! 1. A **structural floor** (cheap, schema-independent): `version` is a
//!    string, `runs` is an array, every run carries `tool.driver.name`, and
//!    every result carries a `ruleId`/`ruleIndex` plus a message. This mirrors
//!    the minimum-conformity check in the ndaal `sarif` skill and gives clear
//!    diagnostics even when the JSON-Schema pass is noisy.
//! 2. **JSON-Schema validation** against the [`crate::schema`] validator that
//!    matches the document's declared version (2.1.0 or 2.2). The viewer can
//!    additionally cross-check against the *other* schema — the ndaal skill
//!    mandates dual 2.1.0 + 2.2 validation for documents it emits — and that
//!    cross result is reported as advisory.
//!
//! A document is considered **valid** when both the structural floor and the
//! version-matched schema report no errors.

use serde_json::Value;

use crate::model::SarifVersion;
use crate::schema;

/// Cap on how many schema errors are collected, to keep reports bounded.
const MAX_ERRORS: usize = 200;

/// The outcome of validating one SARIF document.
#[derive(Debug, Clone)]
pub struct ValidationReport {
    /// The detected schema version.
    pub version: SarifVersion,
    /// The raw `version` string the document declared (or `"(absent)"`).
    pub version_raw: String,
    /// Which schema the primary pass used.
    pub schema_label: &'static str,
    /// Structural-floor errors.
    pub structural: Vec<String>,
    /// Errors from the version-matched schema.
    pub schema_errors: Vec<String>,
    /// Advisory errors from the *other* schema (dual cross-check), when run.
    pub cross_label: Option<&'static str>,
    /// The cross-check errors (empty vec = cross-clean; `None` = not run).
    pub cross_errors: Option<Vec<String>>,
}

impl ValidationReport {
    /// Whether the document passed: no structural and no primary-schema errors.
    #[must_use]
    pub fn is_valid(&self) -> bool {
        self.structural.is_empty() && self.schema_errors.is_empty()
    }

    /// A one-line summary suitable for logs and the CLI.
    #[must_use]
    pub fn summary(&self) -> String {
        if self.is_valid() {
            format!("VALID against {}", self.schema_label)
        } else {
            format!(
                "INVALID against {} ({} structural, {} schema error(s))",
                self.schema_label,
                self.structural.len(),
                self.schema_errors.len()
            )
        }
    }
}

/// Run the structural-floor checks; returns one message per violation.
fn structural_floor(doc: &Value) -> Vec<String> {
    let mut errors = Vec::new();
    if doc.get("version").and_then(Value::as_str).is_none() {
        errors.push("top-level `version` is missing or not a string".to_owned());
    }
    let Some(runs) = doc.get("runs").and_then(Value::as_array) else {
        errors.push("top-level `runs` is missing or not an array".to_owned());
        return errors;
    };
    for (ri, run) in runs.iter().enumerate() {
        let name = run
            .get("tool")
            .and_then(|t| t.get("driver"))
            .and_then(|d| d.get("name"))
            .and_then(Value::as_str);
        if name.is_none_or(str::is_empty) {
            errors.push(format!("runs[{ri}]: `tool.driver.name` is missing or empty"));
        }
        if let Some(results) = run.get("results").and_then(Value::as_array) {
            for (xi, result) in results.iter().enumerate() {
                let has_rule = result.get("ruleId").and_then(Value::as_str).is_some()
                    || result.get("ruleIndex").and_then(Value::as_u64).is_some();
                if !has_rule {
                    errors.push(format!(
                        "runs[{ri}].results[{xi}]: neither `ruleId` nor `ruleIndex` present"
                    ));
                }
                let has_message = result.get("message").is_some();
                if !has_message {
                    errors.push(format!("runs[{ri}].results[{xi}]: `message` is missing"));
                }
                if errors.len() >= MAX_ERRORS {
                    return errors;
                }
            }
        }
    }
    errors
}

/// Collect up to [`MAX_ERRORS`] messages from a schema validator.
fn schema_errors(validator: &jsonschema::Validator, doc: &Value) -> Vec<String> {
    validator
        .iter_errors(doc)
        .take(MAX_ERRORS)
        .map(|error| {
            let path = error.instance_path().to_string();
            if path.is_empty() {
                error.to_string()
            } else {
                format!("{path}: {error}")
            }
        })
        .collect()
}

/// Map a detected version to its primary validator and label.
fn primary_validator(
    version: SarifVersion,
) -> Result<(&'static jsonschema::Validator, &'static str), String> {
    match version {
        SarifVersion::V2_2 => Ok((schema::validator_2_2()?, "SARIF 2.2 (draft) schema")),
        // Unknown versions are checked against 2.1.0 — the stable standard.
        SarifVersion::V2_1_0 | SarifVersion::Other => {
            Ok((schema::validator_2_1_0()?, "SARIF 2.1.0 schema"))
        }
    }
}

/// Validate a SARIF document. When `dual` is set, also cross-check against the
/// schema for the *other* version and report it as advisory (the ndaal `sarif`
/// skill mandates dual 2.1.0 + 2.2 validation for emitted documents).
///
/// # Errors
///
/// Returns an error only if an embedded schema fails to compile (impossible
/// with the vendored schemas) — validation *failures* are returned in the
/// report, not as an error.
pub fn validate(doc: &Value, dual: bool) -> Result<ValidationReport, String> {
    let version = SarifVersion::detect(doc);
    let version_raw = doc
        .get("version")
        .and_then(Value::as_str)
        .unwrap_or("(absent)")
        .to_owned();

    let structural = structural_floor(doc);
    let (validator, schema_label) = primary_validator(version)?;
    let primary_errors = schema_errors(validator, doc);

    let (cross_label, cross_errors) = if dual {
        let (cross_validator, cross_label) = match version {
            SarifVersion::V2_2 => (schema::validator_2_1_0()?, "SARIF 2.1.0 schema"),
            SarifVersion::V2_1_0 | SarifVersion::Other => {
                (schema::validator_2_2()?, "SARIF 2.2 (draft) schema")
            }
        };
        (Some(cross_label), Some(schema_errors(cross_validator, doc)))
    } else {
        (None, None)
    };

    Ok(ValidationReport {
        version,
        version_raw,
        schema_label,
        structural,
        schema_errors: primary_errors,
        cross_label,
        cross_errors,
    })
}

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

    #[test]
    fn valid_minimal_log_passes() {
        let doc = serde_json::json!({
            "version": "2.1.0",
            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
            "runs": [ { "tool": { "driver": { "name": "demo" } }, "results": [] } ]
        });
        let report = validate(&doc, true).expect("schemas compile");
        assert!(report.is_valid(), "{}", report.summary());
        assert!(report.cross_errors.is_some(), "dual cross-check ran");
    }

    #[test]
    fn missing_driver_name_fails_structural_and_schema() {
        let doc = serde_json::json!({
            "version": "2.1.0",
            "runs": [ { "tool": { "driver": { } }, "results": [] } ]
        });
        let report = validate(&doc, false).expect("schemas compile");
        assert!(!report.is_valid());
        assert!(!report.structural.is_empty(), "structural floor catches it");
    }

    #[test]
    fn wrong_type_fails_schema() {
        // `runs` is an object, not an array.
        let doc = serde_json::json!({ "version": "2.1.0", "runs": {} });
        let report = validate(&doc, false).expect("schemas compile");
        assert!(!report.is_valid());
    }
}
