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

//! Import of SARIF documents from the filesystem.
//!
//! Every imported file is parsed and then **schema-validated** before it is
//! admitted to the store — an invalid document is rejected with its validation
//! errors, never silently loaded. This is the import half of the "validate on
//! every import and export" requirement.

use std::path::Path;

use serde_json::Value;

use crate::store::{LoadedLog, SarifStore};
use crate::validation;

/// File extensions treated as SARIF.
const SARIF_EXTENSIONS: [&str; 2] = ["sarif", "json"];

/// The outcome of importing one path (which may be a file or a directory).
#[derive(Debug, Default)]
pub struct ImportOutcome {
    /// Ids of logs successfully imported.
    pub imported: Vec<String>,
    /// `(source, error message)` for each file that was rejected.
    pub errors: Vec<(String, String)>,
}

impl ImportOutcome {
    /// Merge another outcome into this one.
    fn absorb(&mut self, other: Self) {
        self.imported.extend(other.imported);
        self.errors.extend(other.errors);
    }

    /// Number of files imported.
    #[must_use]
    pub fn ok_count(&self) -> usize {
        self.imported.len()
    }

    /// Number of files rejected.
    #[must_use]
    pub fn err_count(&self) -> usize {
        self.errors.len()
    }
}

/// Whether a path has a SARIF-ish extension.
fn looks_like_sarif(path: &Path) -> bool {
    path.extension()
        .and_then(|e| e.to_str())
        .map(|e| e.to_ascii_lowercase())
        .is_some_and(|e| SARIF_EXTENSIONS.contains(&e.as_str()))
}

/// Parse + validate one file's bytes into a [`LoadedLog`], or return the
/// validation/parse error text. Does not touch the store.
///
/// # Errors
///
/// Returns the parse error, or a newline-joined list of validation errors when
/// the document does not validate against its SARIF schema.
pub fn load_bytes(source: &str, bytes: &[u8], store: &SarifStore) -> Result<LoadedLog, String> {
    let doc: Value = serde_json::from_slice(bytes).map_err(|e| format!("not valid JSON: {e}"))?;
    let report = validation::validate(&doc, false)?;
    if !report.is_valid() {
        let mut lines = report.structural.clone();
        lines.extend(report.schema_errors.iter().cloned());
        let shown: Vec<String> = lines.into_iter().take(20).collect();
        return Err(format!(
            "{} — rejected:\n  {}",
            report.summary(),
            shown.join("\n  ")
        ));
    }
    let id = store.unique_id(source);
    Ok(LoadedLog::from_value(id, source.to_owned(), doc))
}

/// Import a single file into the store.
///
/// # Errors
///
/// Returns the read or validation error.
pub fn import_file(path: &Path, store: &SarifStore) -> Result<String, String> {
    // The import path is operator-chosen (a file the user explicitly selects);
    // there is no confinement boundary to apply — cap-std confines *writes*
    // (exports), not operator-named reads. See skills/rust-path-security.md.
    let bytes = std::fs::read(path) // allow-std-fs: operator-chosen import path
        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
    let source = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("upload")
        .to_owned();
    let log = load_bytes(&source, &bytes, store)?;
    let id = log.id.clone();
    store.insert(log);
    Ok(id)
}

/// Import a path that may be a file or a directory of `*.sarif` / `*.json`.
#[must_use]
pub fn import_path(path: &Path, store: &SarifStore) -> ImportOutcome {
    let mut outcome = ImportOutcome::default();
    if path.is_dir() {
        outcome.absorb(import_dir(path, store));
    } else {
        match import_file(path, store) {
            Ok(id) => outcome.imported.push(id),
            Err(e) => outcome.errors.push((path.display().to_string(), e)),
        }
    }
    outcome
}

/// Import every SARIF file directly inside `dir` (non-recursive).
fn import_dir(dir: &Path, store: &SarifStore) -> ImportOutcome {
    let mut outcome = ImportOutcome::default();
    // Operator-chosen import directory; only *.sarif/*.json entries directly
    // inside it are read (non-recursive). See skills/rust-path-security.md.
    let entries = match std::fs::read_dir(dir) { // allow-std-fs: operator-chosen import dir
        Ok(entries) => entries,
        Err(e) => {
            outcome
                .errors
                .push((dir.display().to_string(), format!("cannot read dir: {e}")));
            return outcome;
        }
    };
    let mut paths: Vec<_> = entries
        .filter_map(Result::ok)
        .map(|e| e.path())
        .filter(|p| p.is_file() && looks_like_sarif(p))
        .collect();
    paths.sort();
    for path in paths {
        match import_file(&path, store) {
            Ok(id) => outcome.imported.push(id),
            Err(e) => outcome.errors.push((path.display().to_string(), e)),
        }
    }
    outcome
}

#[cfg(test)]
mod tests {
    use super::{load_bytes, ImportOutcome};
    use crate::store::SarifStore;

    #[test]
    fn valid_log_loads() {
        let store = SarifStore::new();
        let bytes = br#"{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"t"}},"results":[]}]}"#;
        let log = load_bytes("ok.sarif", bytes, &store).expect("valid");
        assert_eq!(log.version_raw, "2.1.0");
    }

    #[test]
    fn invalid_log_is_rejected() {
        let store = SarifStore::new();
        // `runs` missing entirely — fails structural + schema.
        let bytes = br#"{"version":"2.1.0"}"#;
        assert!(load_bytes("bad.sarif", bytes, &store).is_err());
    }

    #[test]
    fn non_json_is_rejected() {
        let store = SarifStore::new();
        assert!(load_bytes("x.sarif", b"not json", &store).is_err());
    }

    #[test]
    fn outcome_counts() {
        let mut o = ImportOutcome::default();
        o.imported.push("a".to_owned());
        o.errors.push(("b".to_owned(), "boom".to_owned()));
        assert_eq!(o.ok_count(), 1);
        assert_eq!(o.err_count(), 1);
    }
}
