123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314
pub mod cache;
pub mod convert;

use anyhow::Result;
use rust_embed::RustEmbed;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tiny_http::{Header, Method, Request, Response, StatusCode};

// Inherited from adom-step via git submodule at vendor/adom-step/.
// adom-step itself inherits the Babylon9 viewer bundle from the
// canonical adom-3d-viewer-babylon9 repo (its own submodule), so this
// chipsmith binary picks up the entire stack transitively. Workflow:
//   git submodule update --remote --recursive
//   cargo build --release
// is the full upstream-inheritance loop.
//
// chipsmith-specific UI (8 tabs, detection pipeline, sign-off, etc.)
// will live in src/assets/chipsmith-overlay.{js,css} (separate files)
// served via dedicated routes — never touches the inherited shell.html.
#[derive(RustEmbed)]
#[folder = "vendor/adom-step/vendor/3d-viewer-dist/"]
struct ViewerAssets;

const SHELL_HTML: &[u8] = include_bytes!("../../vendor/adom-step/src/assets/shell.html");
const ADOM_CSS: &[u8]   = include_bytes!("../../vendor/adom-step/src/assets/adom.css");
const ICON_SVG: &[u8]   = include_bytes!("../assets/icon.svg");
// Chipsmith-specific overlay (branding + 8 tabs + detection + sign-off).
// Auto-injected into the inherited shell.html via <script> tag before
// </body>. Source-of-truth lives in src/assets/chipsmith-overlay.js;
// shell.html itself is read-only (it's the adom-step submodule).
const CHIPSMITH_OVERLAY_JS: &[u8] = include_bytes!("../assets/chipsmith-overlay.js");
// Vendored earcut.min.js (Mapbox earcut v2.2.4, MIT) — exposes
// window.earcut. Required by Babylon's MeshBuilder.CreatePolygon /
// PolygonMeshBuilder for the ds2sf SVG-to-vector-polygon renderer.
// Loaded BEFORE chipsmith-overlay.js so it's globally available when
// renderDs2sfOverlay() runs.
const EARCUT_MIN_JS: &[u8] = include_bytes!("../assets/earcut.min.js");

pub struct ServerConfig {
    pub port: u16,
    pub initial_glb_path: Option<PathBuf>,
    pub initial_source_label: Option<String>,
    /// Provenance: "kicad-library" | "local-file" | "upload" | "unknown".
    /// Drives the UI's "From KiCad library / Local file / Upload" badge so
    /// the user always knows where the geometry came from.
    pub initial_source_type: Option<String>,
}

#[derive(Clone, Debug)]
pub struct ConsoleMessage {
    pub seq: u64,
    pub ts_ms: u64,
    pub level: String,
    pub text: String,
}

#[derive(Clone, Debug)]
pub struct EvalJob {
    pub id: String,
    pub code: String,
    pub result: Option<serde_json::Value>,
    pub done: bool,
    pub taken: bool,
}

pub struct ServerState {
    pub config: ServerConfig,
    pub current_glb_path: Mutex<Option<PathBuf>>,
    pub current_source_label: Mutex<Option<String>>,
    pub current_source_type: Mutex<Option<String>>,
    pub current_materials: Mutex<serde_json::Value>,
    /// Last components tree the UI posted to /api/components.
    pub components: Mutex<serde_json::Value>,
    /// Per-node visibility map {nodeName: bool}.
    pub component_visibility: Mutex<serde_json::Value>,
    /// Active section planes — list of {axis, offset, enabled, flipped}.
    pub sections: Mutex<serde_json::Value>,
    /// Last selection posted by the UI (for `adom-chipsmith inspect`-like queries).
    pub selection: Mutex<serde_json::Value>,
    /// Pending command bus — UI polls /api/poll-command and applies.
    pub pending_command: Mutex<Option<serde_json::Value>>,
    /// chipsmith-spec.json contents (manifest with pin_count, structural_type, body_dims_mm, etc.)
    pub spec: Mutex<serde_json::Value>,
    /// Parsed footprint (.kicad_mod) JSON — pads, layers, drill, etc.
    pub footprint: Mutex<serde_json::Value>,
    /// Multi-source footprints — keyed by source name (datasheet | kicad | fusion | altium).
    /// Each value has the same shape as `footprint` (pad list).
    pub footprint_sources: Mutex<serde_json::Value>,
    /// Where to write <mpn>.chipsmith.json on sign-off (chip-fetcher library dir).
    pub signoff_dir: Mutex<Option<PathBuf>>,
    /// Path to the source STEP file (so /api/step-meta + /bake can re-read it).
    pub source_step_path: Mutex<Option<PathBuf>>,
    /// Sign-off state ({signed, sidecar_path, ...}).
    pub signoff_state: Mutex<serde_json::Value>,
    /// JS eval pipeline.
    pub eval_jobs: Mutex<Vec<EvalJob>>,
    /// Console ring buffer (UI -> server -> CLI tail).
    pub console: Mutex<VecDeque<ConsoleMessage>>,
    pub console_seq: AtomicU64,
}

const CONSOLE_RING_CAP: usize = 2000;

impl ServerState {
    pub fn new(config: ServerConfig) -> Arc<Self> {
        let initial_path = config.initial_glb_path.clone();
        let initial_label = config.initial_source_label.clone();
        let initial_type = config.initial_source_type.clone();
        Arc::new(Self {
            config,
            current_glb_path: Mutex::new(initial_path),
            current_source_label: Mutex::new(initial_label),
            current_source_type: Mutex::new(initial_type),
            current_materials: Mutex::new(serde_json::json!([])),
            components: Mutex::new(serde_json::json!([])),
            component_visibility: Mutex::new(serde_json::json!({})),
            sections: Mutex::new(serde_json::json!([])),
            selection: Mutex::new(serde_json::json!(null)),
            pending_command: Mutex::new(None),
            spec: Mutex::new(serde_json::json!(null)),
            footprint: Mutex::new(serde_json::json!(null)),
            footprint_sources: Mutex::new(serde_json::json!({})),
            signoff_dir: Mutex::new(None),
            source_step_path: Mutex::new(None),
            signoff_state: Mutex::new(serde_json::json!({"signed": false})),
            eval_jobs: Mutex::new(Vec::new()),
            console: Mutex::new(VecDeque::with_capacity(CONSOLE_RING_CAP)),
            console_seq: AtomicU64::new(0),
        })
    }

    pub fn push_console(&self, level: &str, text: &str) {
        let ts_ms = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);
        let seq = self.console_seq.fetch_add(1, Ordering::SeqCst);
        let mut buf = self.console.lock().unwrap();
        if buf.len() >= CONSOLE_RING_CAP {
            buf.pop_front();
        }
        buf.push_back(ConsoleMessage {
            seq,
            ts_ms,
            level: level.to_string(),
            text: text.to_string(),
        });
    }
}

pub fn run(state: Arc<ServerState>) -> Result<()> {
    // Bind 0.0.0.0 so the cloudflared tunnel (which routes through the
    // container's bridge IP, not host loopback) can reach us. Adom's
    // public proxy at <prefix>-<slug>.adom.cloud → port 8872 fails on a
    // 127.0.0.1-only bind; gallery-style services bind 0.0.0.0 for the
    // same reason. Local CLI calls hit 127.0.0.1 transparently because
    // the OS routes loopback to any-interface listeners.
    let addr = format!("0.0.0.0:{}", state.config.port);
    let server = tiny_http::Server::http(&addr)
        .map_err(|e| anyhow::anyhow!("failed to bind {}: {}", addr, e))?;

    eprintln!("adom-chipsmith listening on http://{}", addr);

    for request in server.incoming_requests() {
        let state = state.clone();
        std::thread::spawn(move || {
            if let Err(e) = route(&state, request) {
                eprintln!("request error: {e:#}");
            }
        });
    }
    Ok(())
}

fn url_decode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let bytes = s.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'+' { out.push(' '); i += 1; }
        else if bytes[i] == b'%' && i + 2 < bytes.len() {
            let hh = std::str::from_utf8(&bytes[i+1..i+3]).unwrap_or("00");
            let v = u8::from_str_radix(hh, 16).unwrap_or(0);
            out.push(v as char); i += 3;
        }
        else { out.push(bytes[i] as char); i += 1; }
    }
    out
}

fn chrono_iso8601_now() -> String {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64).unwrap_or(0);
    // Manual ISO-8601 — avoid pulling chrono crate just for this.
    let (y, m, d, hh, mm, ss) = epoch_to_ymdhms(secs);
    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, hh, mm, ss)
}

fn epoch_to_ymdhms(epoch: i64) -> (i32, u32, u32, u32, u32, u32) {
    let days_since_epoch = epoch / 86400;
    let secs = epoch.rem_euclid(86400) as u32;
    let hh = secs / 3600;
    let mm = (secs / 60) % 60;
    let ss = secs % 60;
    // Days since 1970-01-01
    let mut y = 1970;
    let mut d = days_since_epoch as i32;
    loop {
        let leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
        let days_in_year = if leap { 366 } else { 365 };
        if d < days_in_year { break; }
        d -= days_in_year;
        y += 1;
    }
    let leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
    let mlens = if leap {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };
    let mut month = 0u32;
    let mut day = d as u32;
    for (i, &n) in mlens.iter().enumerate() {
        if day < n { month = i as u32 + 1; break; }
        day -= n;
    }
    (y, month, day + 1, hh, mm, ss)
}

// Cloudflare-compliant anti-cache headers (per gallia/skills/adom guides).
const CACHE_CONTROL_VAL: &str = "no-store, no-cache, must-revalidate, max-age=0";
const PRAGMA_VAL: &str = "no-cache";
const SURROGATE_VAL: &str = "no-store";

fn no_cache<R: std::io::Read>(resp: Response<R>) -> Response<R> {
    resp.with_header(Header::from_bytes(&b"Cache-Control"[..], CACHE_CONTROL_VAL.as_bytes()).unwrap())
        .with_header(Header::from_bytes(&b"Pragma"[..], PRAGMA_VAL.as_bytes()).unwrap())
        .with_header(Header::from_bytes(&b"Surrogate-Control"[..], SURROGATE_VAL.as_bytes()).unwrap())
}

fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
    let bytes = serde_json::to_vec(body).unwrap_or_default();
    let resp = Response::from_data(bytes)
        .with_status_code(StatusCode(status))
        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());
    no_cache(resp)
}

fn text_response(status: u16, mime: &str, body: Vec<u8>) -> Response<std::io::Cursor<Vec<u8>>> {
    let resp = Response::from_data(body)
        .with_status_code(StatusCode(status))
        .with_header(Header::from_bytes(&b"Content-Type"[..], mime.as_bytes()).unwrap());
    no_cache(resp)
}

fn read_body(req: &mut Request) -> Vec<u8> {
    let mut buf = Vec::new();
    let _ = req.as_reader().read_to_end(&mut buf);
    buf
}

fn parse_json(req: &mut Request) -> Option<serde_json::Value> {
    let bytes = read_body(req);
    serde_json::from_slice(&bytes).ok()
}

fn next_eval_id(state: &ServerState) -> String {
    let seq = state.console_seq.fetch_add(1, Ordering::SeqCst);
    let ts = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    format!("e_{ts}_{seq}")
}

fn route(state: &Arc<ServerState>, mut request: Request) -> Result<()> {
    let url = request.url().to_string();
    // Strip query string for routing — handlers re-parse from request.url() if needed.
    let path = url.split('?').next().unwrap_or(&url).to_string();
    let method = request.method().clone();

    // ---- Health & state ---------------------------------------------------

    if path == "/health" {
        let body = serde_json::json!({
            "ok": true,
            "service": "adom-chipsmith",
            "port": state.config.port,
        });
        return Ok(request.respond(json_response(200, &body))?);
    }

    if path == "/state" {
        let body = serde_json::json!({
            "source": *state.current_source_label.lock().unwrap(),
            "source_type": *state.current_source_type.lock().unwrap(),
            "has_model": state.current_glb_path.lock().unwrap().is_some(),
            "materials": *state.current_materials.lock().unwrap(),
            "components": *state.components.lock().unwrap(),
            "visibility": *state.component_visibility.lock().unwrap(),
            "sections": *state.sections.lock().unwrap(),
        });
        return Ok(request.respond(json_response(200, &body))?);
    }

    if path == "/shutdown" && method == Method::Post {
        let _ = request.respond(json_response(200, &serde_json::json!({"ok": true})));
        std::process::exit(0);
    }

    // ---- Static shell -----------------------------------------------------

    if path == "/" || path == "/index.html" {
        // Inject the chipsmith overlay <script> tag before </body> of the
        // inherited shell.html. The shell.html itself is read-only (it
        // ships from the adom-step submodule); overlay code lives in
        // chipsmith-overlay.js where chipsmith-specific behaviors stack.
        let html = String::from_utf8_lossy(SHELL_HTML);
        // Relative path (NOT leading-slash) so the script resolves
        // correctly under the Hydrogen proxy URL (e.g.
        // /proxy/8872/chipsmith-overlay.js, not /chipsmith-overlay.js
        // at the domain root).
        let injected = html.replace(
            "</body>",
            "<script src=\"earcut.min.js\"></script>\n<script src=\"chipsmith-overlay.js\" defer></script>\n</body>",
        );
        return Ok(request.respond(text_response(200, "text/html; charset=utf-8", injected.into_bytes()))?);
    }
    if path == "/chipsmith-overlay.js" {
        return Ok(request.respond(text_response(200, "application/javascript; charset=utf-8", CHIPSMITH_OVERLAY_JS.to_vec()))?);
    }
    if path == "/earcut.min.js" {
        return Ok(request.respond(text_response(200, "application/javascript; charset=utf-8", EARCUT_MIN_JS.to_vec()))?);
    }
    if path == "/adom.css" {
        return Ok(request.respond(text_response(200, "text/css; charset=utf-8", ADOM_CSS.to_vec()))?);
    }
    if path == "/favicon.svg" || path == "/favicon.ico" {
        return Ok(request.respond(text_response(200, "image/svg+xml", ICON_SVG.to_vec()))?);
    }

    // Vendored Babylon9 ESM bundle and chunks: /viewer/<filename>
    if let Some(asset) = path.strip_prefix("/viewer/") {
        if let Some(file) = ViewerAssets::get(asset) {
            let mime = if asset.ends_with(".js") {
                "application/javascript; charset=utf-8"
            } else if asset.ends_with(".css") {
                "text/css; charset=utf-8"
            } else if asset.ends_with(".env") {
                "application/octet-stream"
            } else {
                "application/octet-stream"
            };
            return Ok(request.respond(text_response(200, mime, file.data.into_owned()))?);
        }
        return Ok(request.respond(json_response(404, &serde_json::json!({"error": "not found"})))?);
    }

    // ---- Model serving ----------------------------------------------------

    if path == "/model.glb" {
        let glb_path = state.current_glb_path.lock().unwrap().clone();
        match glb_path {
            Some(p) => match std::fs::read(&p) {
                Ok(bytes) => {
                    let resp = Response::from_data(bytes)
                        .with_status_code(StatusCode(200))
                        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"model/gltf-binary"[..]).unwrap());
                    return Ok(request.respond(no_cache(resp))?);
                }
                Err(e) => {
                    return Ok(request.respond(json_response(
                        500,
                        &serde_json::json!({"error": format!("read {}: {e}", p.display())}),
                    ))?);
                }
            },
            None => {
                return Ok(request.respond(json_response(
                    404,
                    &serde_json::json!({"error": "no model loaded — POST a STEP file to /api/load or drop one onto the canvas"}),
                ))?);
            }
        }
    }

    // ---- STEP upload (drop zone + AI) ------------------------------------

    if path == "/api/load" && method == Method::Post {
        let bytes = read_body(&mut request);
        if bytes.is_empty() {
            return Ok(request.respond(json_response(
                400,
                &serde_json::json!({"error": "empty body"}),
            ))?);
        }
        // Filename hint via X-Filename header (for the source label in HUD).
        let filename = request
            .headers()
            .iter()
            .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case("X-Filename"))
            .map(|h| h.value.as_str().to_string())
            .unwrap_or_else(|| "uploaded.step".to_string());

        match convert::convert_step_bytes(&bytes) {
            Ok(result) => {
                *state.current_glb_path.lock().unwrap() = Some(result.glb_path.clone());
                *state.current_source_label.lock().unwrap() = Some(filename.clone());
                *state.current_source_type.lock().unwrap() = Some("upload".to_string());
                *state.current_materials.lock().unwrap() = result.materials.clone();
                let body = serde_json::json!({
                    "ok": true,
                    "source": filename,
                    "glb_size_bytes": result.size_bytes,
                    "meshes": result.meshes,
                    "nodes": result.nodes,
                    "duration_ms": result.duration_ms,
                    "materials": result.materials,
                    "cache_hit": result.cache_hit,
                });
                return Ok(request.respond(json_response(200, &body))?);
            }
            Err(e) => {
                return Ok(request.respond(json_response(
                    500,
                    &serde_json::json!({"error": e.to_string()}),
                ))?);
            }
        }
    }

    // ---- Components tree (UI <-> server) ---------------------------------

    if path == "/api/components" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            *state.components.lock().unwrap() = v;
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }
    if path == "/api/components" && method == Method::Get {
        let v = state.components.lock().unwrap().clone();
        return Ok(request.respond(json_response(200, &v))?);
    }

    // ---- chipsmith-spec.json (manifest) ---------------------------------

    if path == "/api/spec" && method == Method::Get {
        let v = state.spec.lock().unwrap().clone();
        return Ok(request.respond(json_response(200, &v))?);
    }

    // ---- ds2sf v0.5.1 authoritative footprint extraction ----------------
    // Returns the contents of <MPN>-extraction.result.json so the UI can
    // surface the canonical 2D ground-truth SVG (pixel-trustworthy when
    // sourced from service-kicad-stdlib, layout-only when synthesized).
    if path == "/api/ds2sf" && method == Method::Get {
        let dir = state.signoff_dir.lock().unwrap().clone();
        let mpn = state.current_source_label.lock().unwrap().clone()
            .map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
            .unwrap_or_default();
        let mut out = serde_json::json!({"available": false});
        if let Some(d) = &dir {
            let result_path = d.join(format!("{mpn}-extraction.result.json"));
            if result_path.exists() {
                if let Ok(s) = std::fs::read_to_string(&result_path) {
                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
                        out = v;
                        out["available"] = serde_json::Value::Bool(true);
                    }
                }
            }
        }
        return Ok(request.respond(json_response(200, &out))?);
    }

    // ds2sf v0.6.0 per-pad position data. Returns the pads[] array from
    // <MPN>-footprint.extracted.json in the same schema chipsmith uses
    // for KiCad/Fusion/Altium: [{number, type, shape, at, size, layers, drill}].
    // This is the 5th independent cross-validation source.
    if path == "/api/ds2sf-pads" && method == Method::Get {
        let dir = state.signoff_dir.lock().unwrap().clone();
        let mpn = state.current_source_label.lock().unwrap().clone()
            .map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
            .unwrap_or_default();
        if let Some(d) = &dir {
            let fp_path = d.join(format!("{mpn}-footprint.extracted.json"));
            if fp_path.exists() {
                if let Ok(s) = std::fs::read_to_string(&fp_path) {
                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
                        if let Some(pads) = v.get("pads") {
                            let pad_count = pads.as_array().map(|a| a.len()).unwrap_or(0);
                            let source = v.get("package_outline_source")
                                .or_else(|| v.get("kicad_baseline").and_then(|b| b.get("source")))
                                .and_then(|v| v.as_str())
                                .unwrap_or("unknown");
                            return Ok(request.respond(json_response(200, &serde_json::json!({
                                "pads": pads,
                                "pad_count": pad_count,
                                "source": source,
                                "mpn": mpn,
                            })))?);
                        }
                    }
                }
            }
        }
        return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no ds2sf pads for this chip (needs ds2sf v0.6.0+)"})))?);
    }

    // ---- Generate Adom 3D chip / footprint via local OCCT ----------------
    // Calls create_chip.py / create_footprint.py locally (via the step2glb
    // venv), converts the resulting STEP to GLB via service-step2glb /convert,
    // and serves the GLB so the frontend can load it as a new outline branch.
    // Cached at /tmp/adom-chipsmith-cache/<mpn>-generated-{chip,fp}.glb.
    if (path == "/api/generate-chip-glb" || path == "/api/generate-footprint-glb") && method == Method::Get {
        let is_chip = path.contains("chip");
        let dir = state.signoff_dir.lock().unwrap().clone();
        let mpn = state.current_source_label.lock().unwrap().clone()
            .map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
            .unwrap_or_default();
        let cache_dir = PathBuf::from("/tmp/adom-chipsmith-cache");
        let tag = if is_chip { "chip" } else { "fp" };
        let glb_cache = cache_dir.join(format!("{mpn}-generated-{tag}.glb"));

        if glb_cache.exists() {
            let bytes = std::fs::read(&glb_cache).unwrap_or_default();
            let response = tiny_http::Response::from_data(bytes)
                .with_header("Content-Type: model/gltf-binary".parse::<tiny_http::Header>().unwrap())
                .with_header("Cache-Control: no-cache".parse::<tiny_http::Header>().unwrap());
            return Ok(request.respond(response)?);
        }

        // Need to generate. Read the ds2sf data.
        let fp_path = dir.as_ref().map(|d| d.join(format!("{mpn}-footprint.extracted.json")));
        let fp_json: Option<serde_json::Value> = fp_path.and_then(|p| {
            std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok())
        });

        // Read info.json for document_metadata (mpn, manufacturer, etc.)
        let info_path = dir.as_ref().map(|d| d.join("info.json"));
        let info_json: Option<serde_json::Value> = info_path.and_then(|p| {
            std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok())
        });
        let doc_meta = serde_json::json!({
            "mpn": info_json.as_ref().and_then(|v| v.get("mpn")).cloned().unwrap_or(serde_json::json!(mpn)),
            "manufacturer": info_json.as_ref().and_then(|v| v.get("manufacturer")).cloned().unwrap_or_default(),
            "package": info_json.as_ref().and_then(|v| v.get("package")).cloned().unwrap_or_default(),
            "datasheet_url": info_json.as_ref().and_then(|v| v.get("datasheet_url")).cloned().unwrap_or_default(),
        });

        let config_json = if is_chip {
            fp_json.as_ref().and_then(|fj| fj.get("chip_3d")).cloned().map(|mut c| {
                if let Some(pads) = fp_json.as_ref().and_then(|fj| fj.get("pads")).and_then(|p| p.as_array()) {
                    let annots: Vec<serde_json::Value> = pads.iter().map(|p| {
                        let x = p.get("at").and_then(|a| a.get(0)).and_then(|v| v.as_f64()).unwrap_or(0.0);
                        serde_json::json!({
                            "number": p.get("number").cloned().unwrap_or_default(),
                            "name": p.get("name").cloned().unwrap_or_default(),
                            "function": p.get("function").cloned().unwrap_or_default(),
                            "side": if x > 0.0 { "right" } else { "left" },
                        })
                    }).collect();
                    c["lead_annotations"] = serde_json::json!(annots);
                }
                c["document_metadata"] = doc_meta.clone();
                c
            })
        } else {
            fp_json.as_ref().map(|v| {
                serde_json::json!({
                    "pads": v.get("pads").cloned().unwrap_or(serde_json::json!([])),
                    "copper_thickness_mm": 0.035,
                    "board_thickness_mm": 1.6,
                    "document_metadata": doc_meta.clone(),
                })
            })
        };

        if config_json.is_none() {
            return Ok(request.respond(json_response(404, &serde_json::json!({
                "error": format!("no {} data in ds2sf extraction (needs ds2sf v0.8.0+)", if is_chip { "chip_3d" } else { "pads" })
            })))?);
        }

        // Call the live service endpoints (v0.9.0) instead of local OCCT.
        // Generate STEP via /create-chip or /create-footprint, then convert
        // to GLB via /convert. Both go through the same service URL.
        let service_url = crate::cli::step2glb_service_url();
        let endpoint = if is_chip { "create-chip" } else { "create-footprint" };
        let config_str = serde_json::to_string(&config_json.unwrap()).unwrap_or_default();

        // Step 1: generate STEP
        let step_result = ureq::post(&format!("{service_url}/{endpoint}"))
            .set("Content-Type", "application/json")
            .send_string(&config_str);
        let step_bytes = match step_result {
            Ok(resp) => {
                let mut buf = Vec::new();
                let _ = resp.into_reader().read_to_end(&mut buf);
                buf
            }
            Err(e) => {
                return Ok(request.respond(json_response(502, &serde_json::json!({
                    "error": format!("/{endpoint} failed: {e}")
                })))?);
            }
        };
        if step_bytes.is_empty() {
            return Ok(request.respond(json_response(502, &serde_json::json!({
                "error": format!("/{endpoint} returned empty response")
            })))?);
        }

        // Save STEP for sign-off compositing + config JSON for annotation readback
        // (the chipsmith_metadata product doesn't survive STEP→GLB conversion).
        let step_path = cache_dir.join(format!("{mpn}-generated-{tag}.step"));
        let annot_path = cache_dir.join(format!("{mpn}-generated-{tag}-annotations.json"));
        let _ = std::fs::create_dir_all(&cache_dir);
        let _ = std::fs::write(&step_path, &step_bytes);
        let _ = std::fs::write(&annot_path, &config_str);

        // Step 2: convert STEP → GLB
        match ureq::post(&format!("{service_url}/convert"))
            .set("Content-Type", "application/step")
            .send_bytes(&step_bytes)
        {
            Ok(resp) => {
                let mut glb_bytes = Vec::new();
                let _ = resp.into_reader().read_to_end(&mut glb_bytes);
                let _ = std::fs::write(&glb_cache, &glb_bytes);
                let response = tiny_http::Response::from_data(glb_bytes)
                    .with_header("Content-Type: model/gltf-binary".parse::<tiny_http::Header>().unwrap());
                return Ok(request.respond(response)?);
            }
            Err(e) => {
                return Ok(request.respond(json_response(502, &serde_json::json!({
                    "error": format!("GLB conversion failed: {e}")
                })))?);
            }
        }
    }

    // Serve cached annotation JSON for the generated chip / footprint.
    // The chipsmith_metadata product doesn't survive STEP→GLB, so the
    // frontend reads annotations from this endpoint instead.
    if (path == "/api/generated-chip-annotations" || path == "/api/generated-footprint-annotations") && method == Method::Get {
        let tag = if path.contains("chip") { "chip" } else { "fp" };
        let mpn = state.current_source_label.lock().unwrap().clone()
            .map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
            .unwrap_or_default();
        let annot_path = PathBuf::from("/tmp/adom-chipsmith-cache").join(format!("{mpn}-generated-{tag}-annotations.json"));
        if annot_path.exists() {
            if let Ok(bytes) = std::fs::read(&annot_path) {
                return Ok(request.respond(text_response(200, "application/json; charset=utf-8", bytes))?);
            }
        }
        return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no cached annotations"})))?);
    }

    // ds2sf v0.8.0 chip_3d + mechanical — the 3D generation recipe and
    // JEDEC dimension provenance. Used by the "Adom 3D chip" outline
    // branch and the Measurements tab provenance links.
    if path == "/api/ds2sf-chip3d" && method == Method::Get {
        let dir = state.signoff_dir.lock().unwrap().clone();
        let mpn = state.current_source_label.lock().unwrap().clone()
            .map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
            .unwrap_or_default();
        if let Some(d) = &dir {
            let fp_path = d.join(format!("{mpn}-footprint.extracted.json"));
            if fp_path.exists() {
                if let Ok(s) = std::fs::read_to_string(&fp_path) {
                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
                        let chip_3d = v.get("chip_3d").cloned().unwrap_or(serde_json::json!(null));
                        let mechanical = v.get("mechanical").cloned().unwrap_or(serde_json::json!(null));
                        if !chip_3d.is_null() {
                            return Ok(request.respond(json_response(200, &serde_json::json!({
                                "chip_3d": chip_3d,
                                "mechanical": mechanical,
                                "mpn": mpn,
                            })))?);
                        }
                    }
                }
            }
        }
        return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no chip_3d data (needs ds2sf v0.8.0+)"})))?);
    }

    // Static-serve ds2sf SVGs. v0.8.0 produces two:
    //   /api/ds2sf-svg           → ds2sf's OWN datasheet-derived SVG (primary)
    //   /api/ds2sf-svg?which=pcbnew → pcbnew stdlib render (comparison)
    // Falls back through filename variants for compat with older ds2sf.
    if path == "/api/ds2sf-svg" && method == Method::Get {
        let dir = state.signoff_dir.lock().unwrap().clone();
        let mpn = state.current_source_label.lock().unwrap().clone()
            .map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
            .unwrap_or_default();
        let qs = request.url().split('?').nth(1).unwrap_or("");
        let want_pcbnew = qs.contains("which=pcbnew");
        if let Some(d) = &dir {
            // Try the v0.8.0 filenames first, fall back to the old name.
            let candidates = if want_pcbnew {
                vec![
                    d.join(format!("{mpn}-footprint.pcbnew.svg")),
                ]
            } else {
                vec![
                    d.join(format!("{mpn}-footprint.ds2sf.svg")),
                    d.join(format!("{mpn}-footprint.authoritative.svg")),
                ]
            };
            for svg_path in candidates {
                if svg_path.exists() {
                    if let Ok(bytes) = std::fs::read(&svg_path) {
                        let response = tiny_http::Response::from_data(bytes)
                            .with_header("Content-Type: image/svg+xml".parse::<tiny_http::Header>().unwrap())
                            .with_header("Cache-Control: no-cache".parse::<tiny_http::Header>().unwrap());
                        return Ok(request.respond(response)?);
                    }
                }
            }
        }
        return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no ds2sf SVG for this chip"})))?);
    }

    // ---- Local file paths (for "📂 reveal" buttons in the UI) ----------
    // Returns absolute paths to every artifact chipsmith knows about,
    // so the UI can render reveal-in-explorer buttons next to each.
    if path == "/api/paths" && method == Method::Get {
        let dir = state.signoff_dir.lock().unwrap().clone();
        let step_path = state.source_step_path.lock().unwrap().clone();
        let mpn = state.current_source_label.lock().unwrap().clone()
            .map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
            .unwrap_or_default();
        let mut out = serde_json::json!({});
        if let Some(d) = &dir {
            let try_paths: Vec<(&str, std::path::PathBuf)> = vec![
                ("step",      d.join(format!("{mpn}.step"))),
                ("step_alt",  d.join(format!("{mpn}.STEP"))),
                ("kicad_mod", d.join(format!("{mpn}.kicad_mod"))),
                ("kicad_sym", d.join(format!("{mpn}.kicad_sym"))),
                ("eagle_lbr", d.join(format!("{mpn}.lbr"))),
                ("altium_pcblib", d.join(format!("{mpn}.PcbLib"))),
                ("altium_intlib", d.join(format!("{mpn}.IntLib"))),
                ("datasheet", d.join(format!("{mpn}.pdf"))),
                ("info_json", d.join("info.json")),
                ("chipsmith_step", d.join(format!("{mpn}.chipsmith.step"))),
                ("chipsmith_glb",  d.join(format!("{mpn}.chipsmith.glb"))),
                ("chipsmith_json", d.join(format!("{mpn}.chipsmith.json"))),
                // ds2sf v0.5.1 — authoritative top-down footprint SVG and
                // its extraction result.json (the latter carries the
                // outputs.footprint_svg_source flag: "service-kicad-stdlib"
                // = pixel-trustworthy, "ds2sf-synthesized" = layout-only).
                ("ds2sf_result",   d.join(format!("{mpn}-extraction.result.json"))),
                ("ds2sf_svg",      d.join(format!("{mpn}-footprint.authoritative.svg"))),
            ];
            for (key, path) in try_paths {
                if path.exists() {
                    out[key] = serde_json::Value::String(path.to_string_lossy().into_owned());
                }
            }
            out["dir"] = serde_json::Value::String(d.to_string_lossy().into_owned());
        }
        if let Some(p) = &step_path {
            // Override with the actual loaded STEP path (may differ from dir/mpn.step
            // when the user opened a file directly via `view`).
            out["step"] = serde_json::Value::String(p.to_string_lossy().into_owned());
        }
        return Ok(request.respond(json_response(200, &out))?);
    }

    // ---- Reveal a path in VS Code Explorer (delegates to adom-vscode) --
    if path == "/api/reveal" && method == Method::Post {
        let body = parse_json(&mut request).unwrap_or_default();
        let path_arg = body.get("path").and_then(|v| v.as_str()).unwrap_or("");
        if path_arg.is_empty() {
            return Ok(request.respond(json_response(400, &serde_json::json!({"error": "path required"})))?);
        }
        // Hard-fail any non-existent path to avoid leaking accidental injection.
        if !std::path::Path::new(path_arg).exists() {
            return Ok(request.respond(json_response(404, &serde_json::json!({"error": format!("path not found: {path_arg}")})))?);
        }
        let result = std::process::Command::new("adom-vscode")
            .arg("reveal")
            .arg(path_arg)
            .output();
        match result {
            Ok(o) if o.status.success() => {
                return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true, "path": path_arg})))?);
            }
            Ok(o) => {
                let stderr = String::from_utf8_lossy(&o.stderr).to_string();
                return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("adom-vscode reveal failed: {stderr}")})))?);
            }
            Err(e) => {
                return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("adom-vscode not on PATH: {e}")})))?);
            }
        }
    }

    // ---- Open a file in VS Code's editor pane. Pairs with /api/reveal
    // — reveal pings the Explorer sidebar, open actually opens the
    // file in a tab. Used by chipsmith for source files like .kicad_mod
    // / .step / info.json (PDFs go to adom-pdf-viewer instead — see the
    // datasheet section in the right-pane Source tab). -----------
    if path == "/api/open" && method == Method::Post {
        let body = parse_json(&mut request).unwrap_or_default();
        let path_arg = body.get("path").and_then(|v| v.as_str()).unwrap_or("");
        if path_arg.is_empty() {
            return Ok(request.respond(json_response(400, &serde_json::json!({"error": "path required"})))?);
        }
        if !std::path::Path::new(path_arg).exists() {
            return Ok(request.respond(json_response(404, &serde_json::json!({"error": format!("path not found: {path_arg}")})))?);
        }
        let result = std::process::Command::new("adom-vscode")
            .arg("open")
            .arg(path_arg)
            .output();
        match result {
            Ok(o) if o.status.success() => {
                return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true, "path": path_arg})))?);
            }
            Ok(o) => {
                let stderr = String::from_utf8_lossy(&o.stderr).to_string();
                return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("adom-vscode open failed: {stderr}")})))?);
            }
            Err(e) => {
                return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("adom-vscode not on PATH: {e}")})))?);
            }
        }
    }

    // ---- Datasheet PDF (served from the chip-fetcher dir) --------------
    if path == "/api/datasheet" && method == Method::Get {
        let dir = state.signoff_dir.lock().unwrap().clone();
        if let Some(d) = dir {
            // Prefer <MPN>.pdf, then any single .pdf in the dir as fallback.
            let mpn = state.current_source_label.lock().unwrap().clone()
                .map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
                .unwrap_or_default();
            let canonical = d.join(format!("{mpn}.pdf"));
            if canonical.exists() {
                if let Ok(bytes) = std::fs::read(&canonical) {
                    let resp = Response::from_data(bytes)
                        .with_status_code(StatusCode(200))
                        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/pdf"[..]).unwrap())
                        .with_header(Header::from_bytes(&b"Content-Disposition"[..], format!("inline; filename=\"{mpn}.pdf\"").as_bytes()).unwrap());
                    return Ok(request.respond(resp)?);
                }
            }
        }
        return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no datasheet PDF"})))?);
    }
    if path == "/api/spec" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            // Update in-memory spec.
            *state.spec.lock().unwrap() = v.clone();
            // ALSO persist to info.json next to the source STEP, so the
            // user's pin_count / structural_type / body_dims_mm edits
            // survive reloads. We merge into the existing info.json
            // rather than overwrite — non-chipsmith fields stay intact.
            let dir = state.signoff_dir.lock().unwrap().clone();
            let mut wrote = false;
            if let Some(d) = dir {
                let info_path = d.join("info.json");
                let existing: serde_json::Value = std::fs::read_to_string(&info_path)
                    .ok()
                    .and_then(|s| serde_json::from_str(&s).ok())
                    .unwrap_or_else(|| serde_json::json!({}));
                let mut merged = existing.as_object().cloned().unwrap_or_default();
                if let Some(obj) = v.as_object() {
                    for (k, v) in obj { merged.insert(k.clone(), v.clone()); }
                }
                if let Ok(s) = serde_json::to_string_pretty(&serde_json::Value::Object(merged)) {
                    if std::fs::write(&info_path, s).is_ok() {
                        wrote = true;
                    }
                }
            }
            return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true, "persisted": wrote})))?);
        }
        return Ok(request.respond(json_response(400, &serde_json::json!({"error": "no body"})))?);
    }

    // ---- footprint (.kicad_mod) -----------------------------------------

    if path == "/api/footprint" && method == Method::Get {
        let v = state.footprint.lock().unwrap().clone();
        return Ok(request.respond(json_response(200, &v))?);
    }
    if path == "/api/footprint" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            *state.footprint.lock().unwrap() = v;
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }

    // ---- Multi-source footprints (datasheet / kicad / fusion / altium) ----

    if path == "/api/footprint-sources" && method == Method::Get {
        let v = state.footprint_sources.lock().unwrap().clone();
        return Ok(request.respond(json_response(200, &v))?);
    }

    // ---- KiCad-stock reference fetch (service-kicad proxy) -------------
    // Given a footprint_name like "Package_SOIC/SOIC-8", fetch:
    //   - The .kicad_mod from service-kicad
    //   - The reference 3D STEP (when available) and convert via service-step2glb
    // Returns JSON { kicad_mod_text, glb_url } so the JS can render both
    // as a "KiCad reference" overlay branch in the scene-graph.
    if path == "/api/kicad-stock" && method == Method::Get {
        let qs = url.split('?').nth(1).unwrap_or("");
        let mut footprint_name = String::new();
        let mut chip_3d_name = String::new();
        let mut client_candidates: Vec<String> = Vec::new();
        for kv in qs.split('&') {
            let mut it = kv.splitn(2, '=');
            let k = it.next().unwrap_or("");
            let v = it.next().unwrap_or("").to_string();
            let v = url_decode(&v);
            if k == "footprint_name" { footprint_name = v; }
            else if k == "kicad_3d_name" { chip_3d_name = v; }
            else if k == "candidates" {
                // pipe-delimited list of "library/leaf" entries (no .pretty,
                // no .kicad_mod) — chipsmith JS pre-resolves them from the
                // package_kind + pin_count + body_dims_mm.
                client_candidates.extend(v.split('|').filter(|s| !s.is_empty()).map(|s| s.to_string()));
            }
        }
        if footprint_name.is_empty() && client_candidates.is_empty() {
            return Ok(request.respond(json_response(400, &serde_json::json!({"error": "footprint_name or candidates required"})))?);
        }
        // Build URLs to the shared service-kicad container.
        let kicad_base = std::env::var("KICAD_SERVICE_API")
            .unwrap_or_else(|_| "https://kicad-rk5ue5pcfemi.adom.cloud".to_string());
        let kicad_base = kicad_base.trim_end_matches('/');
        // Build the URL list. service-kicad serves the static library as
        // /footprints/<library>.pretty/<leaf>.kicad_mod (note the .pretty on
        // the directory). Try every permutation of "library/leaf" the
        // client gave us, plus legacy "library/leaf.kicad_mod" forms.
        let mut leaf_candidates: Vec<String> = Vec::new();
        if !footprint_name.is_empty() { leaf_candidates.push(footprint_name.clone()); }
        for c in &client_candidates {
            if !leaf_candidates.contains(c) { leaf_candidates.push(c.clone()); }
        }
        let mut url_candidates: Vec<String> = Vec::new();
        for c in &leaf_candidates {
            // c is "<library>/<leaf>" — split into library + leaf.
            let mut parts = c.splitn(2, '/');
            let library = parts.next().unwrap_or("");
            let leaf = parts.next().unwrap_or("");
            if library.is_empty() || leaf.is_empty() { continue; }
            // Canonical: Package_X.pretty/<leaf>.kicad_mod
            url_candidates.push(format!("{kicad_base}/footprints/{library}.pretty/{leaf}.kicad_mod"));
            // Legacy fallback (some early service-kicad versions stripped .pretty)
            url_candidates.push(format!("{kicad_base}/footprints/{library}/{leaf}.kicad_mod"));
        }
        let mut out = serde_json::json!({"footprint_name": footprint_name, "kicad_3d_name": chip_3d_name});
        let mut got = false;
        let mut last_err = String::new();
        let mut tried: Vec<serde_json::Value> = Vec::new();
        for fp_url in &url_candidates {
            let res = ureq::get(fp_url).timeout(std::time::Duration::from_secs(8)).call();
            match res {
                Ok(r) if r.status() == 200 => {
                    let txt = r.into_string().unwrap_or_default();
                    // Parse the .kicad_mod text into a pads + silk_markers
                    // structure that matches the four user-supplied
                    // footprint sources, so cross-validation in Tab 4
                    // can include the stock library as a fifth pair.
                    if let Ok(parsed) = crate::cli::view_library::parse_kicad_mod_text(&txt) {
                        out["parsed"] = parsed;
                    }
                    out["kicad_mod_text"] = serde_json::Value::String(txt);
                    out["kicad_mod_url"] = serde_json::Value::String(fp_url.clone());
                    // Surface the matched library / leaf so the UI can show
                    // exactly which one chipsmith found.
                    if let Some(after) = fp_url.split("/footprints/").nth(1) {
                        let matched = after.trim_end_matches(".kicad_mod").replace(".pretty/", "/");
                        out["matched_footprint_name"] = serde_json::Value::String(matched);
                    }
                    tried.push(serde_json::json!({ "url": fp_url, "status": 200 }));
                    got = true;
                    break;
                }
                Ok(r) => {
                    let s = r.status();
                    last_err = format!("HTTP {s} on {fp_url}");
                    tried.push(serde_json::json!({ "url": fp_url, "status": s }));
                }
                Err(e) => {
                    last_err = format!("{e} on {fp_url}");
                    tried.push(serde_json::json!({ "url": fp_url, "error": format!("{e}") }));
                }
            }
        }
        if !got {
            out["error_kicad_mod"] = serde_json::json!({
                "error": last_err,
                "tried_count": tried.len(),
                "tried": tried,
            });
        }
        // Try the matching 3D model too. KiCad layout: Package_X.3dshapes/<leaf>.step
        if !chip_3d_name.is_empty() {
            let m_url = format!("{kicad_base}/models/{}.step", chip_3d_name);
            out["kicad_3d_url"] = serde_json::Value::String(m_url);
        } else if got {
            // Derive 3D name from the matched footprint when possible.
            if let Some(matched) = out.get("matched_footprint_name").and_then(|v| v.as_str()) {
                let parts: Vec<&str> = matched.splitn(2, '/').collect();
                if parts.len() == 2 {
                    let model_path = format!("{}.3dshapes/{}.step", parts[0], parts[1]);
                    out["kicad_3d_name"] = serde_json::Value::String(model_path.clone());
                    out["kicad_3d_url"] = serde_json::Value::String(format!("{kicad_base}/models/{model_path}"));
                }
            }
        }
        return Ok(request.respond(json_response(200, &out))?);
    }
    if path == "/api/footprint-sources" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            *state.footprint_sources.lock().unwrap() = v;
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }

    // ---- step-meta (B-rep proxy from service-step2glb) ------------------

    if path == "/api/step-meta" && method == Method::Get {
        // Proxy to service-step2glb /step-meta, caching the resulting JSON
        // by source-STEP SHA-256 next to the GLB.
        let step_path = state.source_step_path.lock().unwrap().clone();
        let step_path = match step_path {
            Some(p) => p,
            None => return Ok(request.respond(json_response(404, &serde_json::json!({
                "error": "no source STEP path on this server (started without view-library?)"
            })))?),
        };
        let step_bytes = match std::fs::read(&step_path) {
            Ok(b) => b,
            Err(e) => return Ok(request.respond(json_response(500, &serde_json::json!({
                "error": format!("read step: {e}")
            })))?),
        };
        match convert::fetch_step_meta(&step_bytes) {
            Ok(Some(v)) => return Ok(request.respond(json_response(200, &v))?),
            Ok(None) => return Ok(request.respond(json_response(404, &serde_json::json!({
                "error": "service-step2glb /step-meta endpoint not yet deployed (returned 404). Falling back to GLB-walk detection."
            })))?),
            Err(e) => return Ok(request.respond(json_response(502, &serde_json::json!({
                "error": format!("upstream /step-meta failed: {e}")
            })))?),
        }
    }

    // ---- chipsmith sign-off bake ----------------------------------------

    if path == "/api/signoff" && method == Method::Post {
        let body = parse_json(&mut request).unwrap_or_default();
        let source_label_owned = state.current_source_label.lock().unwrap().clone();
        let source_label = source_label_owned.clone().unwrap_or_else(|| "unknown".to_string());
        let mpn = source_label.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string();
        let sidecar_dir = state.signoff_dir.lock().unwrap().clone();
        let sidecar_path = match &sidecar_dir {
            Some(d) => d.join(format!("{mpn}.chipsmith.json")),
            None => std::env::temp_dir().join(format!("{mpn}.chipsmith.json")),
        };
        let baked_step_path = match &sidecar_dir {
            Some(d) => d.join(format!("{mpn}.chipsmith.step")),
            None => std::env::temp_dir().join(format!("{mpn}.chipsmith.step")),
        };
        let mut sidecar = body.clone();
        if sidecar.is_object() {
            sidecar["mpn"] = serde_json::Value::String(mpn.clone());
            sidecar["source"] = serde_json::Value::String(source_label.clone());
            sidecar["signed_at"] = serde_json::Value::String(chrono_iso8601_now());
            sidecar["chipsmith_version"] = serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string());
        }
        // Try to bake the canonical .chipsmith.step via service-step2glb /bake.
        // Requires the source STEP bytes; we read them from the chip-fetcher
        // library dir if available.
        let mut step_baked = false;
        let mut bake_status = String::new();
        if let Some(d) = &sidecar_dir {
            let mut step_path = d.join(format!("{mpn}.step"));
            if !step_path.exists() { step_path = d.join(format!("{mpn}.STEP")); }
            if step_path.exists() {
                if let Ok(step_bytes) = std::fs::read(&step_path) {
                    let bake_meta = serde_json::json!({
                        "applied_scale": sidecar.get("applied_unit_scale").cloned().unwrap_or(serde_json::json!(1.0)),
                        "applied_translation_mm": sidecar.get("applied_translation_mm").cloned().unwrap_or(serde_json::json!([0.0, 0.0, 0.0])),
                        "applied_rotation": sidecar.get("applied_rotation").cloned().unwrap_or(serde_json::json!({})),
                        "role_tags": sidecar.get("role_tags").cloned().unwrap_or(serde_json::json!([])),
                        "pin1_marker": sidecar.get("pin1_chip").cloned().unwrap_or(serde_json::json!(null)),
                        // Names of GLB-product entries the user (or chipsmith
                        // auto-flag) marked as decorative — vendor logos, lens
                        // cosmetics, embossed marketing geometry. service-step2glb
                        // /bake reparents these under a `chipsmith_decorative`
                        // assembly node so downstream Adom tools can default
                        // them OFF. NEVER deleted, only moved.
                        "decorative_product_names": sidecar.get("decorative_product_names").cloned().unwrap_or(serde_json::json!([])),
                        "signoff_metadata": {
                            "chipsmith_version": env!("CARGO_PKG_VERSION"),
                            "signed_at": chrono_iso8601_now(),
                            "spec": sidecar.get("spec").cloned().unwrap_or(serde_json::json!(null)),
                        },
                    });
                    match convert::bake_step(&step_bytes, &bake_meta) {
                        Ok(Some(out)) => {
                            if let Err(e) = std::fs::write(&baked_step_path, &out) {
                                bake_status = format!("bake produced bytes but write failed: {e}");
                            } else {
                                step_baked = true;
                                bake_status = format!("baked to {}", baked_step_path.display());
                            }
                        }
                        Ok(None) => {
                            bake_status = "service-step2glb /bake not deployed yet (404) — JSON sidecar still written".to_string();
                        }
                        Err(e) => {
                            bake_status = format!("bake error: {e} — JSON sidecar still written");
                        }
                    }
                }
            }
        }
        if let Err(e) = std::fs::write(&sidecar_path, serde_json::to_string_pretty(&sidecar).unwrap_or_default()) {
            return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("write sidecar: {e}")})))?);
        }
        *state.signoff_state.lock().unwrap() = serde_json::json!({
            "signed": true,
            "sidecar_path": sidecar_path.to_string_lossy(),
            "step_baked": step_baked,
            "step_path": if step_baked { Some(baked_step_path.to_string_lossy().to_string()) } else { None },
            "bake_status": bake_status,
        });
        return Ok(request.respond(json_response(200, &serde_json::json!({
            "ok": true,
            "sidecar_path": sidecar_path.to_string_lossy(),
            "step_baked": step_baked,
            "step_path": if step_baked { Some(baked_step_path.to_string_lossy().to_string()) } else { None },
            "bake_status": bake_status,
        })))?);
    }
    if path == "/api/signoff" && method == Method::Get {
        let v = state.signoff_state.lock().unwrap().clone();
        return Ok(request.respond(json_response(200, &v))?);
    }

    // ---- Visibility ------------------------------------------------------

    if path == "/api/visibility" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            *state.component_visibility.lock().unwrap() = v;
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }

    // ---- Sections --------------------------------------------------------

    if path == "/api/sections" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            *state.sections.lock().unwrap() = v;
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }
    if path == "/api/sections" && method == Method::Get {
        let v = state.sections.lock().unwrap().clone();
        return Ok(request.respond(json_response(200, &v))?);
    }

    // ---- Selection (UI -> server, drives `inspect` CLI) ------------------

    if path == "/api/selection" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            *state.selection.lock().unwrap() = v;
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }
    if path == "/api/selection" && method == Method::Get {
        let v = state.selection.lock().unwrap().clone();
        return Ok(request.respond(json_response(200, &v))?);
    }

    // ---- Command bus (CLI -> server -> UI poll) --------------------------

    if path == "/api/command" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            *state.pending_command.lock().unwrap() = Some(v);
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }
    if path == "/api/poll-command" && method == Method::Get {
        let v = state.pending_command.lock().unwrap().take();
        return Ok(request.respond(json_response(200, &serde_json::json!({"command": v})))?);
    }

    // ---- JS eval pipeline ------------------------------------------------

    if path == "/eval" && method == Method::Post {
        let body: serde_json::Value = parse_json(&mut request).unwrap_or(serde_json::json!({}));
        let code = body
            .get("code")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        if code.is_empty() {
            return Ok(request.respond(json_response(
                400,
                &serde_json::json!({"error": "missing 'code'"}),
            ))?);
        }
        let id = next_eval_id(state);
        state.eval_jobs.lock().unwrap().push(EvalJob {
            id: id.clone(),
            code,
            result: None,
            done: false,
            taken: false,
        });
        return Ok(request.respond(json_response(200, &serde_json::json!({"id": id})))?);
    }

    if path == "/eval/pending" && method == Method::Get {
        let mut jobs = state.eval_jobs.lock().unwrap();
        let next = jobs
            .iter_mut()
            .find(|j| !j.done && !j.taken)
            .map(|j| {
                j.taken = true;
                serde_json::json!({"id": j.id, "code": j.code})
            });
        return Ok(request.respond(json_response(200, &serde_json::json!({"job": next})))?);
    }

    if path.starts_with("/eval/") && path.ends_with("/result") && method == Method::Post {
        let id = path
            .trim_start_matches("/eval/")
            .trim_end_matches("/result")
            .to_string();
        let body = parse_json(&mut request).unwrap_or(serde_json::json!(null));
        let mut jobs = state.eval_jobs.lock().unwrap();
        if let Some(job) = jobs.iter_mut().find(|j| j.id == id) {
            job.result = Some(body);
            job.done = true;
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }

    if path.starts_with("/eval/")
        && !path.ends_with("/result")
        && path != "/eval/pending"
        && method == Method::Get
    {
        let id = path.trim_start_matches("/eval/").to_string();
        let jobs = state.eval_jobs.lock().unwrap();
        let body = match jobs.iter().find(|j| j.id == id) {
            Some(j) => serde_json::json!({
                "id": j.id,
                "done": j.done,
                "result": j.result.clone().unwrap_or(serde_json::Value::Null),
            }),
            None => serde_json::json!({"error": "no such id"}),
        };
        return Ok(request.respond(json_response(200, &body))?);
    }

    // ---- Console forwarder ----------------------------------------------

    if path == "/console" && method == Method::Post {
        if let Some(v) = parse_json(&mut request) {
            let level = v.get("level").and_then(|x| x.as_str()).unwrap_or("log");
            let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("");
            state.push_console(level, text);
        }
        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
    }

    if path == "/console" && method == Method::Get {
        let qs = url.split('?').nth(1).unwrap_or("");
        let since: u64 = qs
            .split('&')
            .find_map(|p| p.strip_prefix("since=").and_then(|v| v.parse().ok()))
            .unwrap_or(0);
        let buf = state.console.lock().unwrap();
        let messages: Vec<_> = buf
            .iter()
            .filter(|m| m.seq >= since)
            .map(|m| {
                serde_json::json!({
                    "seq": m.seq,
                    "ts_ms": m.ts_ms,
                    "level": m.level,
                    "text": m.text,
                })
            })
            .collect();
        let next_since = buf.iter().last().map(|m| m.seq + 1).unwrap_or(since);
        let body = serde_json::json!({"messages": messages, "next_since": next_since});
        return Ok(request.respond(json_response(200, &body))?);
    }

    // ---- 404 -------------------------------------------------------------

    Ok(request.respond(json_response(
        404,
        &serde_json::json!({"error": format!("no route for {} {}", method, path)}),
    ))?)
}