//! `mcptest sbom` handler (WOR-946).
//!
//! Serves the CycloneDX 1.5 BOM that `build.rs` embedded into the binary
//! at compile time. The handler decides the output shape (`cyclonedx`,
//! `licenses`, `names`) and optionally verifies the embedded blob's
//! SHA-256 against the build-time hash so a reviewer can confirm the
//! BOM in the running binary has not been swapped at runtime.

use std::fs;
use std::io::Write;

use anyhow::{Context, Result};
use serde_json::Value;
use sha2::{Digest, Sha256};

use crate::cli::args::{SbomArgs, SbomFormat};

/// Bytes of the CycloneDX BOM the build script wrote. `include_str!`
/// hard-fails the build if `build.rs` skipped writing the file, which
/// is the property we want: a release binary always has a real SBOM.
const EMBEDDED_BOM: &str = include_str!(concat!(env!("OUT_DIR"), "/bom.cdx.json"));

/// SHA-256 of `EMBEDDED_BOM` as computed by `build.rs`. Used by
/// `--verify` to confirm the embedded bytes match what the build
/// stamped in.
const EMBEDDED_BOM_SHA256: &str = include_str!(concat!(env!("OUT_DIR"), "/bom.sha256"));

pub fn sbom_command(args: SbomArgs) -> Result<i32> {
    if args.verify {
        return verify_embedded_bom();
    }
    let body = match args.format {
        SbomFormat::Cyclonedx => EMBEDDED_BOM.to_string(),
        SbomFormat::Licenses => render_licenses()?,
        SbomFormat::Names => render_names()?,
    };
    write_output(&body, args.output.as_deref())?;
    Ok(0)
}

fn render_licenses() -> Result<String> {
    let value: Value = serde_json::from_str(EMBEDDED_BOM).context("parsing embedded BOM")?;
    let components = value
        .get("components")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let mut out = String::new();
    for comp in components {
        let name = comp.get("name").and_then(Value::as_str).unwrap_or("?");
        let version = comp.get("version").and_then(Value::as_str).unwrap_or("?");
        let spdx = first_license_expression(&comp).unwrap_or("UNKNOWN");
        out.push_str(&format!("{name:30} {version:15} {spdx}\n"));
    }
    Ok(out)
}

fn render_names() -> Result<String> {
    let value: Value = serde_json::from_str(EMBEDDED_BOM).context("parsing embedded BOM")?;
    let components = value
        .get("components")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let mut out = String::new();
    for comp in components {
        let name = comp.get("name").and_then(Value::as_str).unwrap_or("?");
        let version = comp.get("version").and_then(Value::as_str).unwrap_or("?");
        out.push_str(&format!("{name} {version}\n"));
    }
    Ok(out)
}

fn first_license_expression(comp: &Value) -> Option<&str> {
    comp.get("licenses")?.as_array()?.iter().find_map(|entry| {
        entry
            .get("expression")
            .and_then(Value::as_str)
            .or_else(|| entry.get("license").and_then(|l| l.get("id")?.as_str()))
    })
}

fn verify_embedded_bom() -> Result<i32> {
    let mut hasher = Sha256::new();
    hasher.update(EMBEDDED_BOM.as_bytes());
    let actual = hex_encode(&hasher.finalize());
    let expected = EMBEDDED_BOM_SHA256.trim();
    if actual == expected {
        println!("mcptest sbom: embedded BOM verified");
        println!("  sha256       : {actual}");
        println!("  components   : {}", count_components());
        println!();
        println!("To verify the binary against a published release, run:");
        println!("  cosign verify-blob \\");
        println!("    --certificate-identity-regexp '/soapbucket/mcptest/' \\");
        println!("    --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \\");
        println!("    --signature mcptest.sig \\");
        println!("    mcptest");
        Ok(0)
    } else {
        eprintln!("mcptest sbom: embedded BOM hash mismatch");
        eprintln!("  expected: {expected}");
        eprintln!("  actual  : {actual}");
        eprintln!();
        eprintln!("The bytes of the embedded BOM do not match the hash the");
        eprintln!("build wrote. The binary may have been tampered with after");
        eprintln!("the build, or rebuilt with a modified `build.rs`. Refuse to");
        eprintln!("trust this binary until you can verify its release signature.");
        Ok(2)
    }
}

fn count_components() -> usize {
    serde_json::from_str::<Value>(EMBEDDED_BOM)
        .ok()
        .as_ref()
        .and_then(|v| v.get("components"))
        .and_then(Value::as_array)
        .map(Vec::len)
        .unwrap_or(0)
}

fn write_output(body: &str, out: Option<&std::path::Path>) -> Result<()> {
    match out {
        None => {
            let stdout = std::io::stdout();
            let mut handle = stdout.lock();
            handle
                .write_all(body.as_bytes())
                .context("writing stdout")?;
            if !body.ends_with('\n') {
                handle
                    .write_all(b"\n")
                    .context("writing trailing newline")?;
            }
        }
        Some(path) => {
            fs::write(path, body).with_context(|| format!("writing {}", path.display()))?;
        }
    }
    Ok(())
}

fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for &b in bytes {
        out.push(HEX[(b >> 4) as usize] as char);
        out.push(HEX[(b & 0x0f) as usize] as char);
    }
    out
}

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

    #[test]
    fn embedded_bom_parses_as_cyclonedx_v1_5() {
        let value: Value = serde_json::from_str(EMBEDDED_BOM).expect("BOM is valid JSON");
        assert_eq!(value["bomFormat"], "CycloneDX");
        assert_eq!(value["specVersion"], "1.5");
        assert!(value["components"]
            .as_array()
            .map(|c| !c.is_empty())
            .unwrap_or(false));
    }

    #[test]
    fn licenses_format_renders_one_line_per_component() {
        let rendered = render_licenses().expect("licenses render");
        let component_count = count_components();
        let line_count = rendered.lines().count();
        assert_eq!(line_count, component_count);
    }

    #[test]
    fn names_format_renders_one_line_per_component() {
        let rendered = render_names().expect("names render");
        assert_eq!(rendered.lines().count(), count_components());
    }

    #[test]
    fn verify_against_embedded_sha_matches_at_build_time() {
        // The build script wrote the sha; recomputing it against the same
        // bytes that were embedded must agree, otherwise either the build
        // is non-deterministic across re-includes or `--verify` is broken.
        let mut hasher = Sha256::new();
        hasher.update(EMBEDDED_BOM.as_bytes());
        let actual = hex_encode(&hasher.finalize());
        assert_eq!(actual, EMBEDDED_BOM_SHA256.trim());
    }

    #[test]
    fn first_license_expression_handles_expression_form() {
        let comp = serde_json::json!({
            "licenses": [{ "expression": "MIT OR Apache-2.0" }]
        });
        assert_eq!(first_license_expression(&comp), Some("MIT OR Apache-2.0"));
    }

    #[test]
    fn first_license_expression_handles_id_form() {
        let comp = serde_json::json!({
            "licenses": [{ "license": { "id": "MIT" } }]
        });
        assert_eq!(first_license_expression(&comp), Some("MIT"));
    }
}
