//! Build script for the `mcptest` binary.
//!
//! Emits a CycloneDX 1.5 Software Bill of Materials into `OUT_DIR` so the
//! CLI can `include_str!` it and serve `mcptest sbom` from the binary
//! itself. Generated from `cargo metadata`, so no external tool (no
//! `cargo-cyclonedx` install) is required to build mcptest. The BOM's
//! SHA-256 is also written alongside, which lets `mcptest sbom --verify`
//! detect tampering with the embedded blob.
//!
//! Reproducibility: when `SOURCE_DATE_EPOCH` is set the timestamp honors
//! it; otherwise the BOM omits the timestamp field entirely so two clean
//! builds from the same `Cargo.lock` produce byte-identical output.

use std::env;
use std::fs;
use std::path::PathBuf;

use cargo_metadata::MetadataCommand;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};

fn main() {
    // Build is driven by the locked dep graph. Any change to Cargo.lock or
    // Cargo.toml regenerates the BOM; nothing else needs to.
    println!("cargo:rerun-if-changed=Cargo.lock");
    println!("cargo:rerun-if-changed=Cargo.toml");
    println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");

    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo"));

    // `cargo publish --dry-run` verifies the packaged crate by building it in a
    // staging copy under `target/package/`, where the regenerated lock can
    // disagree with the workspace lock about a path-member's checksum and
    // `cargo metadata` then fails. That build is throwaway (it ships nothing),
    // so during package verification a metadata failure degrades to a root-only
    // BOM with a warning instead of aborting. Every real build resolves
    // metadata and gets the full BOM; a metadata failure in a real build still
    // aborts loudly.
    let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
    let is_package_verify =
        manifest_dir.contains("target/package") || manifest_dir.contains("target\\package");
    let metadata = match MetadataCommand::new().exec() {
        Ok(metadata) => Some(metadata),
        Err(err) if is_package_verify => {
            println!(
                "cargo:warning=mcptest build script: cargo metadata unavailable during package \
                 verification ({err}); emitting a root-only SBOM for this throwaway build"
            );
            None
        }
        Err(err) => panic!("cargo metadata failed during build script: {err}"),
    };

    // The root component of the BOM is the mcptest binary itself.
    // Everything else is reported as a `library` component.
    let root_name = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "mcptest".to_string());
    let root_version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
    let root_purl = format!("pkg:cargo/{root_name}@{root_version}");

    let mut components: Vec<Value> = metadata
        .as_ref()
        .map(|metadata| {
            metadata
                .packages
                .iter()
                .filter(|pkg| pkg.name != root_name)
                .map(|pkg| {
                    let purl = format!("pkg:cargo/{}@{}", pkg.name, pkg.version);
                    let mut entry = json!({
                        "type": "library",
                        "bom-ref": purl,
                        "name": pkg.name,
                        "version": pkg.version.to_string(),
                        "purl": purl,
                    });
                    if let Some(license) = &pkg.license {
                        // SPDX expressions like "MIT OR Apache-2.0" go in
                        // `expression`; single ids are also valid SPDX
                        // expressions, so the same field handles both cases
                        // without us having to parse the string.
                        entry["licenses"] = json!([{ "expression": license }]);
                    }
                    if let Some(desc) = &pkg.description {
                        entry["description"] = json!(desc);
                    }
                    if let Some(repo) = &pkg.repository {
                        entry["externalReferences"] = json!([
                            { "type": "vcs", "url": repo }
                        ]);
                    }
                    entry
                })
                .collect()
        })
        .unwrap_or_default();
    components.sort_by(|a, b| {
        a["bom-ref"]
            .as_str()
            .unwrap_or("")
            .cmp(b["bom-ref"].as_str().unwrap_or(""))
    });

    let tools = json!([{
        "vendor": "soapbucket",
        "name": "mcptest-build-rs",
        "version": root_version,
    }]);

    let mut metadata_block = json!({
        "tools": tools,
        "component": {
            "type": "application",
            "bom-ref": root_purl,
            "name": root_name,
            "version": root_version,
            "purl": root_purl,
        }
    });
    if let Some(ts) = source_date_epoch_to_iso8601() {
        metadata_block["timestamp"] = json!(ts);
    }

    let bom = json!({
        "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
        "bomFormat": "CycloneDX",
        "specVersion": "1.5",
        "version": 1,
        "metadata": metadata_block,
        "components": components,
    });

    // Pretty-printed so a human reading `mcptest sbom` raw sees structured
    // output, and so the SHA stays stable across `serde_json` versions that
    // might tweak compact spacing.
    let bom_str = serde_json::to_string_pretty(&bom).expect("serializing BOM");
    fs::write(out_dir.join("bom.cdx.json"), &bom_str).expect("writing bom.cdx.json");

    let mut hasher = Sha256::new();
    hasher.update(bom_str.as_bytes());
    let digest = hasher.finalize();
    let bom_sha = hex_encode(&digest);
    fs::write(out_dir.join("bom.sha256"), &bom_sha).expect("writing bom.sha256");
}

/// Format `SOURCE_DATE_EPOCH` as an RFC 3339 UTC string. Returns `None`
/// when the env var is unset, which is the reproducible-build path
/// (omits the timestamp entirely).
fn source_date_epoch_to_iso8601() -> Option<String> {
    let raw = env::var("SOURCE_DATE_EPOCH").ok()?;
    let secs: i64 = raw.parse().ok()?;
    // Compute Y M D H M S without pulling in chrono. The formula handles
    // any timestamp from 1970 forward, which is all we care about here.
    Some(format_unix_utc(secs))
}

fn format_unix_utc(secs: i64) -> String {
    let days = secs.div_euclid(86_400);
    let mut rem = secs.rem_euclid(86_400);
    let h = rem / 3_600;
    rem %= 3_600;
    let m = rem / 60;
    let s = rem % 60;
    let (y, mo, d) = civil_from_days(days);
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}

/// Howard Hinnant's date algorithm, adapted: days since 1970-01-01 to
/// (year, month 1..12, day 1..31).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}

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
}
