//! SBOM Stream — OpenVEX 0.2.0 emit + CycloneDX 1.5 `vulnerabilities[]`
//! population.
//!
//! Produces droidsaw's per-CVE claims (`not_affected` / `fixed` /
//! `under_investigation`) against a curated table of native-library
//! CVEs. Two output paths:
//!
//! 1. **Inline CycloneDX 1.5 `vulnerabilities[]`** — every CVE in the
//!    curated table that matches a native lib in the BOM yields one
//!    entry with `analysis.state` reflecting the evidence rule.
//! 2. **Side-emit OpenVEX 0.2.0 JSON-LD** — same claims serialized in
//!    the standalone OpenVEX schema for tools that consume VEX
//!    separately (Trivy `--vex`, Grype `--vex-add`).
//!
//! Evidence rules (v1, conservative):
//!
//! - **Fixed**: native lib's extracted version is at or above the
//!   curated `fixed_in` for the CVE. Emits OpenVEX `fixed` /
//!   CycloneDX `resolved`.
//! - **Not-affected (cross-fork)**: native lib's `canonical_name` is
//!   `libressl` and the CVE is registered against the
//!   `cpe:2.3:a:openssl:openssl:*` CPE. The LibreSSL fork descends from
//!   a pre-fork OpenSSL ancestor and has diverged; the OpenSSL
//!   implementation that the CVE is registered against is not present.
//!   Emits OpenVEX `not_affected` with
//!   `justification: component_not_present`. (BoringSSL detection is
//!   not currently wired in `droidsaw-apk`'s `native_lib_versions`
//!   table — BoringSSL doesn't emit a version-bearing rodata anchor,
//!   so no sound VEX claim can be made about it today.)
//! - **Default**: every other curated-table CVE matched against the
//!   lib in the BOM yields `under_investigation` / `in_triage`. Honest
//!   coverage signal: droidsaw knows about the CVE but has no evidence
//!   either way.
//!
//! CVEs NOT in the curated table receive no statement — silent rather
//! than fabricating coverage. Consumers can layer their own VEX feeds
//! on top.
//!
//! Spec references:
//! - OpenVEX 0.2.0: https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md
//! - CycloneDX 1.5 `vulnerabilities[]`: https://cyclonedx.org/docs/1.5/json/#vulnerabilities

use std::collections::BTreeMap;

use serde_json::{json, Value};

use droidsaw_apk::native_lib_versions::NativeLibVersion;

/// One curated row of the CVE table. Each entry pins a CVE to a
/// canonical native library + (optional) fixed-in version + (optional)
/// CPE vendor/product the CVE is registered against.
#[derive(Debug, Clone, Copy)]
pub struct VexCveRule {
    /// CVE identifier (`CVE-YYYY-NNNNN` form).
    pub cve_id: &'static str,
    /// Canonical library name from
    /// [`droidsaw_apk::native_lib_versions::NativeLibVersion::canonical_name`].
    /// Statement fires when a native lib in the BOM matches this name.
    pub canonical_name: &'static str,
    /// Version at which the CVE is fixed. `None` when the CVE has no
    /// patched release droidsaw knows about (still emits `in_triage`).
    pub fixed_in: Option<&'static str>,
    /// CPE vendor/product the CVE is registered against. Drives the
    /// cross-fork `not_affected` rule. `None` when the CPE attribution
    /// is ambiguous or absent.
    pub cpe_vendor_product: Option<(&'static str, &'static str)>,
    /// One-line severity hint for downstream consumers. Free text.
    pub severity: &'static str,
}

/// Curated CVE table — seeded from a representative F-Droid corpus
/// sample with broad native-lib coverage. Each entry pins the CVE to a
/// canonical lib + curated fixed-in version + CPE attribution.
///
/// **TODO: replace with scanner-feed projection.** Hand-curated CVE
/// tables rot. The principled emit path:
///   1. Consumer runs Grype/OSV against droidsaw's SBOM → CVE list
///      (records carry CPE + `fixed_in`).
///   2. droidsaw ingests that output as VEX input.
///   3. `project_claims(scan, apk)` walks each (CVE, component) pair
///      and applies droidsaw's static evidence:
///        - cross-fork (canonical_name vs CPE vendor): generic, no
///          curated data required;
///        - symbol-import dead-code (lib exports ∩ DEX JNI imports = ∅):
///          requires wiring the projection between
///          `apk.native_libs[].imported_symbols` and the DEX side;
///        - version-fixed: read `fixed_in` from the scanner record,
///          do not curate.
///
/// The codec (envelope, statement shape, version compare, CDX
/// `vulnerabilities[]` mapping) stays; `VEX_CVE_RULES` + the table
/// walk in `claims_for_native_lib` go.
pub const VEX_CVE_RULES: &[VexCveRule] = &[
    // ── OpenSSL — high/critical recent CVEs ────────────────────────────
    VexCveRule {
        cve_id: "CVE-2024-5535",
        canonical_name: "openssl",
        fixed_in: Some("3.0.14"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Critical",
    },
    VexCveRule {
        cve_id: "CVE-2023-0286",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1t"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2023-2650",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1u"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2022-1292",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1o"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2022-2068",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1p"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2022-0778",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1n"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2021-3711",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1l"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Critical",
    },
    VexCveRule {
        cve_id: "CVE-2021-3712",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1l"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2021-3449",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1k"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2021-23840",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1j"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2021-23841",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1j"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2023-5678",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1x"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2023-3446",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1v"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2023-4807",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1w"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2023-0464",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1u"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2023-0466",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1u"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2023-0215",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1t"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2019-1543",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1b"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2019-1549",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1d"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2019-1551",
        canonical_name: "openssl",
        fixed_in: Some("1.1.1e"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2024-2511",
        canonical_name: "openssl",
        fixed_in: Some("3.0.14"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    VexCveRule {
        cve_id: "CVE-2024-9143",
        canonical_name: "openssl",
        fixed_in: Some("3.0.16"),
        cpe_vendor_product: Some(("openssl", "openssl")),
        severity: "Medium",
    },
    // ── libcurl — critical / high CVEs (single-branch upstream) ────────
    //
    // libcurl ships from a single development line — no LTS branches —
    // so `version >= fixed_in` is a sound comparison for
    // upstream-pulled binaries (NDK / vendored / vcpkg / Conan), which
    // is the typical APK case. The four entries below are the
    // best-known critical/high CVEs since 2022.
    VexCveRule {
        cve_id: "CVE-2023-38545",
        canonical_name: "libcurl",
        fixed_in: Some("8.4.0"),
        cpe_vendor_product: Some(("haxx", "libcurl")),
        severity: "Critical",
    },
    VexCveRule {
        cve_id: "CVE-2022-32221",
        canonical_name: "libcurl",
        fixed_in: Some("7.86.0"),
        cpe_vendor_product: Some(("haxx", "libcurl")),
        severity: "Critical",
    },
    VexCveRule {
        cve_id: "CVE-2023-27534",
        canonical_name: "libcurl",
        fixed_in: Some("8.0.0"),
        cpe_vendor_product: Some(("haxx", "libcurl")),
        severity: "High",
    },
    VexCveRule {
        cve_id: "CVE-2024-6197",
        canonical_name: "libcurl",
        fixed_in: Some("8.9.0"),
        cpe_vendor_product: Some(("haxx", "libcurl")),
        severity: "High",
    },
    // ── zlib — critical CVEs (single-branch upstream) ──────────────────
    //
    // zlib's `version >= fixed_in` is sound for the same reason as
    // libcurl: single-line development, no LTS branches. The two
    // entries below are the only modern-era critical CVEs in zlib's
    // history; both are widely exploitable in the wild and 1.2.8
    // (observed on F-Droid binaries) is below both fix points.
    VexCveRule {
        cve_id: "CVE-2022-37434",
        canonical_name: "zlib",
        fixed_in: Some("1.2.13"),
        cpe_vendor_product: Some(("zlib", "zlib")),
        severity: "Critical",
    },
    VexCveRule {
        cve_id: "CVE-2018-25032",
        canonical_name: "zlib",
        fixed_in: Some("1.2.12"),
        cpe_vendor_product: Some(("zlib", "zlib")),
        severity: "High",
    },
];

/// Per-component VEX claim emitted for one CVE × one bom-ref.
#[derive(Debug, Clone)]
pub struct VexClaim {
    /// CVE identifier (matches the curated rule's `cve_id`).
    pub cve_id: String,
    /// `bom-ref` of the component the claim is about. Mirrored into
    /// OpenVEX `products[].@id` and CDX `affects[].ref`.
    pub bom_ref: String,
    /// Status enum: `not_affected` / `fixed` / `under_investigation` /
    /// `affected`. Maps to CDX `analysis.state` per the table at the
    /// top of this module.
    pub status: VexStatus,
    /// `justification` (only meaningful when status=not_affected). One
    /// of the OpenVEX-spec enums: `component_not_present`,
    /// `vulnerable_code_not_present`, `vulnerable_code_not_in_execute_path`,
    /// `vulnerable_code_cannot_be_controlled_by_adversary`,
    /// `inline_mitigations_already_exist`.
    pub justification: Option<&'static str>,
    /// Free-text evidence reference for the human reading the BOM.
    pub impact_statement: String,
}

/// VEX status enum mirroring OpenVEX 0.2.0 `statements[].status`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VexStatus {
    /// Component is not affected by this CVE.
    NotAffected,
    /// CVE is fixed in the version present in this BOM.
    Fixed,
    /// CVE is being investigated; droidsaw has no evidence either way.
    UnderInvestigation,
    /// CVE applies to this component (droidsaw confirms exploitability).
    /// v1 doesn't emit this state — reserved for future evidence rules
    /// that can affirmatively prove exploitability.
    #[allow(dead_code, reason = "reserved for v1.x evidence rules")]
    Affected,
}

impl VexStatus {
    /// OpenVEX 0.2.0 wire string.
    fn openvex_str(self) -> &'static str {
        match self {
            Self::NotAffected => "not_affected",
            Self::Fixed => "fixed",
            Self::UnderInvestigation => "under_investigation",
            Self::Affected => "affected",
        }
    }

    /// CycloneDX 1.5 `analysis.state` wire string.
    fn cyclonedx_state_str(self) -> &'static str {
        match self {
            Self::NotAffected => "not_affected",
            Self::Fixed => "resolved",
            Self::UnderInvestigation => "in_triage",
            Self::Affected => "exploitable",
        }
    }
}

/// Compare two dotted version strings (`MAJOR.MINOR.PATCH[a-z]?`) for
/// ordering. Returns `Less` / `Equal` / `Greater`. Hand-rolled because
/// OpenSSL's letter-suffix convention (`1.1.1a` < `1.1.1b`) and the
/// `MM.mm.pp` shape we extract don't map cleanly to standard semver
/// crates without bringing in a dep.
///
/// Tolerant of trailing non-version bytes (`"1.1.1a  20 Nov 2018"` →
/// compares as `1.1.1a`). Missing components default to 0.
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
    let (a_parts, a_suffix) = parse_version(a);
    let (b_parts, b_suffix) = parse_version(b);
    for i in 0..4 {
        let av = a_parts.get(i).copied().unwrap_or(0);
        let bv = b_parts.get(i).copied().unwrap_or(0);
        match av.cmp(&bv) {
            std::cmp::Ordering::Equal => continue,
            ord => return ord,
        }
    }
    a_suffix.cmp(&b_suffix)
}

/// Parse `<digits>.<digits>.<digits>[a-z]?` into a `(parts, suffix)`
/// tuple. `suffix` is `0` when no letter suffix is present, or the
/// lowercase letter's ASCII value (`'a'` = 97). Letter suffix ordering
/// preserves OpenSSL's `1.1.1a` < `1.1.1b` semantics.
fn parse_version(s: &str) -> (Vec<u32>, u8) {
    let mut parts = Vec::new();
    let mut current: u32 = 0;
    let mut saw_digit = false;
    let mut suffix: u8 = 0;
    for c in s.chars() {
        if let Some(d) = c.to_digit(10) {
            current = current.saturating_mul(10).saturating_add(d);
            saw_digit = true;
        } else if c == '.' && saw_digit {
            parts.push(current);
            current = 0;
            saw_digit = false;
        } else if c.is_ascii_lowercase() && saw_digit {
            // Infallible: `is_ascii_lowercase` bounds `c` to ASCII; the
            // fallback keeps the no-suffix semantics if that ever broke.
            suffix = u8::try_from(c).unwrap_or(0);
            break;
        } else {
            break;
        }
    }
    if saw_digit {
        parts.push(current);
    }
    (parts, suffix)
}

/// Walk the curated table against a single native-lib BOM component;
/// produce zero-or-more VEX claims based on the evidence rules.
///
/// Inputs:
/// - `info`: the resolved `NativeLibVersion` from Stream D's extractor
///   (canonical_name + version + cpe_vendor_product).
/// - `bom_ref`: the component's `bom-ref` from the SBOM emit. Mirrors
///   into the claim.
///
/// Evidence ordering: `not_affected` (cross-fork) takes precedence over
/// `fixed` (since the fork detection is the stronger claim — the
/// vulnerable code isn't even present). `fixed` takes precedence over
/// `under_investigation`.
pub fn claims_for_native_lib(info: &NativeLibVersion, bom_ref: &str) -> Vec<VexClaim> {
    let mut out = Vec::new();
    for rule in VEX_CVE_RULES {
        // Cross-fork rule: LibreSSL libs against an OpenSSL-CPE-keyed
        // CVE — not_affected with `component_not_present`. BoringSSL
        // would qualify but `droidsaw-apk`'s extractor cannot pin a
        // BoringSSL version, so no claim is emitted on its behalf.
        let is_cross_fork = matches!(
            (info.canonical_name.as_str(), rule.cpe_vendor_product),
            ("libressl", Some(("openssl", "openssl")))
        );
        if is_cross_fork {
            out.push(VexClaim {
                cve_id: rule.cve_id.to_owned(),
                bom_ref: bom_ref.to_owned(),
                status: VexStatus::NotAffected,
                justification: Some("component_not_present"),
                impact_statement: format!(
                    "Component is {} fork; OpenSSL implementation that {} is registered against is not present in this binary.",
                    info.canonical_name, rule.cve_id,
                ),
            });
            continue;
        }
        // Canonical-name rule: only fire on rules matching this lib's
        // canonical_name.
        if rule.canonical_name != info.canonical_name {
            continue;
        }
        // Fixed rule: extracted version is at or above curated fixed_in.
        if let Some(fixed_in) = rule.fixed_in
            && compare_versions(&info.version, fixed_in) != std::cmp::Ordering::Less
        {
            out.push(VexClaim {
                cve_id: rule.cve_id.to_owned(),
                bom_ref: bom_ref.to_owned(),
                status: VexStatus::Fixed,
                justification: None,
                impact_statement: format!(
                    "Extracted version {} is at or above curated fixed-in version {}.",
                    info.version, fixed_in,
                ),
            });
            continue;
        }
        // Default: under_investigation. CVE is in the curated table
        // and matches the canonical lib, but no evidence rule fired.
        out.push(VexClaim {
            cve_id: rule.cve_id.to_owned(),
            bom_ref: bom_ref.to_owned(),
            status: VexStatus::UnderInvestigation,
            justification: None,
            impact_statement: format!(
                "Curated CVE table lists this CVE against {} {}; droidsaw has no static evidence yet to mark it not_affected or fixed.",
                info.canonical_name, info.version,
            ),
        });
    }
    out
}

/// Build the CycloneDX 1.5 `vulnerabilities[]` entry for a single
/// VexClaim. One entry per claim.
pub fn cyclonedx_vulnerability(claim: &VexClaim) -> Value {
    let mut analysis = serde_json::Map::new();
    analysis.insert("state".into(), json!(claim.status.cyclonedx_state_str()));
    if let Some(j) = claim.justification {
        analysis.insert("justification".into(), json!(map_justification_to_cdx(j)));
    }
    if !claim.impact_statement.is_empty() {
        analysis.insert("detail".into(), json!(claim.impact_statement));
    }
    json!({
        "bom-ref": format!("{}#{}", claim.cve_id, &claim.bom_ref),
        "id": claim.cve_id,
        "source": { "name": "NVD" },
        "affects": [
            { "ref": claim.bom_ref }
        ],
        "analysis": Value::Object(analysis),
    })
}

/// Build the full OpenVEX 0.2.0 JSON-LD envelope from a set of claims.
/// One envelope per BOM (consumers route by author + timestamp).
pub fn openvex_envelope(claims: &[VexClaim], bom_serial_number: &str) -> Value {
    let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
    let statements: Vec<Value> = claims
        .iter()
        .map(|c| {
            let mut stmt = serde_json::Map::new();
            stmt.insert(
                "vulnerability".into(),
                json!({ "@id": format!("https://nvd.nist.gov/vuln/detail/{}", c.cve_id), "name": c.cve_id }),
            );
            stmt.insert(
                "products".into(),
                json!([{ "@id": c.bom_ref }]),
            );
            stmt.insert("status".into(), json!(c.status.openvex_str()));
            if let Some(j) = c.justification {
                stmt.insert("justification".into(), json!(j));
            }
            if !c.impact_statement.is_empty() {
                stmt.insert("impact_statement".into(), json!(c.impact_statement));
            }
            Value::Object(stmt)
        })
        .collect();
    let tool_version = env!("CARGO_PKG_VERSION");
    json!({
        "@context": "https://openvex.dev/ns/v0.2.0",
        "@id": format!("urn:droidsaw:openvex:{bom_serial_number}"),
        "author": format!("droidsaw {tool_version}"),
        "role": "Open Source Project",
        "timestamp": timestamp,
        "version": 1,
        "statements": statements,
    })
}

/// Map an OpenVEX justification enum to CycloneDX 1.5's distinct enum.
/// The two specs use different vocabularies for the same concepts.
fn map_justification_to_cdx(openvex_justification: &str) -> &'static str {
    match openvex_justification {
        "component_not_present" => "code_not_present",
        "vulnerable_code_not_present" => "code_not_present",
        "vulnerable_code_not_in_execute_path" => "code_not_reachable",
        "vulnerable_code_cannot_be_controlled_by_adversary" => "requires_environment",
        "inline_mitigations_already_exist" => "protected_by_mitigating_control",
        _ => "code_not_present",
    }
}

/// Walk every component in a freshly-built BOM, gather native-lib
/// VEX claims, return them grouped by BOM. The caller decides whether
/// to inject into `vulnerabilities[]`, side-emit a `.openvex.json`
/// file, or both.
pub fn gather_claims(
    apk_native_libs: &BTreeMap<String, Vec<droidsaw_apk::apk::NativeLib>>,
) -> Vec<VexClaim> {
    let mut out = Vec::new();
    for libs in apk_native_libs.values() {
        for lib in libs {
            let Some(info) = lib.version_info.as_ref() else {
                continue;
            };
            let bom_ref = format!("pkg:generic/{}@{}", info.canonical_name, info.version);
            out.extend(claims_for_native_lib(info, &bom_ref));
        }
    }
    out
}

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

    // ── Version comparison ────────────────────────────────────────────

    #[test]
    fn compare_versions_handles_canonical_semver() {
        assert_eq!(compare_versions("1.1.1", "1.1.0"), std::cmp::Ordering::Greater);
        assert_eq!(compare_versions("1.1.0", "1.1.1"), std::cmp::Ordering::Less);
        assert_eq!(compare_versions("1.1.1", "1.1.1"), std::cmp::Ordering::Equal);
    }

    #[test]
    fn compare_versions_orders_openssl_letter_suffix() {
        assert_eq!(compare_versions("1.1.1a", "1.1.1b"), std::cmp::Ordering::Less);
        assert_eq!(compare_versions("1.1.1z", "1.1.1a"), std::cmp::Ordering::Greater);
        assert_eq!(compare_versions("1.1.1u", "1.1.1t"), std::cmp::Ordering::Greater);
        assert_eq!(compare_versions("1.1.1a", "1.1.1"), std::cmp::Ordering::Greater);
    }

    #[test]
    fn compare_versions_handles_major_differences() {
        assert_eq!(compare_versions("3.0.0", "1.1.1u"), std::cmp::Ordering::Greater);
        assert_eq!(compare_versions("1.0.2k", "1.1.1"), std::cmp::Ordering::Less);
    }

    #[test]
    fn compare_versions_tolerates_trailing_text() {
        assert_eq!(
            compare_versions("1.1.1a  20 Nov 2018", "1.1.1a"),
            std::cmp::Ordering::Equal,
        );
    }

    // ── Evidence rules ────────────────────────────────────────────────

    fn nlv(name: &str, version: &str) -> NativeLibVersion {
        NativeLibVersion {
            canonical_name: name.to_owned(),
            version: version.to_owned(),
            matched_string: format!("{name} {version}"),
            cpe_vendor_product: Some(("openssl".to_owned(), "openssl".to_owned())),
        }
    }

    #[test]
    fn fixed_rule_fires_when_version_at_or_above_curated_fixed_in() {
        let info = nlv("openssl", "1.1.1u");
        let claims = claims_for_native_lib(&info, "pkg:generic/openssl@1.1.1u");
        // CVE-2023-0286 fixed_in=1.1.1t — version 1.1.1u >= 1.1.1t → Fixed.
        let cve = claims.iter().find(|c| c.cve_id == "CVE-2023-0286").expect("present");
        assert_eq!(cve.status, VexStatus::Fixed);
        // CVE-2023-5678 fixed_in=1.1.1x — version 1.1.1u < 1.1.1x → UnderInvestigation.
        let cve = claims.iter().find(|c| c.cve_id == "CVE-2023-5678").expect("present");
        assert_eq!(cve.status, VexStatus::UnderInvestigation);
    }

    #[test]
    fn under_investigation_fires_when_version_below_fixed_in() {
        let info = nlv("openssl", "1.1.1a");
        let claims = claims_for_native_lib(&info, "pkg:generic/openssl@1.1.1a");
        // ALL OpenSSL CVEs in the curated table fire as UnderInvestigation
        // for 1.1.1a since their fixed_in is > 1.1.1a.
        let under = claims.iter().filter(|c| c.status == VexStatus::UnderInvestigation).count();
        assert!(under >= 15, "expected ≥15 under_investigation claims; got {under}");
        let fixed = claims.iter().filter(|c| c.status == VexStatus::Fixed).count();
        assert_eq!(fixed, 0, "no curated CVE is fixed_in <= 1.1.1a");
    }

    #[test]
    fn cross_fork_rule_fires_for_libressl() {
        let info = NativeLibVersion {
            canonical_name: "libressl".to_owned(),
            version: "3.7.0".to_owned(),
            matched_string: "LibreSSL banner".to_owned(),
            cpe_vendor_product: None,
        };
        let claims = claims_for_native_lib(&info, "pkg:generic/libressl@3.7.0");
        for claim in &claims {
            assert_eq!(claim.status, VexStatus::NotAffected);
        }
    }

    #[test]
    fn non_curated_canonical_yields_no_claims() {
        // nghttp2 is detected by native_lib_versions but has no entries
        // in VEX_CVE_RULES yet — silent rather than fabricating coverage.
        let info = nlv("nghttp2", "1.50.0");
        let claims = claims_for_native_lib(&info, "pkg:generic/nghttp2@1.50.0");
        assert!(claims.is_empty(), "non-curated canonical must yield no claims");
    }

    // ── CycloneDX vulnerability shape ─────────────────────────────────

    #[test]
    fn cyclonedx_vulnerability_emits_expected_fields() {
        let claim = VexClaim {
            cve_id: "CVE-2023-0286".to_owned(),
            bom_ref: "pkg:generic/openssl@1.1.1a".to_owned(),
            status: VexStatus::UnderInvestigation,
            justification: None,
            impact_statement: "test".to_owned(),
        };
        let v = cyclonedx_vulnerability(&claim);
        assert_eq!(v["id"], "CVE-2023-0286");
        assert_eq!(v["affects"][0]["ref"], "pkg:generic/openssl@1.1.1a");
        assert_eq!(v["analysis"]["state"], "in_triage");
        assert_eq!(v["analysis"]["detail"], "test");
    }

    #[test]
    fn cyclonedx_vulnerability_emits_fixed_state() {
        let claim = VexClaim {
            cve_id: "CVE-2023-0286".to_owned(),
            bom_ref: "pkg:generic/openssl@1.1.1u".to_owned(),
            status: VexStatus::Fixed,
            justification: None,
            impact_statement: String::new(),
        };
        let v = cyclonedx_vulnerability(&claim);
        // Fixed → CycloneDX "resolved".
        assert_eq!(v["analysis"]["state"], "resolved");
    }

    #[test]
    fn cyclonedx_vulnerability_emits_not_affected_with_justification() {
        let claim = VexClaim {
            cve_id: "CVE-2023-0286".to_owned(),
            bom_ref: "pkg:generic/boringssl@0.0.0".to_owned(),
            status: VexStatus::NotAffected,
            justification: Some("component_not_present"),
            impact_statement: "BoringSSL fork".to_owned(),
        };
        let v = cyclonedx_vulnerability(&claim);
        assert_eq!(v["analysis"]["state"], "not_affected");
        // CycloneDX uses different justification enum than OpenVEX.
        assert_eq!(v["analysis"]["justification"], "code_not_present");
    }

    // ── OpenVEX envelope shape ────────────────────────────────────────

    #[test]
    fn openvex_envelope_has_required_top_level_fields() {
        let claim = VexClaim {
            cve_id: "CVE-2023-0286".to_owned(),
            bom_ref: "pkg:generic/openssl@1.1.1u".to_owned(),
            status: VexStatus::Fixed,
            justification: None,
            impact_statement: String::new(),
        };
        let env = openvex_envelope(&[claim], "urn:uuid:test");
        assert_eq!(env["@context"], "https://openvex.dev/ns/v0.2.0");
        assert!(env["@id"].as_str().unwrap_or("").contains("urn:droidsaw:openvex"));
        assert!(env["author"].as_str().unwrap_or("").starts_with("droidsaw"));
        assert_eq!(env["version"], 1);
        let statements = env["statements"].as_array().expect("statements array");
        assert_eq!(statements.len(), 1);
        assert_eq!(statements[0]["status"], "fixed");
        assert_eq!(statements[0]["vulnerability"]["name"], "CVE-2023-0286");
    }

    #[test]
    fn openvex_envelope_includes_justification_when_not_affected() {
        let claim = VexClaim {
            cve_id: "CVE-2023-0286".to_owned(),
            bom_ref: "pkg:generic/boringssl@0.0.0".to_owned(),
            status: VexStatus::NotAffected,
            justification: Some("component_not_present"),
            impact_statement: "BoringSSL fork".to_owned(),
        };
        let env = openvex_envelope(&[claim], "urn:uuid:test");
        let stmt = &env["statements"][0];
        assert_eq!(stmt["status"], "not_affected");
        assert_eq!(stmt["justification"], "component_not_present");
        assert_eq!(stmt["impact_statement"], "BoringSSL fork");
    }

    // ── Curated-table coverage gauges ─────────────────────────────────

    #[test]
    fn curated_table_has_at_least_twenty_entries() {
        assert!(
            VEX_CVE_RULES.len() >= 20,
            "curated CVE table must have ≥20 entries; got {}",
            VEX_CVE_RULES.len(),
        );
    }

    #[test]
    fn curated_table_covers_openssl_critical_severity_recent() {
        let cves: Vec<&str> = VEX_CVE_RULES.iter().map(|r| r.cve_id).collect();
        // Anchor CVEs that drive the Pokemon TCG demo (Grype matched).
        for required in &["CVE-2024-5535", "CVE-2023-0286", "CVE-2022-1292", "CVE-2021-3711"] {
            assert!(
                cves.contains(required),
                "curated table must cover anchor CVE {required}; got {cves:?}",
            );
        }
    }
}
