//! Control-application dependency SBOM from a CODESYS `.projectarchive`.
//!
//! A `.projectarchive` is a ZIP that bundles the project plus its referenced
//! libraries and the target runtime. Each referenced library is an entry whose
//! path segment follows the CODESYS convention
//! `Name, Major.Minor.Patch.Build (Company)` (e.g. `Standard, 3.5.18.0 (System)`,
//! `CSV Utility SL, 1.9.0.0 (CODESYS)`); the runtime appears as a device-descriptor
//! entry `CODESYS Control … <version> …`. We enumerate those entries — no binary
//! parsing — and emit SBOM components tagged `pkg:codesys/…`, so the CVE pipeline
//! can match them (the runtime `Cmp*` / `3SLicense` components carry real CODESYS
//! CVEs).

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use compliance_core::models::SbomEntry;

/// Collect the control-application SBOM from every `.projectarchive` reachable for
/// a target: the ingested artifact file itself (an uploaded archive), plus any
/// `*.projectarchive` committed inside the working tree — e.g. a git repo or an
/// extracted source archive that ships the archive alongside its PLCopen XML / ST
/// exports. Deduplicated by (name, version).
pub fn collect_sbom(artifact_file: &Path, working_path: &Path, repo_id: &str) -> Vec<SbomEntry> {
    let mut archives: Vec<PathBuf> = Vec::new();
    if artifact_file.is_file() {
        archives.push(artifact_file.to_path_buf());
    }
    for entry in walkdir::WalkDir::new(working_path)
        .max_depth(8)
        .into_iter()
        .filter_map(|e| e.ok())
    {
        let p = entry.path();
        if entry.file_type().is_file()
            && p.extension()
                .and_then(|x| x.to_str())
                .is_some_and(|x| x.eq_ignore_ascii_case("projectarchive"))
        {
            archives.push(p.to_path_buf());
        }
    }
    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
    let mut out = Vec::new();
    for a in archives {
        for e in projectarchive_sbom(&a, repo_id) {
            if seen.insert((e.name.clone(), e.version.clone())) {
                out.push(e);
            }
        }
    }
    out
}

/// Extract CODESYS library + runtime components from a `.projectarchive` (a zip).
/// Best-effort: returns empty if the file is not a readable zip (e.g. a bare
/// `.st`/`.xml` project, which carries no library manifest).
pub fn projectarchive_sbom(archive: &Path, repo_id: &str) -> Vec<SbomEntry> {
    let Ok(file) = std::fs::File::open(archive) else {
        return Vec::new();
    };
    let Ok(mut zip) = zip::ZipArchive::new(file) else {
        return Vec::new();
    };
    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
    let mut entries = Vec::new();
    for i in 0..zip.len() {
        let Ok(entry) = zip.by_index(i) else {
            continue;
        };
        // Entry paths use `\` (Windows-authored) and/or `/` separators; the
        // component id is one path segment.
        for seg in entry.name().split(['/', '\\']) {
            if let Some((name, version)) = parse_library(seg).or_else(|| parse_runtime(seg)) {
                if seen.insert((name.clone(), version.clone())) {
                    let mut e = SbomEntry::new(
                        repo_id.to_string(),
                        name.clone(),
                        version.clone(),
                        "codesys".to_string(),
                    );
                    e.purl = Some(format!(
                        "pkg:codesys/{}@{version}",
                        name.replace(' ', "%20")
                    ));
                    entries.push(e);
                }
            }
        }
    }
    entries
}

/// `Name, X.Y.Z.W (Company)` → (name, version).
fn parse_library(seg: &str) -> Option<(String, String)> {
    let seg = seg.trim();
    // Company is the trailing "(…)".
    let open = seg.rfind(" (")?;
    let rest = &seg[open + 2..];
    let close = rest.find(')')?;
    if rest[..close].trim().is_empty() {
        return None;
    }
    let head = seg[..open].trim(); // "Name, X.Y.Z.W"
    let comma = head.rfind(", ")?;
    let name = head[..comma].trim().to_string();
    let version = head[comma + 2..].trim().to_string();
    if name.is_empty() || !is_dotted_version(&version) {
        return None;
    }
    Some((name, version))
}

/// Device-descriptor entry `CODESYS Control … X.Y.Z.W …` → (runtime name, version).
fn parse_runtime(seg: &str) -> Option<(String, String)> {
    let seg = seg.trim();
    if !seg.starts_with("CODESYS Control") {
        return None;
    }
    let version = seg
        .split_whitespace()
        .find(|t| is_dotted_version(t))?
        .to_string();
    // The runtime name is the first field, before the run of padding spaces that
    // precede the descriptor's numeric columns.
    let name = seg.split("  ").next().unwrap_or(seg).trim().to_string();
    if name.is_empty() {
        return None;
    }
    Some((name, version))
}

/// A dotted numeric version with at least 3 components (`3.5.18.0`, `4.17.0.0`).
fn is_dotted_version(s: &str) -> bool {
    let parts: Vec<&str> = s.split('.').collect();
    parts.len() >= 3
        && parts
            .iter()
            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::io::Write;

    /// Build a synthetic `.projectarchive` (zip) mirroring the real CODESYS entry
    /// naming (verified against Proemion/codesys-examples): a native `.project`,
    /// referenced libraries as `Name, Version (Company)` segments, and a runtime
    /// device descriptor.
    fn synthetic_archive(dir: &Path) -> std::path::PathBuf {
        let path = dir.join("App.projectarchive");
        write_synthetic_archive(&path);
        path
    }

    fn write_synthetic_archive(path: &Path) {
        let file = std::fs::File::create(path).expect("create");
        let mut zip = zip::ZipWriter::new(file);
        let opts: zip::write::SimpleFileOptions = Default::default();
        let names = [
            "App.project",
            r"{b0b5}\App.Device.Plc.compileinfo",
            r"{e179}\Standard, 3.5.18.0 (System)   standard.compiled-library-v3",
            r"{e179}\Util, 3.5.21.0 (System)   util.compiled-library-v3",
            r"{e179}\CSV Utility SL, 1.9.0.0 (CODESYS)   csv utility sl.compiled-library-v3",
            r"{e179}\3SLicense, 3.5.20.0 (CODESYS)   3slicense.compiled-library-v3",
            r"{0c63}\CODESYS Control for Linux ARM SL     0000 0006     4.17.0.0     4096     .zip",
        ];
        for n in names {
            zip.start_file(n, opts).expect("start");
            zip.write_all(b"x").expect("write");
        }
        zip.finish().expect("finish");
    }

    #[test]
    fn extracts_libraries_and_runtime_from_projectarchive() {
        let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&tmp).expect("mkdir");
        let archive = synthetic_archive(&tmp);

        let entries = projectarchive_sbom(&archive, "plc-target");
        let by_name: HashMap<&str, &SbomEntry> =
            entries.iter().map(|e| (e.name.as_str(), e)).collect();

        // Libraries with their versions.
        assert_eq!(
            by_name.get("Standard").map(|e| e.version.as_str()),
            Some("3.5.18.0")
        );
        assert_eq!(
            by_name.get("Util").map(|e| e.version.as_str()),
            Some("3.5.21.0")
        );
        assert_eq!(
            by_name.get("CSV Utility SL").map(|e| e.version.as_str()),
            Some("1.9.0.0"),
            "multi-word library names must parse"
        );
        assert!(by_name.contains_key("3SLicense"));

        // The runtime, from the device descriptor.
        assert_eq!(
            by_name
                .get("CODESYS Control for Linux ARM SL")
                .map(|e| e.version.as_str()),
            Some("4.17.0.0")
        );

        // Every component is CODESYS-tagged with a purl the CVE pipeline can match,
        // and the native `.project` / compileinfo are not mistaken for components.
        for e in &entries {
            assert_eq!(e.package_manager, "codesys");
            assert!(e.purl.as_deref().unwrap_or("").starts_with("pkg:codesys/"));
        }
        assert!(!by_name.contains_key("App"));

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn collect_sbom_finds_a_projectarchive_committed_in_a_git_tree() {
        let tmp = std::env::temp_dir().join(format!("cs-plc-collect-{}", uuid::Uuid::new_v4()));
        let src = tmp.join("clone/src");
        std::fs::create_dir_all(&src).expect("mkdir");
        // Simulate a git clone that commits the archive alongside its exports.
        write_synthetic_archive(&src.join("PumpStation.projectarchive"));

        // The artifact "file" is a git URL (not a real file), so the SBOM must
        // come from walking the cloned tree.
        let entries = collect_sbom(Path::new("https://git.example/plc.git"), &tmp, "t");
        let names: std::collections::HashSet<&str> =
            entries.iter().map(|e| e.name.as_str()).collect();
        assert!(
            names.contains("Standard"),
            "found libs in the committed archive"
        );
        assert!(names.contains("CODESYS Control for Linux ARM SL"));

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn non_zip_file_yields_no_sbom() {
        let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-st-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&tmp).expect("mkdir");
        let st = tmp.join("prog.st");
        std::fs::write(&st, "PROGRAM P\nVAR x : INT; END_VAR\nEND_PROGRAM\n").expect("write");
        assert!(projectarchive_sbom(&st, "t").is_empty());
        let _ = std::fs::remove_dir_all(&tmp);
    }
}
