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

//! In-memory store of loaded SARIF logs.
//!
//! A SARIF viewer is read-mostly: documents are loaded from disk (or the
//! embedded samples), validated, and then browsed and exported. The store
//! holds each parsed log behind an [`Arc`] so listing and rendering are cheap
//! clones, guarded by a single [`RwLock`].

use std::sync::{Arc, RwLock};

use serde_json::Value;

use crate::model::{self, LevelCounts, RunView, SarifVersion};

/// A SARIF log that has been parsed, validated and admitted to the store.
#[derive(Debug, Clone)]
pub struct LoadedLog {
    /// Stable slug used in URLs and export filenames.
    pub id: String,
    /// Where the log came from (a filename, or `sample:<name>`).
    pub source: String,
    /// The declared `version` string.
    pub version_raw: String,
    /// The detected schema version.
    pub version: SarifVersion,
    /// The `$schema` URI the document declares, when present.
    pub schema_uri: Option<String>,
    /// The extracted run views.
    pub runs: Vec<RunView>,
    /// Severity tally across all runs.
    pub counts: LevelCounts,
    /// The full document — the source of truth for re-validation and export.
    pub raw: Value,
}

impl LoadedLog {
    /// Build a loaded log from a parsed document plus provenance.
    #[must_use]
    pub fn from_value(id: String, source: String, raw: Value) -> Self {
        let runs = model::runs_of(&raw);
        let counts = model::count_levels(&runs);
        let version = SarifVersion::detect(&raw);
        let version_raw = raw
            .get("version")
            .and_then(Value::as_str)
            .unwrap_or("(absent)")
            .to_owned();
        let schema_uri = raw
            .get("$schema")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned);
        Self {
            id,
            source,
            version_raw,
            version,
            schema_uri,
            runs,
            counts,
            raw,
        }
    }

    /// Total number of results across all runs.
    #[must_use]
    pub fn result_count(&self) -> usize {
        self.runs.iter().map(|r| r.results.len()).sum()
    }

    /// A short title built from the driving tool name(s).
    #[must_use]
    pub fn title(&self) -> String {
        match self.runs.split_first() {
            None => self.source.clone(),
            Some((first, rest)) if rest.is_empty() => first.tool_name.clone(),
            Some((first, _)) => format!(
                "{} (+{} more run(s))",
                first.tool_name,
                self.runs.len().saturating_sub(1)
            ),
        }
    }
}

/// Thread-safe collection of loaded logs.
#[derive(Default)]
pub struct SarifStore {
    logs: RwLock<Vec<Arc<LoadedLog>>>,
}

impl SarifStore {
    /// Create an empty store.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Insert a log, replacing any existing one with the same id. Returns the
    /// stored handle.
    pub fn insert(&self, log: LoadedLog) -> Arc<LoadedLog> {
        let handle = Arc::new(log);
        if let Ok(mut guard) = self.logs.write() {
            guard.retain(|existing| existing.id != handle.id);
            guard.push(Arc::clone(&handle));
        }
        handle
    }

    /// Snapshot of all logs, in insertion order.
    #[must_use]
    pub fn list(&self) -> Vec<Arc<LoadedLog>> {
        self.logs
            .read()
            .map(|guard| guard.clone())
            .unwrap_or_default()
    }

    /// Fetch one log by id.
    #[must_use]
    pub fn get(&self, id: &str) -> Option<Arc<LoadedLog>> {
        self.logs
            .read()
            .ok()?
            .iter()
            .find(|log| log.id == id)
            .map(Arc::clone)
    }

    /// Remove one log by id; returns whether it existed.
    pub fn remove(&self, id: &str) -> bool {
        if let Ok(mut guard) = self.logs.write() {
            let before = guard.len();
            guard.retain(|log| log.id != id);
            return guard.len() != before;
        }
        false
    }

    /// Number of logs currently held.
    #[must_use]
    pub fn len(&self) -> usize {
        self.logs.read().map(|g| g.len()).unwrap_or(0)
    }

    /// Whether the store is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// A slug derived from `base` that does not collide with an existing id.
    #[must_use]
    pub fn unique_id(&self, base: &str) -> String {
        let slug = slugify(base);
        if self.get(&slug).is_none() {
            return slug;
        }
        for n in 2..u32::MAX {
            let candidate = format!("{slug}-{n}");
            if self.get(&candidate).is_none() {
                return candidate;
            }
        }
        slug
    }
}

/// Turn an arbitrary label into a safe URL/filename slug.
#[must_use]
pub fn slugify(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let mut last_dash = false;
    for ch in input.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            last_dash = false;
        } else if matches!(ch, '.' | '_' | '-') {
            out.push(ch);
            last_dash = false;
        } else if !last_dash {
            out.push('-');
            last_dash = true;
        }
    }
    // Trim leading/trailing dots and dashes so a slug can never be `.`/`..`
    // or otherwise resemble a path segment when used as an export filename.
    let trimmed = out.trim_matches(|c| c == '-' || c == '.').to_owned();
    if trimmed.is_empty() {
        "log".to_owned()
    } else {
        trimmed
    }
}

#[cfg(test)]
mod tests {
    use super::{slugify, LoadedLog, SarifStore};

    fn log(id: &str) -> LoadedLog {
        LoadedLog::from_value(
            id.to_owned(),
            "test".to_owned(),
            serde_json::json!({ "version": "2.1.0", "runs": [] }),
        )
    }

    #[test]
    fn insert_get_remove_roundtrip() {
        let store = SarifStore::new();
        store.insert(log("a"));
        assert_eq!(store.len(), 1);
        assert!(store.get("a").is_some());
        assert!(store.remove("a"));
        assert!(store.is_empty());
    }

    #[test]
    fn insert_replaces_same_id() {
        let store = SarifStore::new();
        store.insert(log("dup"));
        store.insert(log("dup"));
        assert_eq!(store.len(), 1);
    }

    #[test]
    fn unique_id_avoids_collision() {
        let store = SarifStore::new();
        store.insert(log("report"));
        assert_eq!(store.unique_id("report"), "report-2");
    }

    #[test]
    fn slugify_examples() {
        assert_eq!(slugify("My Report.sarif"), "my-report.sarif");
        assert_eq!(slugify("../etc/passwd"), "etc-passwd");
        assert_eq!(slugify("   "), "log");
    }
}
