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

//! Multi-format export of a loaded SARIF log.
//!
//! Adapted from the ndaal BSI-Grundschutz OSCAL viewer's export engine:
//!
//! * **SARIF / JSON** — the validated document, pretty-printed.
//! * **Markdown** and **ODT** — produced in-process by `lo_writer` (the
//!   pure-Rust libreoffice-rs document model; no external LibreOffice).
//! * **PDF** — rendered by [`crate::export_pdf`] with an embedded Roboto
//!   TrueType font so Unicode (umlauts, symbols) survives.
//!
//! **Every export re-validates the document against its SARIF schema first**
//! (dual 2.1.0 + 2.2 cross-check) and refuses to write anything when the
//! document is invalid — the export half of the "validate on every import and
//! export" requirement. Each artifact is written into a capability-scoped
//! directory together with its checksum [sidecars](crate::sidecar).

use std::fmt;

use lo_writer::WriterEditor;

use crate::export_pdf;
use crate::sidecar::{write_sidecars, SidecarHashes};
use crate::store::LoadedLog;
use crate::validation;

/// One output format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
    /// The validated SARIF document (pretty JSON), `.sarif`.
    Sarif,
    /// GitHub-flavoured Markdown, `.md`.
    Markdown,
    /// OpenDocument Text, `.odt`.
    Odt,
    /// PDF, `.pdf`.
    Pdf,
}

impl ExportFormat {
    /// Every format, in display order.
    pub const ALL: [Self; 4] = [Self::Sarif, Self::Markdown, Self::Odt, Self::Pdf];

    /// Parse a format token (`sarif` / `json` / `markdown` / `md` / `odt` / `pdf`).
    #[must_use]
    pub fn from_token(token: &str) -> Option<Self> {
        match token.trim().to_ascii_lowercase().as_str() {
            "sarif" | "json" => Some(Self::Sarif),
            "markdown" | "md" => Some(Self::Markdown),
            "odt" => Some(Self::Odt),
            "pdf" => Some(Self::Pdf),
            _ => None,
        }
    }

    /// The file extension for this format.
    #[must_use]
    pub const fn extension(self) -> &'static str {
        match self {
            Self::Sarif => "sarif",
            Self::Markdown => "md",
            Self::Odt => "odt",
            Self::Pdf => "pdf",
        }
    }

    /// The token used in form checkboxes / CLI flags.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::Sarif => "sarif",
            Self::Markdown => "markdown",
            Self::Odt => "odt",
            Self::Pdf => "pdf",
        }
    }

    /// A human label.
    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::Sarif => "SARIF (JSON)",
            Self::Markdown => "Markdown",
            Self::Odt => "OpenDocument Text (ODT)",
            Self::Pdf => "PDF",
        }
    }
}

/// An export error.
#[derive(Debug)]
pub enum ExportError {
    /// The document failed schema validation; export is refused.
    Invalid(String),
    /// An artifact filename was not a safe single path segment.
    UnsafePath(String),
    /// No output format was requested.
    NoFormats,
    /// Document rendering (`lo_writer`) failed.
    Render(String),
    /// JSON serialisation failed.
    Json(String),
    /// An output file already exists and overwrite was not requested.
    Exists(String),
    /// A filesystem write failed.
    Io(std::io::Error),
}

impl fmt::Display for ExportError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Invalid(msg) => write!(f, "refusing to export an invalid SARIF document: {msg}"),
            Self::UnsafePath(name) => write!(f, "unsafe export filename rejected: {name:?}"),
            Self::NoFormats => write!(f, "no export format selected"),
            Self::Render(e) => write!(f, "document rendering failed: {e}"),
            Self::Json(e) => write!(f, "JSON serialisation failed: {e}"),
            Self::Exists(name) => write!(
                f,
                "{name} already exists in the export directory — enable \
                 \"overwrite\" to replace it, or remove it first"
            ),
            Self::Io(e) => write!(f, "write failed: {e}"),
        }
    }
}

impl std::error::Error for ExportError {}

impl From<std::io::Error> for ExportError {
    fn from(value: std::io::Error) -> Self {
        Self::Io(value)
    }
}

/// One rendered artifact: a flat filename and its bytes.
#[derive(Debug, Clone)]
pub struct Artifact {
    /// Flat filename including extension (e.g. `sarif-report.pdf`).
    pub filename: String,
    /// The file contents.
    pub bytes: Vec<u8>,
}

/// One internal document node, rendered into every document format.
enum Node {
    /// A heading at the given level (1 = run, 2 = section, 3 = result).
    Heading(u8, String),
    /// A body paragraph.
    Para(String),
}

/// The document title for a log: tool name(s) plus declared version.
fn document_title(log: &LoadedLog) -> String {
    format!("SARIF report — {} — {}", log.title(), log.version.label())
}

/// Build the shared node list for a log (used by Markdown, ODT and PDF).
fn build_nodes(log: &LoadedLog) -> Vec<Node> {
    let mut nodes = Vec::new();
    nodes.push(Node::Para(format!(
        "Source: {} • Declared version: {} • Runs: {} • Results: {}",
        log.source,
        log.version_raw,
        log.runs.len(),
        log.result_count()
    )));
    if log.runs.is_empty() {
        nodes.push(Node::Para("This SARIF log contains no runs.".to_owned()));
        return nodes;
    }
    for (index, run) in log.runs.iter().enumerate() {
        let version = run
            .tool_version
            .as_deref()
            .map_or_else(String::new, |v| format!(" {v}"));
        nodes.push(Node::Heading(
            1,
            format!("Run {}: {}{version}", index.saturating_add(1), run.tool_name),
        ));
        let counts = crate::model::count_levels(std::slice::from_ref(run));
        let mut facts = Vec::new();
        if let Some(org) = &run.organization {
            facts.push(format!("Organization: {org}"));
        }
        facts.push(format!("Rules defined: {}", run.rules_count));
        facts.push(format!(
            "Results: {} ({} error, {} warning, {} note, {} none)",
            counts.total(),
            counts.error,
            counts.warning,
            counts.note,
            counts.none
        ));
        nodes.push(Node::Para(facts.join("  •  ")));

        for result in &run.results {
            let name = result
                .rule_name
                .as_deref()
                .map_or_else(String::new, |n| format!(" — {n}"));
            nodes.push(Node::Heading(
                3,
                format!("[{}] {}{name}", result.level, result.rule_id),
            ));
            if !result.message.is_empty() {
                nodes.push(Node::Para(result.message.clone()));
            }
            if !result.locations.is_empty() {
                nodes.push(Node::Para(format!(
                    "Location(s): {}",
                    result.locations.join(", ")
                )));
            }
        }
    }
    nodes
}

/// Render the nodes into a `lo_writer` document.
fn build_editor(log: &LoadedLog, nodes: &[Node]) -> WriterEditor {
    let mut editor = WriterEditor::new(document_title(log));
    for node in nodes {
        match node {
            Node::Heading(level, text) => {
                editor.push_heading(*level, text.clone());
            }
            Node::Para(text) => {
                editor.push_paragraph(text.clone());
            }
        }
    }
    editor
}

/// Render the nodes into [`export_pdf`] blocks.
fn build_pdf_blocks(nodes: &[Node]) -> Vec<export_pdf::Block> {
    nodes
        .iter()
        .map(|node| match node {
            Node::Heading(level, text) => export_pdf::Block::Heading(*level, text.clone()),
            Node::Para(text) => export_pdf::Block::Paragraph(text.clone()),
        })
        .collect()
}

/// The validated SARIF document bytes (pretty JSON).
fn sarif_bytes(log: &LoadedLog) -> Result<Vec<u8>, ExportError> {
    serde_json::to_vec_pretty(&log.raw).map_err(|e| ExportError::Json(e.to_string()))
}

/// Render `log` into `formats`, returning in-memory artifacts (no IO).
///
/// The document is schema-validated first; an invalid document yields
/// [`ExportError::Invalid`] and nothing is rendered.
///
/// # Errors
///
/// Returns [`ExportError`] when the document is invalid, no format was
/// requested, or a renderer fails.
pub fn build_artifacts(
    log: &LoadedLog,
    formats: &[ExportFormat],
) -> Result<Vec<Artifact>, ExportError> {
    if formats.is_empty() {
        return Err(ExportError::NoFormats);
    }
    // Mandatory pre-export schema validation (dual 2.1.0 + 2.2 cross-check).
    let report = validation::validate(&log.raw, true).map_err(ExportError::Render)?;
    if !report.is_valid() {
        return Err(ExportError::Invalid(report.summary()));
    }

    // The lo_writer document backs Markdown + ODT; PDF renders from blocks.
    let needs_nodes = formats
        .iter()
        .any(|f| matches!(f, ExportFormat::Markdown | ExportFormat::Odt | ExportFormat::Pdf));
    let nodes = needs_nodes.then(|| build_nodes(log));
    let editor = nodes.as_ref().map(|n| build_editor(log, n));

    // Strip a redundant `.sarif`/`.json` suffix from the id so artifacts are
    // named `report.pdf`, not `report.sarif.pdf`.
    let stem = log
        .id
        .strip_suffix(".sarif")
        .or_else(|| log.id.strip_suffix(".json"))
        .unwrap_or(&log.id)
        .to_owned();
    let mut artifacts = Vec::with_capacity(formats.len());
    for &format in formats {
        let bytes = match format {
            ExportFormat::Sarif => sarif_bytes(log)?,
            ExportFormat::Markdown => render_doc(editor.as_ref(), "md")?,
            ExportFormat::Odt => render_doc(editor.as_ref(), "odt")?,
            ExportFormat::Pdf => {
                let blocks = nodes.as_ref().map(|n| build_pdf_blocks(n)).unwrap_or_default();
                export_pdf::render(&document_title(log), &blocks)
                    .map_err(|e| ExportError::Render(e.to_string()))?
            }
        };
        artifacts.push(Artifact {
            filename: format!("{stem}.{}", format.extension()),
            bytes,
        });
    }
    Ok(artifacts)
}

/// Render the pre-built `lo_writer` document into one format (`md` / `odt`).
fn render_doc(editor: Option<&WriterEditor>, format: &str) -> Result<Vec<u8>, ExportError> {
    let editor = editor.ok_or_else(|| ExportError::Render("document not built".to_owned()))?;
    lo_writer::save_as(&editor.document, format).map_err(|e| ExportError::Render(e.to_string()))
}

/// One written file plus the sidecars written beside it.
#[derive(Debug, Clone)]
pub struct WrittenArtifact {
    /// The artifact filename.
    pub file: String,
    /// The byte length written.
    pub bytes: usize,
    /// The sidecar filenames written next to it.
    pub sidecars: Vec<String>,
}

/// Open (creating if needed) `path` as a capability-scoped directory handle.
///
/// # Errors
///
/// Returns an IO error when the directory cannot be created or opened.
pub fn open_export_dir(path: &str) -> std::io::Result<cap_std::fs::Dir> {
    // Create the operator-configured export boundary root, then scope every
    // subsequent write to it via the cap-std handle (the one audited
    // ambient-authority site). See skills/rust-path-security.md.
    std::fs::create_dir_all(path)?; // allow-std-fs: creates the export boundary root
    cap_std::fs::Dir::open_ambient_dir(path, cap_std::ambient_authority())
}

/// Write `artifacts` (and their sidecars) into `dir`. When `overwrite` is
/// false, existing files are never replaced.
///
/// # Errors
///
/// Returns [`ExportError::Exists`] when `overwrite` is false and an output file
/// already exists, or [`ExportError::Io`] when a write fails.
pub fn write_artifacts(
    dir: &cap_std::fs::Dir,
    artifacts: &[Artifact],
    hashes: &SidecarHashes,
    overwrite: bool,
) -> Result<Vec<WrittenArtifact>, ExportError> {
    let mut written = Vec::with_capacity(artifacts.len());
    for artifact in artifacts {
        // Belt-and-braces: the filename is already a slugified single segment,
        // and cap-std would reject a traversal at the syscall layer — but a
        // lexical check here yields a clear error and a code-review audit point
        // (see skills/rust-path-security.md, example 4).
        if !crate::path_security::is_safe_segment(&artifact.filename) {
            return Err(ExportError::UnsafePath(artifact.filename.clone()));
        }
        let mut options = cap_std::fs::OpenOptions::new();
        if overwrite {
            options.write(true).create(true).truncate(true);
        } else {
            options.write(true).create_new(true);
        }
        {
            use std::io::Write as _;
            let mut file = dir.open_with(&artifact.filename, &options).map_err(|error| {
                if error.kind() == std::io::ErrorKind::AlreadyExists {
                    ExportError::Exists(artifact.filename.clone())
                } else {
                    ExportError::Io(error)
                }
            })?;
            file.write_all(&artifact.bytes)?;
        }
        let sidecars = write_sidecars(dir, &artifact.filename, &artifact.bytes, hashes)?;
        written.push(WrittenArtifact {
            file: artifact.filename.clone(),
            bytes: artifact.bytes.len(),
            sidecars,
        });
    }
    Ok(written)
}

#[cfg(test)]
mod tests {
    // Tests index fixed-size artifact vectors; production indexing stays denied.
    #![allow(clippy::indexing_slicing)]

    use super::{build_artifacts, ExportError, ExportFormat};
    use crate::store::LoadedLog;

    fn log() -> LoadedLog {
        let raw = serde_json::json!({
            "version": "2.1.0",
            "runs": [{
                "tool": { "driver": { "name": "demo", "version": "1.0",
                    "rules": [{ "id": "R1", "name": "RuleOne" }] } },
                "results": [{ "ruleId": "R1", "ruleIndex": 0, "level": "error",
                    "message": { "text": "Geschäft: something broke" },
                    "locations": [{ "physicalLocation": {
                        "artifactLocation": { "uri": "src/a.rs" },
                        "region": { "startLine": 7 } } }] }]
            }]
        });
        LoadedLog::from_value("report".to_owned(), "report.sarif".to_owned(), raw)
    }

    #[test]
    fn all_formats_render_nonempty_and_well_formed() {
        let arts = build_artifacts(&log(), &ExportFormat::ALL).expect("valid export");
        assert_eq!(arts.len(), 4);
        // SARIF
        assert!(serde_json::from_slice::<serde_json::Value>(&arts[0].bytes).is_ok());
        assert!(arts[0].filename.ends_with(".sarif"));
        // Markdown mentions the rule id
        assert!(String::from_utf8_lossy(&arts[1].bytes).contains("R1"));
        // ODT is a zip, PDF is %PDF
        assert!(arts[2].bytes.starts_with(b"PK"), "odt is a zip");
        assert!(arts[3].bytes.starts_with(b"%PDF"), "pdf header");
    }

    #[test]
    fn invalid_document_is_refused() {
        // `runs` missing -> invalid -> export refused.
        let raw = serde_json::json!({ "version": "2.1.0" });
        let bad = LoadedLog::from_value("bad".to_owned(), "bad.sarif".to_owned(), raw);
        assert!(matches!(
            build_artifacts(&bad, &[ExportFormat::Sarif]),
            Err(ExportError::Invalid(_))
        ));
    }

    #[test]
    fn no_formats_errors() {
        assert!(matches!(
            build_artifacts(&log(), &[]),
            Err(ExportError::NoFormats)
        ));
    }

    #[test]
    fn token_roundtrip() {
        for f in ExportFormat::ALL {
            assert_eq!(ExportFormat::from_token(f.token()), Some(f));
        }
        assert_eq!(ExportFormat::from_token("json"), Some(ExportFormat::Sarif));
    }
}
