123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927
//! service-kicad — shared headless KiCad + kicad-packages3d cache.
//!
//! Replaces the legacy `service-kicad` container (being decommissioned)
//! and extracts the KiCad bits from `service-aci` so every Adom tool
//! shares one source of truth for:
//!
//! - `kicad-cli` subprocess wrappers (DRC, ERC, SVG/STEP/GLB/Gerber export).
//! - `kicad-packages3d` static asset serving (the ~5 GB model library).
//! - `kicad-symbols` + `kicad-footprints` static asset serving.
//!
//! This binary is **tool-agnostic by design** — it does NOT depend on
//! any aci-* crate. That means symbol-creator, footprint-creator,
//! library-creator, adom-tsci, adom-chipfit, adom-circuit, etc. all
//! hit the same endpoints with no ordering constraint on releases.
//!
//! Contract: `docs/service-kicad-api.md`.

use axum::{
    body::Bytes,
    extract::{DefaultBodyLimit, Path as AxumPath, Query, RawQuery},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{get, post},
    Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::{net::SocketAddr, path::PathBuf, process::Command};
use tempfile::NamedTempFile;
use tower_http::services::ServeDir;

const MAX_UPLOAD_BYTES: usize = 100 * 1024 * 1024; // 100 MB

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();

    let models_dir     = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");
    let footprints_dir = dir_or_default("KICAD_FOOTPRINTS_DIR", "/usr/share/kicad/footprints");
    let symbols_dir    = dir_or_default("KICAD_SYMBOLS_DIR",    "/usr/share/kicad/symbols");

    let app = Router::new()
        .route("/health",  get(health))
        .route("/version", get(version))
        // kicad-cli pcb operations
        .route("/kicad/pcb/drc",             post(pcb_drc))
        .route("/kicad/pcb/export/svg",      post(pcb_export_svg))
        .route("/kicad/pcb/export/gerbers",  post(pcb_export_gerbers))
        .route("/kicad/pcb/export/step",     post(pcb_export_step))
        .route("/kicad/pcb/export/glb",      post(pcb_export_glb))
        .route("/kicad/pcb/export/glb-zup",  post(pcb_export_glb_zup))
        .route("/kicad/pcb/export/glb-package", post(pcb_export_glb_package))
        .route("/kicad/pcb/export/glb-package-zup", post(pcb_export_glb_package_zup))
        // kicad-cli sch operations
        .route("/kicad/sch/erc",             post(sch_erc))
        .route("/kicad/sch/export/svg",      post(sch_export_svg))
        .route("/kicad/sch/export/svg-project", post(sch_export_svg_project))
        .route("/kicad/sch/export/pdf",      post(sch_export_pdf))
        .route("/kicad/sch/export/bom",      post(sch_export_bom))
        // Altium → KiCad import (KiCad 10's plugin IO auto-detects the OLE
        // format). POST raw .SchLib → .kicad_sym; POST raw .PcbLib → .kicad_mod.
        .route("/kicad/altium/sym",          post(altium_sym_upgrade))
        .route("/kicad/altium/fp",           post(altium_fp_upgrade))
        // Symbol + footprint export by library/name — cached lookup.
        .route("/sym/export/svg/:library/:name", get(sym_export_svg))
        .route("/fp/export/svg/:library/:name",  get(fp_export_svg))
        // Static library mounts
        .nest_service("/models",     ServeDir::new(&models_dir))
        .nest_service("/footprints", ServeDir::new(&footprints_dir))
        .nest_service("/symbols",    ServeDir::new(&symbols_dir))
        .layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES));

    let port: u16 = std::env::var("PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(8780);
    let addr: SocketAddr = format!("0.0.0.0:{port}").parse()?;
    tracing::info!(%addr, "service-kicad listening");
    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

fn dir_or_default(env_key: &str, default: &str) -> String {
    std::env::var(env_key).unwrap_or_else(|_| default.to_string())
}

async fn health() -> Json<Value> {
    let kicad_version = Command::new(kicad_cli())
        .arg("version")
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .unwrap_or_else(|| "unknown".into());
    Json(json!({
        "ok": true,
        "service": "service-kicad",
        "version": env!("CARGO_PKG_VERSION"),
        "kicad_version": kicad_version,
        "git_sha": GIT_SHA,
        "git_sha_short": git_sha_short(),
    }))
}

async fn version() -> Json<Value> {
    Json(json!({
        "service": "service-kicad",
        "version": env!("CARGO_PKG_VERSION"),
        "api_version": "1.0.0",
        "git_sha": GIT_SHA,
        "git_sha_short": git_sha_short(),
    }))
}

/// SHA the running binary was built from (baked in by api/build.rs).
/// Deploy verification: if this lags origin/main on GitHub, the prod
/// watchdog's fetch-rebuild loop is failing silently.
const GIT_SHA: &str = env!("GIT_SHA");

fn git_sha_short() -> &'static str {
    &GIT_SHA[..GIT_SHA.len().min(8)]
}

fn kicad_cli() -> String {
    std::env::var("KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into())
}

/// kicad-cli invocation wrapper — always sets KICAD{6,7,8,9,10}_3DMODEL_DIR
/// so STEP/GLB exports resolve component models. Matches the fix baked
/// into aci-validate (PLAN Part 25). Setting unused versions is a no-op,
/// so the same binary works on v9 and v10 containers.
fn run_kicad(args: &[&str]) -> Result<Vec<u8>, (StatusCode, String)> {
    let model_dir = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");
    let out = Command::new(kicad_cli())
        .args(args)
        .env("KICAD6_3DMODEL_DIR", &model_dir)
        .env("KICAD7_3DMODEL_DIR", &model_dir)
        .env("KICAD8_3DMODEL_DIR", &model_dir)
        .env("KICAD9_3DMODEL_DIR", &model_dir)
        .env("KICAD10_3DMODEL_DIR", &model_dir)
        .output()
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("spawn kicad-cli: {e}")))?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
        return Err((StatusCode::BAD_REQUEST, stderr));
    }
    Ok(out.stdout)
}

fn write_temp(body: &Bytes, ext: &str) -> Result<NamedTempFile, (StatusCode, String)> {
    let tmp = tempfile::Builder::new()
        .suffix(&format!(".{ext}"))
        .tempfile()
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("tempfile: {e}")))?;
    std::fs::write(tmp.path(), body)
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("write: {e}")))?;
    Ok(tmp)
}

fn err(code: StatusCode, message: &str) -> Response {
    (code, Json(json!({ "error": message, "code": code.as_u16() }))).into_response()
}

// ─── PCB operations ───────────────────────────────────────────────────

async fn pcb_drc(body: Bytes) -> Response {
    let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    let out_tmp = match NamedTempFile::with_suffix(".json") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };
    if let Err((c, m)) = run_kicad(&[
        "pcb", "drc",
        pcb.path().to_str().unwrap_or_default(),
        "-o", out_tmp.path().to_str().unwrap_or_default(),
        "--format", "json",
    ]) { return err(c, &m); }
    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], bytes).into_response()
}

#[derive(Deserialize)]
struct SvgQuery { side: Option<String>, layers: Option<String> }

/// Extract repeated `?D=name=value` pairs from a raw query string.
///
/// We can't use `Query<Vec<String>>` because axum's default Query
/// extractor uses serde_urlencoded, which collapses repeated keys into
/// a single value (and errors when deserializing into a Vec). Parsing
/// the raw query string ourselves keeps the API ergonomic from the
/// caller side (`?D=name1=value1&D=name2=value2`) without pulling in
/// a serde_qs / serde_html_form dependency just for this.
///
/// Same validation as before: each entry must contain `=`, the name
/// must match `[A-Za-z_][A-Za-z0-9_-]*`. Bad entries are silently
/// dropped (logging would be noise on a hot endpoint).
fn parse_define_pairs(raw: &Option<String>) -> Vec<(String, String)> {
    let Some(q) = raw.as_deref() else { return Vec::new() };
    let mut out = Vec::new();
    for pair in q.split('&') {
        let Some(rest) = pair.strip_prefix("D=") else { continue };
        // URL-decode the value portion. `name=value` is the SHAPE we
        // expect AFTER decoding, so decode first then split.
        let decoded = urlencoding::decode(rest).unwrap_or(std::borrow::Cow::Borrowed(rest)).into_owned();
        let Some(eq) = decoded.find('=') else { continue };
        let name = decoded[..eq].to_string();
        let value = decoded[eq + 1..].to_string();
        if name.is_empty() { continue }
        let valid = name.chars().enumerate().all(|(i, c)|
            (i == 0 && (c.is_ascii_alphabetic() || c == '_'))
            || (i > 0 && (c.is_ascii_alphanumeric() || c == '_' || c == '-'))
        );
        if !valid { continue }
        out.push((name, value));
    }
    out
}

/// Flatten parsed pairs into the `-D name=value` arg sequence.
fn define_var_args(pairs: &[(String, String)]) -> Vec<String> {
    let mut out = Vec::with_capacity(pairs.len() * 2);
    for (name, value) in pairs {
        out.push("-D".to_string());
        out.push(format!("{name}={value}"));
    }
    out
}

/// Allowed kicad-cli pcb export flags (boolean and value-bearing).
/// These are the flags from `kicad-cli pcb export glb --help` and
/// `kicad-cli pcb export step --help` (KiCad 10). Flags already
/// hardcoded (`--subst-models`, `--no-dnp`) are excluded — passing
/// them again is harmless but pointless.
///
/// Callers pass `?F=--include-pads&F=--no-board-body` in the query
/// string. Unknown flags are silently dropped.
const ALLOWED_PCB_EXPORT_FLAGS: &[&str] = &[
    // boolean flags
    "--board-only",
    "--cut-vias-in-body",
    "--fill-all-vias",
    "--fuse-shapes",
    "--grid-origin",
    "--drill-origin",
    "--include-inner-copper",
    "--include-pads",
    "--include-silkscreen",
    "--include-soldermask",
    "--include-tracks",
    "--include-zones",
    "--no-board-body",
    "--no-components",
    "--no-extra-pad-thickness",
    "--no-optimize-step",
    "--no-unspecified",
    // value flags (passed as --flag=value or --flag value)
    "--component-filter",
    "--min-distance",
    "--net-filter",
    "--user-origin",
    "--variant",
];

/// Parse `F=<flag>` entries from the raw query string. Each flag is
/// validated against `ALLOWED_PCB_EXPORT_FLAGS`. Boolean flags are
/// returned as-is; value flags (e.g. `F=--min-distance%3D0.01`) are
/// split into flag + value and returned as two entries.
fn parse_extra_flags(raw: &Option<String>) -> Vec<String> {
    let Some(q) = raw.as_deref() else { return Vec::new() };
    let mut out = Vec::new();
    for pair in q.split('&') {
        let Some(rest) = pair.strip_prefix("F=") else { continue };
        let decoded = urlencoding::decode(rest)
            .unwrap_or(std::borrow::Cow::Borrowed(rest))
            .into_owned();
        // Split on first '=' after the flag name: "--min-distance=0.01"
        if let Some(eq) = decoded.strip_prefix("--").and_then(|s| s.find('=').map(|i| i + 2)) {
            let flag_name = &decoded[..eq];
            let flag_value = &decoded[eq + 1..];
            if ALLOWED_PCB_EXPORT_FLAGS.contains(&flag_name) {
                out.push(flag_name.to_string());
                out.push(flag_value.to_string());
            }
        } else if ALLOWED_PCB_EXPORT_FLAGS.contains(&decoded.as_str()) {
            out.push(decoded);
        }
    }
    out
}

async fn pcb_export_svg(Query(q): Query<SvgQuery>, RawQuery(raw): RawQuery, body: Bytes) -> Response {
    let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    let out_tmp = match NamedTempFile::with_suffix(".svg") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };
    // Side defaults to top; layer set matches aci-validate's default. Callers can
    // override the layer set with `?layers=F.Cu,F.Mask,...` (comma-separated KiCad
    // layer names); we allow only layer-name characters so it's safe to forward.
    let side = q.side.unwrap_or_else(|| "top".into());
    let default_layers = if side == "bottom" { "B.Cu,B.Silkscreen,Edge.Cuts" } else { "F.Cu,F.Silkscreen,Edge.Cuts" };
    let valid = |l: &str| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ',' | '_'));
    let custom = q.layers.as_deref().map(valid).unwrap_or(false);
    let layers = if custom { q.layers.as_deref().unwrap() } else { default_layers };
    let pairs = parse_define_pairs(&raw);
    let var_args = define_var_args(&pairs);
    let mut args: Vec<&str> = vec![
        "pcb", "export", "svg",
        pcb.path().to_str().unwrap_or_default(),
        "-o", out_tmp.path().to_str().unwrap_or_default(),
        "--layers", layers,
        "--mode-single",
    ];
    // a custom layer set is a board-preview request → drop the paper frame + title block
    if custom { args.push("--exclude-drawing-sheet"); }
    for s in &var_args { args.push(s); }
    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }
    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()
}

/// Altium symbol library (`.SchLib`, OLE binary) → KiCad `.kicad_sym`.
/// KiCad 10's `sym upgrade` runs every SCH_IO plugin's CanRead probe, so it
/// auto-detects and converts the Altium format — no Altium install needed.
/// The input MUST keep its `.SchLib` extension for the plugin to bite.
async fn altium_sym_upgrade(body: Bytes) -> Response {
    let lib = match write_temp(&body, "SchLib") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    // kicad-cli refuses to write to a path that already exists, so the output
    // must NOT be pre-created — hand it a fresh name inside a temp dir.
    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };
    let out_path = out_dir.path().join("out.kicad_sym");
    let args = vec![
        "sym", "upgrade", "--force",
        "-o", out_path.to_str().unwrap_or_default(),
        lib.path().to_str().unwrap_or_default(),
    ];
    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }
    let bytes = match std::fs::read(&out_path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    if bytes.is_empty() { return err(StatusCode::BAD_REQUEST, "kicad-cli produced an empty .kicad_sym (no symbol in library?)"); }
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
}

/// Altium footprint library (`.PcbLib`, OLE binary) → KiCad `.kicad_mod`.
/// `fp upgrade` writes a `.pretty` directory of `.kicad_mod` files; a single
/// part library yields one, which we return. (Multi-footprint libraries return
/// the first — chip-fetcher libraries are single-part.)
async fn altium_fp_upgrade(body: Bytes) -> Response {
    let lib = match write_temp(&body, "PcbLib") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    // -o must be a .pretty dir that does NOT exist yet — kicad-cli creates it
    // and refuses if the path already exists. Nest it inside a temp dir.
    let work = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };
    let out_pretty = work.path().join("out.pretty");
    let args = vec![
        "fp", "upgrade", "--force",
        "-o", out_pretty.to_str().unwrap_or_default(),
        lib.path().to_str().unwrap_or_default(),
    ];
    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }
    // The .pretty output dir holds one or more .kicad_mod files; pick the first.
    let mod_path = std::fs::read_dir(&out_pretty)
        .ok()
        .and_then(|rd| rd.flatten()
            .map(|e| e.path())
            .find(|p| p.extension().map(|x| x == "kicad_mod").unwrap_or(false)));
    let Some(mod_path) = mod_path else {
        return err(StatusCode::BAD_REQUEST, "kicad-cli produced no .kicad_mod (no footprint in library?)");
    };
    let bytes = match std::fs::read(&mod_path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
}

async fn pcb_export_gerbers(RawQuery(raw): RawQuery, body: Bytes) -> Response {
    let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };
    let pairs = parse_define_pairs(&raw);
    let var_args = define_var_args(&pairs);
    let mut args: Vec<&str> = vec![
        "pcb", "export", "gerbers",
        pcb.path().to_str().unwrap_or_default(),
        "-o", out_dir.path().to_str().unwrap_or_default(),
        "--layers", "F.Cu,B.Cu,F.Mask,B.Mask,F.Silkscreen,B.Silkscreen,Edge.Cuts",
    ];
    for s in &var_args { args.push(s); }
    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }
    // Drill file is a separate kicad-cli subcommand; emit Excellon
    // (PTH+NPTH merged, metric, decimal) into the same tempdir so the
    // tar-roll picks it up. Without drills, pcb-stackup downstream
    // can't punch holes through the substrate/mask/pads — which is
    // what we want for any preview render or fab pack. Failures here
    // are non-fatal: a drill-less gerber set is still useful for the
    // copper / mask / silk preview, just without holes.
    // --generate-map is a boolean flag with NO argument in kicad-cli 9 + 10.
    // Passing "false" as a value makes the CLI parse it as the INPUT_FILE
    // positional, which then errors with "Maximum number of positional
    // arguments exceeded". Default is no-map, so just omit the flag.
    let _ = run_kicad(&[
        "pcb", "export", "drill",
        pcb.path().to_str().unwrap_or_default(),
        "-o", out_dir.path().to_str().unwrap_or_default(),
        "--format", "excellon",
        "--excellon-units", "mm",
        "--excellon-zeros-format", "decimal",
    ]);
    // Tar the directory for transport.
    let tar = match NamedTempFile::with_suffix(".tar") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar tempfile: {e}")) };
    let status = Command::new("tar")
        .args(["-cf", tar.path().to_str().unwrap_or_default(), "-C", out_dir.path().to_str().unwrap_or_default(), "."])
        .status();
    match status {
        Ok(s) if s.success() => {}
        Ok(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "tar failed"),
        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),
    }
    let bytes = match std::fs::read(tar.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read tar: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/x-tar")], bytes).into_response()
}

async fn pcb_export_step(RawQuery(raw): RawQuery, body: Bytes) -> Response {
    let pairs = parse_define_pairs(&raw);
    let flags = parse_extra_flags(&raw);
    kicad_pcb_binary_export(body, "step", "model/step", &pairs, &flags)
}
async fn pcb_export_glb(RawQuery(raw): RawQuery, body: Bytes) -> Response {
    let pairs = parse_define_pairs(&raw);
    let flags = parse_extra_flags(&raw);
    let resp = kicad_pcb_binary_export(body, "glb",  "model/gltf-binary", &pairs, &flags);
    glb_stamp_provenance_response(resp).await
}
async fn pcb_export_glb_zup(RawQuery(raw): RawQuery, body: Bytes) -> Response {
    let pairs = parse_define_pairs(&raw);
    let flags = parse_extra_flags(&raw);
    let resp = kicad_pcb_binary_export(body, "glb", "model/gltf-binary", &pairs, &flags);
    let resp = glb_apply_zup(resp).await;
    glb_stamp_provenance_response(resp).await
}

/// GLB export from a *packaged* board: tar.gz body containing a `.kicad_pcb`
/// plus any sibling files it references (typically `3dmodels/*.step`). The
/// archive is extracted to a tempdir and `KIPRJMOD` is set to the directory
/// containing the `.kicad_pcb`, so model paths like
/// `${KIPRJMOD}/3dmodels/MachineContactLarge.step` resolve correctly during
/// `kicad-cli pcb export glb --subst-models`.
///
/// The flat /glb endpoint can't do this — it sees only the .kicad_pcb bytes
/// and KIPRJMOD points at a tempdir with no siblings, so any
/// project-relative model is rendered as a bare hole. This endpoint is the
/// fix for that case (driven by adom-inc/kicad-to-molecule moleculizer
/// packages).
async fn pcb_export_glb_package(RawQuery(raw): RawQuery, body: Bytes) -> Response {
    let pairs = parse_define_pairs(&raw);
    let flags = parse_extra_flags(&raw);
    let workdir = match tempfile::tempdir() {
        Ok(d) => d,
        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")),
    };
    let tar = match write_temp(&body, "tar.gz") {
        Ok(t) => t,
        Err((c, m)) => return err(c, &m),
    };
    let extract = Command::new("tar")
        .args([
            "-xzf",
            tar.path().to_str().unwrap_or_default(),
            "-C",
            workdir.path().to_str().unwrap_or_default(),
        ])
        .output();
    match extract {
        Ok(o) if o.status.success() => {}
        Ok(o) => return err(
            StatusCode::BAD_REQUEST,
            &format!("tar -xzf failed: {}", String::from_utf8_lossy(&o.stderr)),
        ),
        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),
    }

    let pcb = match find_first_with_extension(workdir.path(), "kicad_pcb") {
        Some(p) => p,
        None => return err(StatusCode::BAD_REQUEST, "no .kicad_pcb file inside archive"),
    };
    let kiprjmod = pcb.parent().map(PathBuf::from).unwrap_or_else(|| workdir.path().to_path_buf());

    let out_tmp = match NamedTempFile::with_suffix(".glb") {
        Ok(t) => t,
        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")),
    };

    let var_args = define_var_args(&pairs);
    let model_dir = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");
    let pcb_str = pcb.to_str().unwrap_or_default().to_string();
    let out_str = out_tmp.path().to_str().unwrap_or_default().to_string();
    let mut args: Vec<String> = vec![
        "pcb".into(), "export".into(), "glb".into(),
        pcb_str, "-o".into(), out_str,
        "--subst-models".into(), "--no-dnp".into(),
    ];
    for s in &var_args { args.push(s.clone()); }
    for s in &flags { args.push(s.clone()); }
    let result = Command::new(kicad_cli())
        .args(&args)
        .env("KICAD6_3DMODEL_DIR", &model_dir)
        .env("KICAD7_3DMODEL_DIR", &model_dir)
        .env("KICAD8_3DMODEL_DIR", &model_dir)
        .env("KICAD9_3DMODEL_DIR", &model_dir)
        .env("KICAD10_3DMODEL_DIR", &model_dir)
        .env("KIPRJMOD", &kiprjmod)
        .output();
    let out = match result {
        Ok(o) => o,
        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("spawn kicad-cli: {e}")),
    };
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
        return err(StatusCode::BAD_REQUEST, &stderr);
    }
    let bytes = match std::fs::read(out_tmp.path()) {
        Ok(b) => b,
        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")),
    };
    let resp = (
        StatusCode::OK,
        [(axum::http::header::CONTENT_TYPE, "model/gltf-binary")],
        bytes,
    )
        .into_response();
    glb_stamp_provenance_response(resp).await
}

async fn pcb_export_glb_package_zup(RawQuery(raw): RawQuery, body: Bytes) -> Response {
    let resp = pcb_export_glb_package(RawQuery(raw), body).await;
    glb_apply_zup(resp).await
    // zup output is already stamped: pcb_export_glb_package stamps before
    // the rotation, and both edits merge into asset.extras.adom.
}

/// Apply Y-up → Z-up rotation to a GLB response. Passes errors through.
async fn glb_apply_zup(resp: Response) -> Response {
    let (parts, body) = resp.into_parts();
    if !parts.status.is_success() {
        return Response::from_parts(parts, body);
    }
    let bytes = match axum::body::to_bytes(body, 200 * 1024 * 1024).await {
        Ok(b) => b.to_vec(),
        Err(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "failed to read glb body"),
    };
    match glb_rotate_y_to_z(bytes) {
        Ok(rotated) => (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "model/gltf-binary")], rotated).into_response(),
        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, &format!("glb rotation: {e}")),
    }
}

/// Parse a GLB, apply `edit` to its JSON chunk, and reassemble with the
/// binary chunk untouched (JSON chunk re-padded to 4-byte alignment).
fn glb_edit_json(glb: Vec<u8>, edit: impl FnOnce(&mut Value) -> Result<(), String>) -> Result<Vec<u8>, String> {
    if glb.len() < 20 { return Err("GLB too short".into()); }
    let magic = u32::from_le_bytes(glb[0..4].try_into().unwrap());
    if magic != 0x46546C67 { return Err("not a GLB".into()); }
    let json_len = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;
    let json_type = u32::from_le_bytes(glb[16..20].try_into().unwrap());
    if json_type != 0x4E4F534A { return Err("first chunk not JSON".into()); }
    let json_end = 20 + json_len;
    if glb.len() < json_end { return Err("truncated JSON chunk".into()); }
    let json_str = std::str::from_utf8(&glb[20..json_end]).map_err(|e| e.to_string())?;
    let mut doc: Value = serde_json::from_str(json_str).map_err(|e| e.to_string())?;

    edit(&mut doc)?;

    let new_json = serde_json::to_vec(&doc).map_err(|e| e.to_string())?;
    let padded_len = (new_json.len() + 3) & !3;
    let mut padded = new_json;
    padded.resize(padded_len, b' ');
    let bin_chunk = &glb[json_end..];
    let total = 12 + 8 + padded.len() + bin_chunk.len();
    let mut out = Vec::with_capacity(total);
    out.extend_from_slice(&0x46546C67u32.to_le_bytes());
    out.extend_from_slice(&2u32.to_le_bytes());
    out.extend_from_slice(&(total as u32).to_le_bytes());
    out.extend_from_slice(&(padded.len() as u32).to_le_bytes());
    out.extend_from_slice(&0x4E4F534Au32.to_le_bytes());
    out.extend_from_slice(&padded);
    out.extend_from_slice(bin_chunk);
    Ok(out)
}

/// Merge keys into `asset.extras.adom` without clobbering what other
/// edits (zup rotation, provenance stamp) already wrote there.
fn merge_extras_adom(doc: &mut Value, entries: Value) -> Result<(), String> {
    if doc.get("asset").is_none() { doc["asset"] = json!({}); }
    let asset = doc.get_mut("asset").ok_or("no asset")?;
    if !asset.get("extras").map(Value::is_object).unwrap_or(false) { asset["extras"] = json!({}); }
    let extras = asset.get_mut("extras").ok_or("no extras")?;
    if !extras.get("adom").map(Value::is_object).unwrap_or(false) { extras["adom"] = json!({}); }
    let adom = extras.get_mut("adom").and_then(|v| v.as_object_mut()).ok_or("adom extras not an object")?;
    let obj = entries.as_object().ok_or("entries not an object")?;
    for (k, v) in obj { adom.insert(k.clone(), v.clone()); }
    Ok(())
}

/// Rotate a GLB binary from Y-up (glTF default) to Z-up by wrapping
/// all scene root nodes under a new parent with +90° X rotation.
/// Merges `asset.extras.adom.zUp = true` as metadata.
fn glb_rotate_y_to_z(glb: Vec<u8>) -> Result<Vec<u8>, String> {
    glb_edit_json(glb, |doc| {
        let s = std::f64::consts::FRAC_PI_4.sin();
        let c = std::f64::consts::FRAC_PI_4.cos();

        // Read scene_idx and old_roots before taking mutable borrows
        let scene_idx = doc.get("scene").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        let old_roots = doc.get("scenes")
            .and_then(|s| s.as_array())
            .and_then(|a| a.get(scene_idx))
            .and_then(|s| s.get("nodes"))
            .and_then(|n| n.as_array())
            .cloned()
            .unwrap_or_default();

        // Push new root node
        let root_idx = doc.get("nodes").and_then(|n| n.as_array()).map(|a| a.len()).unwrap_or(0);
        doc.get_mut("nodes")
            .and_then(|n| n.as_array_mut())
            .ok_or("no nodes")?
            .push(json!({ "name": "zup_root", "rotation": [s, 0.0, 0.0, c], "children": old_roots }));

        // Rewrite scene roots
        doc.get_mut("scenes")
            .and_then(|s| s.as_array_mut())
            .and_then(|a| a.get_mut(scene_idx))
            .ok_or("scene index OOB")?
            ["nodes"] = json!([root_idx]);

        merge_extras_adom(doc, json!({"zUp": true}))
    })
}

/// Provenance marker for KiCad-native GLB output (page issue #106).
/// These exports bypass the molecule pipeline (no MP-marker anchoring,
/// gold machine-pin material, meter normalization, or footprint emit),
/// so every GLB that leaves this service is stamped in
/// `asset.extras.adom` where downstream importers — and humans with
/// `strings board.glb | grep provenance` — can catch it.
const GLB_PROVENANCE: &str = "kicad-native-glb";
const GLB_PROVENANCE_WARNING: &str =
    "Exported by kicad-cli via service-kicad, NOT the molecule pipeline: \
     no MP-marker anchoring, gold machine-pin material, meter normalization, \
     or footprint emit. For molecule use, export STEP here and convert with \
     step2glb/service-occt. See wiki.adom.inc/adom/service-kicad issue #106.";

fn glb_stamp_provenance(glb: Vec<u8>) -> Result<Vec<u8>, String> {
    glb_edit_json(glb, |doc| {
        merge_extras_adom(doc, json!({
            "provenance": GLB_PROVENANCE,
            "provenanceWarning": GLB_PROVENANCE_WARNING,
            "generator": format!("service-kicad {}", git_sha_short()),
        }))
    })
}

/// Stamp a successful GLB response body and add the X-Adom-Provenance
/// header. A stamp failure must never break the export — the endpoints
/// stay behaviorally identical for existing consumers — so on error the
/// original bytes go out with the header as the only marker.
async fn glb_stamp_provenance_response(resp: Response) -> Response {
    let (parts, body) = resp.into_parts();
    if !parts.status.is_success() {
        return Response::from_parts(parts, body);
    }
    let bytes = match axum::body::to_bytes(body, 200 * 1024 * 1024).await {
        Ok(b) => b.to_vec(),
        Err(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "failed to read glb body"),
    };
    let stamped = match glb_stamp_provenance(bytes.clone()) {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!("glb provenance stamp failed ({e}) — returning unstamped");
            bytes
        }
    };
    (
        StatusCode::OK,
        [
            (axum::http::header::CONTENT_TYPE, "model/gltf-binary"),
            (axum::http::header::HeaderName::from_static("x-adom-provenance"), GLB_PROVENANCE),
        ],
        stamped,
    )
        .into_response()
}

/// BFS the directory for the first file with the given extension.
fn find_first_with_extension(root: &std::path::Path, ext: &str) -> Option<PathBuf> {
    use std::collections::VecDeque;
    let mut queue: VecDeque<PathBuf> = VecDeque::new();
    queue.push_back(root.to_path_buf());
    while let Some(d) = queue.pop_front() {
        let entries = match std::fs::read_dir(&d) {
            Ok(e) => e,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            let p = entry.path();
            if p.is_dir() {
                queue.push_back(p);
                continue;
            }
            if p.extension().and_then(|s| s.to_str()) == Some(ext) {
                return Some(p);
            }
        }
    }
    None
}

fn kicad_pcb_binary_export(body: Bytes, kind: &str, content_type: &'static str, pairs: &[(String, String)], flags: &[String]) -> Response {
    let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    let out_tmp = match NamedTempFile::with_suffix(&format!(".{kind}")) { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };
    let var_args = define_var_args(pairs);
    let mut args: Vec<&str> = vec![
        "pcb", "export", kind,
        pcb.path().to_str().unwrap_or_default(),
        "-o", out_tmp.path().to_str().unwrap_or_default(),
        "--subst-models", "--no-dnp",
    ];
    for s in &var_args { args.push(s); }
    for s in flags { args.push(s); }
    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }
    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()
}

// ─── Schematic operations ─────────────────────────────────────────────

async fn sch_erc(body: Bytes) -> Response {
    let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    let out_tmp = match NamedTempFile::with_suffix(".json") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };
    if let Err((c, m)) = run_kicad(&[
        "sch", "erc",
        sch.path().to_str().unwrap_or_default(),
        "-o", out_tmp.path().to_str().unwrap_or_default(),
        "--format", "json",
    ]) { return err(c, &m); }
    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], bytes).into_response()
}

async fn sch_export_svg(body: Bytes) -> Response { kicad_sch_file_export(body, "svg", "image/svg+xml") }
async fn sch_export_pdf(body: Bytes) -> Response { kicad_sch_file_export(body, "pdf", "application/pdf") }

fn kicad_sch_file_export(body: Bytes, kind: &str, content_type: &'static str) -> Response {
    let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };
    if let Err((c, m)) = run_kicad(&[
        "sch", "export", kind,
        sch.path().to_str().unwrap_or_default(),
        "-o", out_dir.path().to_str().unwrap_or_default(),
    ]) { return err(c, &m); }
    // Pick the first emitted file matching the extension.
    let found = std::fs::read_dir(out_dir.path()).ok()
        .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some(kind))
            .map(|e| e.path()));
    let path = match found { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "no file produced") };
    let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()
}

/// Render a whole hierarchical schematic. The body is a `.tar` of all the
/// project's `.kicad_sch` (+ optional `.kicad_pro`) files, preserving filenames
/// so kicad-cli can resolve the sheet hierarchy (a single-file render can't).
/// Returns a `.tar` of one SVG per sheet.
async fn sch_export_svg_project(body: Bytes) -> Response {
    let workdir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };
    let src = workdir.path().join("src");
    if let Err(e) = std::fs::create_dir_all(&src) { return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("mkdir: {e}")); }
    let tar = match write_temp(&body, "tar") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    match Command::new("tar").args(["-xf", tar.path().to_str().unwrap_or_default(), "-C", src.to_str().unwrap_or_default()]).output() {
        Ok(o) if o.status.success() => {}
        Ok(o) => return err(StatusCode::BAD_REQUEST, &format!("tar -xf failed: {}", String::from_utf8_lossy(&o.stderr))),
        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),
    }
    let entries: Vec<PathBuf> = std::fs::read_dir(&src).ok().map(|d| d.flatten().map(|e| e.path()).collect()).unwrap_or_default();
    let is_ext = |p: &PathBuf, e: &str| p.extension().and_then(|s| s.to_str()) == Some(e);
    // root = the .kicad_sch whose stem matches the .kicad_pro, else the first sheet
    let pro_stem = entries.iter().find(|p| is_ext(p, "kicad_pro")).and_then(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from));
    let root = pro_stem.as_ref()
        .and_then(|st| entries.iter().find(|p| is_ext(p, "kicad_sch") && p.file_stem().and_then(|s| s.to_str()) == Some(st.as_str())).cloned())
        .or_else(|| entries.iter().find(|p| is_ext(p, "kicad_sch")).cloned());
    let root = match root { Some(r) => r, None => return err(StatusCode::BAD_REQUEST, "no .kicad_sch inside archive") };
    let out = workdir.path().join("out");
    if let Err(e) = std::fs::create_dir_all(&out) { return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("mkdir: {e}")); }
    if let Err((c, m)) = run_kicad(&["sch", "export", "svg", root.to_str().unwrap_or_default(), "-o", out.to_str().unwrap_or_default()]) { return err(c, &m); }
    let out_tar = match NamedTempFile::with_suffix(".tar") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar tempfile: {e}")) };
    match Command::new("tar").args(["-cf", out_tar.path().to_str().unwrap_or_default(), "-C", out.to_str().unwrap_or_default(), "."]).status() {
        Ok(s) if s.success() => {}
        _ => return err(StatusCode::INTERNAL_SERVER_ERROR, "tar output failed"),
    }
    let bytes = match std::fs::read(out_tar.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/x-tar")], bytes).into_response()
}

async fn sch_export_bom(body: Bytes) -> Response {
    let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };
    let out_tmp = match NamedTempFile::with_suffix(".csv") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };
    if let Err((c, m)) = run_kicad(&[
        "sch", "export", "bom",
        sch.path().to_str().unwrap_or_default(),
        "-o", out_tmp.path().to_str().unwrap_or_default(),
    ]) { return err(c, &m); }
    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/csv")], bytes).into_response()
}

// ─── Library lookup — by library+name convenience wrappers ────────────

async fn sym_export_svg(AxumPath((library, name)): AxumPath<(String, String)>) -> Response {
    let symbols_dir = dir_or_default("KICAD_SYMBOLS_DIR", "/usr/share/kicad/symbols");
    let src = PathBuf::from(&symbols_dir).join(format!("{library}.kicad_sym"));
    if !src.exists() { return err(StatusCode::NOT_FOUND, &format!("symbol library {library} not installed")); }
    // `kicad-cli sym export svg -o` wants a DIRECTORY on both v9 and v10
    // (help text: "Output directory"). It emits `<symbol>_unit1.svg` etc.
    // The pre-7ce0f04 form passed a NamedTempFile path here, which kicad
    // tried to create files inside — silently broken on v9 too.
    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };
    if let Err((c, m)) = run_kicad(&[
        "sym", "export", "svg",
        src.to_str().unwrap_or_default(),
        "-o", out_dir.path().to_str().unwrap_or_default(),
        "--symbol", &name,
    ]) { return err(c, &m); }
    let path = std::fs::read_dir(out_dir.path()).ok()
        .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")).map(|e| e.path()));
    let path = match path { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "kicad-cli produced no SVG") };
    let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()
}

async fn fp_export_svg(AxumPath((library, name)): AxumPath<(String, String)>) -> Response {
    let fp_dir = dir_or_default("KICAD_FOOTPRINTS_DIR", "/usr/share/kicad/footprints");
    let src = PathBuf::from(&fp_dir).join(format!("{library}.pretty"));
    if !src.exists() { return err(StatusCode::NOT_FOUND, &format!("footprint library {library} not installed")); }
    let mod_path = src.join(format!("{name}.kicad_mod"));
    if !mod_path.exists() { return err(StatusCode::NOT_FOUND, &format!("footprint {library}/{name} not installed")); }
    // `kicad-cli fp export svg` takes the .pretty DIRECTORY + `--footprint
    // <name>` on both v9 and v10 (help text: "Input directory"). The
    // pre-7ce0f04 form passed a single .kicad_mod file, which v9 also
    // rejected with "Footprint library does not exist or is not accessible".
    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };
    if let Err((c, m)) = run_kicad(&[
        "fp", "export", "svg",
        src.to_str().unwrap_or_default(),
        "-o", out_dir.path().to_str().unwrap_or_default(),
        "--footprint", &name,
    ]) { return err(c, &m); }
    let path = std::fs::read_dir(out_dir.path()).ok()
        .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")).map(|e| e.path()));
    let path = match path { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "kicad-cli produced no SVG") };
    let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };
    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()
}

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

    /// Minimal valid GLB: JSON chunk + empty BIN chunk.
    fn tiny_glb() -> Vec<u8> {
        let json = serde_json::to_vec(&json!({
            "asset": {"version": "2.0"},
            "scene": 0,
            "scenes": [{"nodes": [0]}],
            "nodes": [{"name": "board"}],
        })).unwrap();
        let padded_len = (json.len() + 3) & !3;
        let mut padded = json;
        padded.resize(padded_len, b' ');
        let total = 12 + 8 + padded.len();
        let mut glb = Vec::with_capacity(total);
        glb.extend_from_slice(&0x46546C67u32.to_le_bytes());
        glb.extend_from_slice(&2u32.to_le_bytes());
        glb.extend_from_slice(&(total as u32).to_le_bytes());
        glb.extend_from_slice(&(padded.len() as u32).to_le_bytes());
        glb.extend_from_slice(&0x4E4F534Au32.to_le_bytes());
        glb.extend_from_slice(&padded);
        glb
    }

    fn json_chunk(glb: &[u8]) -> Value {
        let json_len = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;
        serde_json::from_slice(&glb[20..20 + json_len]).unwrap()
    }

    #[test]
    fn stamp_sets_provenance() {
        let stamped = glb_stamp_provenance(tiny_glb()).unwrap();
        let doc = json_chunk(&stamped);
        let adom = &doc["asset"]["extras"]["adom"];
        assert_eq!(adom["provenance"], GLB_PROVENANCE);
        assert!(adom["provenanceWarning"].as_str().unwrap().contains("molecule pipeline"));
        assert!(adom["generator"].as_str().unwrap().starts_with("service-kicad "));
    }

    #[test]
    fn zup_then_stamp_coexist() {
        let rotated = glb_rotate_y_to_z(tiny_glb()).unwrap();
        let stamped = glb_stamp_provenance(rotated).unwrap();
        let doc = json_chunk(&stamped);
        let adom = &doc["asset"]["extras"]["adom"];
        assert_eq!(adom["zUp"], true);
        assert_eq!(adom["provenance"], GLB_PROVENANCE);
        // rotation actually applied
        assert_eq!(doc["nodes"].as_array().unwrap().len(), 2);
        assert_eq!(doc["scenes"][0]["nodes"], json!([1]));
    }

    #[test]
    fn stamp_then_zup_coexist() {
        let stamped = glb_stamp_provenance(tiny_glb()).unwrap();
        let rotated = glb_rotate_y_to_z(stamped).unwrap();
        let adom = &json_chunk(&rotated)["asset"]["extras"]["adom"];
        assert_eq!(adom["provenance"], GLB_PROVENANCE);
        assert_eq!(adom["zUp"], true);
    }

    #[test]
    fn stamp_rejects_non_glb() {
        assert!(glb_stamp_provenance(b"not a glb at all".to_vec()).is_err());
    }

    #[test]
    fn json_chunk_stays_aligned() {
        let stamped = glb_stamp_provenance(tiny_glb()).unwrap();
        let json_len = u32::from_le_bytes(stamped[12..16].try_into().unwrap()) as usize;
        assert_eq!(json_len % 4, 0);
        let total = u32::from_le_bytes(stamped[8..12].try_into().unwrap()) as usize;
        assert_eq!(total, stamped.len());
    }
}