1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222
use anyhow::{Context, Result};
use std::io::Read;
use std::path::PathBuf;
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};

const INDEX_HTML: &str = include_str!("ui/index.html");

// Single-slot activity state: (raw JSON payload, epoch-seconds it was set).
// Updated via POST /api/activity, read via GET. Used by the viewer to
// highlight the chip currently being worked on. The timestamp lets the
// dashboard distinguish "currently working" from a stale heartbeat that no
// process ever cleared (heartbeats are write-only stage labels with no
// terminal state, so the last one sticks indefinitely without this).
static ACTIVITY: Mutex<Option<(String, u64)>> = Mutex::new(None);

fn now_epoch() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

pub fn run(port: u16) -> Result<()> {
    let addr = format!("0.0.0.0:{port}");
    let server = Server::http(&addr).map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;
    println!("adom-chip-fetcher viewer listening on http://{addr}");
    println!("(open in Hydrogen via `adom-chip-fetcher app`, or visit in any browser)");

    for mut request in server.incoming_requests() {
        let raw_url = request.url().to_string();
        let no_qs = raw_url.split('?').next().unwrap_or(&raw_url);
        // Strip code-server proxy prefix (e.g. /proxy/8786/) so routes work behind the proxy
        let url = if let Some(rest) = no_qs.strip_prefix(&format!("/proxy/{port}")) {
            if rest.is_empty() { "/".to_string() } else { rest.to_string() }
        } else {
            no_qs.to_string()
        };
        let method = request.method().clone();
        let response = match (&method, url.as_str()) {
            (Method::Get, "/") | (Method::Get, "/index.html") => html_response(INDEX_HTML),
            (Method::Get, "/api/library") => library_json(),
            (Method::Get, "/api/sources") => sources_json(),
            (Method::Get, "/api/validate") => validate_json(),
            (Method::Get, "/api/projects") => projects_json(),
            (Method::Get, "/api/activity") => activity_get(),
            (Method::Post, "/api/activity") => {
                let mut body = String::new();
                let _ = request.as_reader().read_to_string(&mut body);
                activity_set(&body)
            }
            (Method::Get, "/api/credentials") => credentials_list(),
            (Method::Post, "/api/credentials") => {
                let mut body = String::new();
                let _ = request.as_reader().read_to_string(&mut body);
                credentials_set(&body)
            }
            (Method::Delete, path) if path.starts_with("/api/credentials/") => {
                let host = path.trim_start_matches("/api/credentials/");
                credentials_delete(host)
            }
            (Method::Get, path) if path.starts_with("/api/chip/") => {
                let mpn = path.trim_start_matches("/api/chip/");
                chip_json(mpn)
            }
            (Method::Post, "/api/reveal") => {
                let mut body = String::new();
                let _ = request.as_reader().read_to_string(&mut body);
                reveal_in_vscode(&body)
            }
            (Method::Post, "/api/open") => {
                let mut body = String::new();
                let _ = request.as_reader().read_to_string(&mut body);
                open_in_app(&body)
            }
            (Method::Post, "/api/orientation") => {
                let mut body = String::new();
                let _ = request.as_reader().read_to_string(&mut body);
                set_orientation(&body)
            }
            (Method::Post, "/api/deliver") => {
                let mut body = String::new();
                let _ = request.as_reader().read_to_string(&mut body);
                deliver_chip(&body)
            }
            (Method::Get, path) if path.starts_with("/asset/") => {
                serve_asset(path.trim_start_matches("/asset/"))
            }
            (Method::Get, path) if path.starts_with("/thumb/") => {
                serve_thumb(path.trim_start_matches("/thumb/"))
            }
            (Method::Get, path) if path.starts_with("/svg/") => {
                serve_authoritative_svg(path.trim_start_matches("/svg/"))
            }
            (Method::Post, path) if path.starts_with("/api/validate/") => {
                let mpn = path.trim_start_matches("/api/validate/").to_string();
                spawn_subprocess_action("validate", &["validate", &mpn], &mpn)
            }
            (Method::Post, path) if path.starts_with("/api/thumbnails/") => {
                let mpn = path.trim_start_matches("/api/thumbnails/").to_string();
                spawn_subprocess_action("thumbnails", &["thumbnails", "--mpn", &mpn, "--force"], &mpn)
            }
            (Method::Post, path) if path.starts_with("/api/refetch/") => {
                let mpn = path.trim_start_matches("/api/refetch/").to_string();
                let qs = raw_url.split('?').nth(1).unwrap_or("");
                let mut format = String::new();
                let mut source = String::new();
                for p in qs.split('&') {
                    if let Some(v) = p.strip_prefix("format=") { format = v.to_string(); }
                    if let Some(v) = p.strip_prefix("source=") { source = v.to_string(); }
                }
                if format.is_empty() || source.is_empty() {
                    error_response(400, "{\"error\":\"need ?format=X&source=Y\"}".into())
                } else {
                    spawn_subprocess_action(
                        "refetch",
                        &["refetch", &mpn, "--format", &format, "--source", &source],
                        &mpn,
                    )
                }
            }
            _ => not_found(),
        };
        let _ = request.respond(response);
    }
    Ok(())
}

fn activity_get() -> Response<std::io::Cursor<Vec<u8>>> {
    let entry = ACTIVITY.lock().ok().and_then(|v| v.clone());
    let json = match entry {
        Some((body, ts)) => {
            let age = now_epoch().saturating_sub(ts);
            // Merge a server-computed age into the stored payload so the
            // dashboard can tell "currently working" from a stale heartbeat.
            match serde_json::from_str::<serde_json::Value>(&body) {
                Ok(serde_json::Value::Object(mut m)) => {
                    m.insert("_age_seconds".into(), serde_json::json!(age));
                    m.insert("_received_at".into(), serde_json::json!(ts));
                    serde_json::Value::Object(m).to_string()
                }
                _ => body,
            }
        }
        None => "{}".to_string(),
    };
    json_response(json)
}

fn activity_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    if serde_json::from_str::<serde_json::Value>(body).is_err() {
        return error_response(400, "{\"error\":\"invalid json\"}".to_string());
    }
    if let Ok(mut slot) = ACTIVITY.lock() {
        *slot = Some((body.to_string(), now_epoch()));
    }
    json_response("{\"ok\":true}".to_string())
}

fn credentials_list() -> Response<std::io::Cursor<Vec<u8>>> {
    match crate::creds::list_public() {
        Ok(creds) => {
            let body = serde_json::json!({"credentials": creds}).to_string();
            json_response(body)
        }
        Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),
    }
}

fn credentials_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let parsed: serde_json::Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
    };
    let host = parsed.get("host").and_then(|v| v.as_str()).unwrap_or("");
    let username = parsed.get("username").and_then(|v| v.as_str()).unwrap_or("");
    let password = parsed.get("password").and_then(|v| v.as_str()).unwrap_or("");
    if host.is_empty() || username.is_empty() || password.is_empty() {
        return error_response(400, "{\"error\":\"host, username, password all required\"}".to_string());
    }
    match crate::creds::set(host, username, password) {
        Ok(_) => json_response("{\"ok\":true}".to_string()),
        Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),
    }
}

fn credentials_delete(host: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let host = urldecode(host);
    match crate::creds::delete(&host) {
        Ok(removed) => json_response(format!("{{\"ok\":true,\"removed\":{removed}}}")),
        Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),
    }
}

// POST /api/open {"mpn":"<X>","kind":"chip3d|symbol|footprint|datasheet"}
//   All four open in a NEW PUP TAB at a publicly-routable proxy URL —
//   this matters because pup runs on the user's Windows machine, so a
//   loopback URL (127.0.0.1) on the container is unreachable from there.
//
//   We resolve the proxy URL from $ADOM_PROXY_HOST or $ADOM_CONTAINER_SLUG
//   (set by `chip-fetcher dashboard ensure`). When neither is set we fall
//   back to 127.0.0.1 so dev-running-on-localhost still works.
/// POST /api/deliver {mpn, target} — send the chip's CAD to a desktop EDA tool.
fn deliver_chip(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let parsed: serde_json::Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
    };
    let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();
    let target = parsed.get("target").and_then(|v| v.as_str()).unwrap_or("kicad").trim();
    if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {
        return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());
    }
    let dir = crate::library::root().join(mpn);
    if !dir.exists() {
        return error_response(404, format!("{{\"error\":\"no library/{mpn}/\"}}"));
    }
    json_response(crate::deliver::deliver(mpn, &dir, target).to_string())
}

fn open_in_app(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let parsed: serde_json::Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
    };
    let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();
    let kind = parsed.get("kind").and_then(|v| v.as_str()).unwrap_or("").trim();
    let source = parsed.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();
    if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {
        return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());
    }
    let dir = crate::library::root().join(mpn);
    if !dir.exists() {
        return error_response(404, format!("{{\"error\":\"no library/{mpn}/\"}}"));
    }

    // chipsmith opens in its own Hydrogen webview (STEP-native detection +
    // footprint sign-off), loading the chip's whole library dir.
    if kind == "chipsmith" {
        use std::process::Stdio;
        let r = std::process::Command::new("adom-chipsmith")
            .args(["view-library"]).arg(&dir)
            .stdout(Stdio::null()).stderr(Stdio::null()).stdin(Stdio::null())
            .spawn();
        return match r {
            Ok(_) => json_response("{\"ok\":true,\"tool\":\"chipsmith\"}".to_string()),
            Err(e) => error_response(500, format!("{{\"error\":\"adom-chipsmith unavailable: {e}\"}}")),
        };
    }

    let base = pup_visible_base_url();

    let target_url = match kind {
        "chip3d-yup" => {
            // Y-up is for ME / mech-CAD users (Fusion 360 / SolidWorks
            // convention). adom-step uses Z-up internally, so we open the
            // pre-rendered large Y-up PNG full-size in pup rather than
            // wrestling adom-step's pose. Click "Z-up" thumb for the live
            // interactive 3D viewer.
            if !dir.join(format!("{mpn}-3d-iso-yup-lg.png")).exists() {
                return error_response(404, format!("{{\"error\":\"no {mpn}-3d-iso-yup-lg.png — run `chip-thumbnailer scan`\"}}"));
            }
            format!("{base}/thumb/{mpn}/chip3d-yup-lg")
        }
        "chip3d" => {
            let step = dir.join(format!("{mpn}.step"));
            if !step.exists() {
                return error_response(404, format!("{{\"error\":\"no {mpn}.step\"}}"));
            }
            // Real adom-step viewer integration:
            //   1. Ensure server on :8851 (spawn detached if not running).
            //   2. POST the STEP bytes to /api/load so the server switches
            //      to this chip — adom-step is single-file-per-server.
            //   3. Open the public proxy URL of port 8851 in a new pup tab.
            ensure_adom_step_server();
            if !load_step_into_adom_step(&step, mpn) {
                println!("WARN: adom-step /api/load failed for {mpn}; falling back to asset URL");
                return open_pup_tab(&format!("{base}/asset/{mpn}/{mpn}.step"), "fallback");
            }
            // Cache-bust the URL so a fresh load happens even if the user already
            // has an adom-step tab open from a previous click.
            let ts = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_millis())
                .unwrap_or(0);
            format!("{}?reload={ts}&title={}", proxy_url_for_port(ADOM_STEP_PORT), urlencode(mpn))
        }
        "datasheet" => {
            let pdf = dir.join(format!("{mpn}.pdf"));
            if !pdf.exists() {
                return error_response(404, format!("{{\"error\":\"no {mpn}.pdf\"}}"));
            }
            ensure_adom_pdf_viewer_server();
            let asset_url = format!("{base}/asset/{mpn}/{mpn}.pdf");
            format!(
                "{}v?src={}&title={}%20datasheet",
                proxy_url_for_port(ADOM_PDF_VIEWER_PORT),
                urlencode(&asset_url),
                urlencode(mpn)
            )
        }
        "symbol" => {
            let sym = dir.join(format!("{mpn}.kicad_sym"));
            if !sym.exists() {
                return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_sym\"}}"));
            }
            // Launch the standalone adom-symbol live app (no gallia, no Node):
            //   1. Ensure `adom-symbol serve` is up on its port.
            //   2. POST the raw .kicad_sym to /load — the app renders it
            //      server-side via service-kicad and updates its state.
            //   3. Open the app's public proxy URL in a pup tab.
            ensure_adom_symbol_server();
            // Folder mode: adom-symbol discovers ds2sf + manufacturer + iso and
            // renders the auto-grouped default with a layout toolbar.
            if !load_dir_into_app(&dir, mpn, ADOM_SYMBOL_PORT, source) {
                println!("WARN: adom-symbol /load (dir) failed for {mpn}; falling back to raw SVG");
                return open_pup_tab(&format!("{base}/thumb/{mpn}/symbol"), "fallback-svg");
            }
            format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_SYMBOL_PORT), cache_bust(), urlencode(mpn))
        }
        "footprint" => {
            let md = dir.join(format!("{mpn}.kicad_mod"));
            if !md.exists() {
                return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_mod\"}}"));
            }
            // Launch the standalone adom-footprint live app (no gallia, no
            // Node): ensure `adom-footprint serve`, POST the raw .kicad_mod to
            // /load (app renders via service-kicad), open its proxy URL.
            ensure_adom_footprint_server();
            if !load_kicad_into_app(&md, mpn, ADOM_FOOTPRINT_PORT, "kicadMod") {
                println!("WARN: adom-footprint /load failed for {mpn}; falling back to raw SVG");
                return open_pup_tab(&format!("{base}/thumb/{mpn}/footprint"), "fallback-svg");
            }
            format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_FOOTPRINT_PORT), cache_bust(), urlencode(mpn))
        }
        "lbr" => {
            let sym = dir.join(format!("{mpn}.kicad_sym"));
            let md = dir.join(format!("{mpn}.kicad_mod"));
            if !sym.exists() && !md.exists() {
                return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_sym or .kicad_mod to build an .lbr\"}}"));
            }
            // Launch the standalone adom-lbr live app (no gallia, no Node):
            //   1. Ensure `adom-lbr serve` is up.
            //   2. Generate a true EAGLE .lbr from the chip's symbol+footprint
            //      and POST it to /load (the app lints + displays it).
            //   3. Open the app's public proxy URL in a pup tab.
            ensure_adom_lbr_server();
            if !load_lbr_into_app(&dir, mpn, ADOM_LBR_PORT) {
                println!("WARN: adom-lbr generate/load failed for {mpn}");
                return error_response(500, format!("{{\"error\":\"could not build .lbr for {mpn}\"}}"));
            }
            format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_LBR_PORT), cache_bust(), urlencode(mpn))
        }
        _ => return error_response(400, format!("{{\"error\":\"unknown kind {:?} (want chip3d|symbol|footprint|lbr|datasheet)\"}}", kind)),
    };

    // Return the app's public URL and let the CLIENT open it with window.open —
    // that opens a tab in whatever browser the user is actually viewing
    // chip-fetcher in (their phone, a pup window, a Hydrogen webview), which is
    // the only universally-correct surface. (The old server-side
    // `adom-desktop browser_open_tab` always opened on the LAPTOP, so a phone
    // user saw nothing.) `desktopBridge` is reported so the client can name the
    // method and gate Send-to.
    json_response(format!(
        "{{\"ok\":true,\"url\":{:?},\"desktopBridge\":{}}}",
        target_url, desktop_bridge_available()
    ))
}

/// Whether an Adom Desktop client is connected to this container's bridge.
/// Cached briefly (the probe shells out). Used only to label the open method +
/// gate Send-to — the actual app open happens client-side via window.open.
fn desktop_bridge_available() -> bool {
    use std::sync::Mutex;
    use std::time::{Duration, Instant};
    static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
    if let Some((t, v)) = *CACHE.lock().unwrap() {
        if t.elapsed() < Duration::from_secs(15) {
            return v;
        }
    }
    let avail = std::process::Command::new("timeout")
        .args(["3", "adom-desktop", "status"])
        .output()
        .ok()
        .map(|o| {
            serde_json::from_str::<serde_json::Value>(&String::from_utf8_lossy(&o.stdout))
                .ok()
                .and_then(|v| v.get("clients").and_then(|c| c.as_u64()))
                .map(|n| n > 0)
                .unwrap_or(false)
        })
        .unwrap_or(false);
    *CACHE.lock().unwrap() = Some((Instant::now(), avail));
    avail
}

/// Fallback open (e.g. raw SVG when the live app load failed): return the URL for
/// the client to window.open — same client-side strategy as the main path.
fn open_pup_tab(url: &str, _label: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    json_response(format!("{{\"ok\":true,\"url\":{:?},\"desktopBridge\":{}}}", url, desktop_bridge_available()))
}

// Dedicated chip-fetcher-owned ports for the spawned helper apps. Using
// non-default ports avoids collisions with any pre-existing adom-step /
// adom-pdf-viewer instances the user may have started for other workflows.
const ADOM_STEP_PORT: u16 = 8857;
const ADOM_PDF_VIEWER_PORT: u16 = 8985;
const ADOM_SYMBOL_PORT: u16 = 8781;
const ADOM_FOOTPRINT_PORT: u16 = 8783;
const ADOM_LBR_PORT: u16 = 8784;

fn port_listening(port: u16) -> bool {
    let url = format!("http://127.0.0.1:{port}/");
    std::process::Command::new("curl")
        .args(["-fs", "-m", "1", "-o", "/dev/null", &url])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

fn ensure_adom_step_server() {
    if port_listening(ADOM_STEP_PORT) { return; }
    // Need a placeholder file to start the server. We'll use any STEP from
    // the library — the next /api/load POST will replace it. If the library
    // is empty, skip; the load call below will surface the error.
    let any_step = std::fs::read_dir(crate::library::root())
        .ok()
        .and_then(|rd| rd.filter_map(|e| e.ok())
            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
            .find_map(|e| {
                let mpn = e.file_name().to_string_lossy().to_string();
                let p = e.path().join(format!("{mpn}.step"));
                if p.is_file() { Some(p) } else { None }
            }));
    let Some(any) = any_step else { return; };
    use std::process::Stdio;
    let _ = std::process::Command::new("adom-step")
        .args(["view", "--no-open", "--port", &ADOM_STEP_PORT.to_string()])
        .arg(&any)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .stdin(Stdio::null())
        .spawn();
    // Wait briefly for it to come up.
    for _ in 0..30 {
        if port_listening(ADOM_STEP_PORT) { break; }
        std::thread::sleep(std::time::Duration::from_millis(200));
    }
}

fn load_step_into_adom_step(step: &std::path::Path, mpn: &str) -> bool {
    let bytes = match std::fs::read(step) { Ok(b) => b, Err(_) => return false };
    let Some(tmp) = write_tmp_file(&bytes) else { return false };
    // curl -X POST --data-binary @<tmp> -H 'X-Filename: <MPN>.step' http://127.0.0.1:8851/api/load
    let url = format!("http://127.0.0.1:{ADOM_STEP_PORT}/api/load");
    let filename = format!("{mpn}.step");
    let header = format!("X-Filename: {filename}");
    let data_arg = format!("@{}", tmp.display());
    let out = std::process::Command::new("curl")
        .args([
            "-fs", "-m", "60", "-X", "POST",
            "-H", &header,
            "--data-binary", &data_arg,
            &url,
        ])
        .output();
    let _ = std::fs::remove_file(&tmp);
    matches!(out, Ok(o) if o.status.success())
}

fn write_tmp_file(bytes: &[u8]) -> Option<std::path::PathBuf> {
    use std::io::Write;
    let path = std::env::temp_dir().join(format!("cf-load-{}.bin", std::process::id()));
    let mut f = std::fs::File::create(&path).ok()?;
    f.write_all(bytes).ok()?;
    Some(path)
}

/// Generate (or refresh) <MPN>-symbol-viewer.html via the local Node
/// helper. Idempotent at the script level — caller checks file existence
/// first and only invokes this on first click. Returns true on success.
// POST /api/orientation {"mpn":"<X>","axis":"z"|"y"}
//   Records the user's choice of up-axis for this chip. Persists to
//   library/<MPN>/info.json's chosen_up_axis field. Downstream tools
//   (adom-step / adom-chiplinter / chip3d previewers) read this field and
//   apply the right rotation when consuming the STEP, so the user only
//   has to pick once per chip.
//
//   When the user picks the SAME axis as chip-thumbnailer's inferred axis,
//   we still record it explicitly — that way "user has confirmed" is
//   visible in the data, vs. "still relying on inference."
fn set_orientation(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let parsed: serde_json::Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
    };
    let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();
    let axis = parsed.get("axis").and_then(|v| v.as_str()).unwrap_or("").trim();
    if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {
        return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());
    }
    let valid = ["z", "y", "x", "-x", "-y", "-z", "xup", "xdown", "yup", "ydown", "zup", "zdown"];
    if !valid.contains(&axis) {
        return error_response(400, "{\"error\":\"axis must be 'z' or 'y'\"}".to_string());
    }
    let info_path = crate::library::root().join(mpn).join("info.json");
    if !info_path.exists() {
        return error_response(404, format!("{{\"error\":\"no info.json for {mpn}\"}}"));
    }
    let raw = match std::fs::read_to_string(&info_path) {
        Ok(s) => s,
        Err(e) => return error_response(500, format!("{{\"error\":\"read info.json: {e}\"}}")),
    };
    let mut info: serde_json::Value = match serde_json::from_str(&raw) {
        Ok(v) => v,
        Err(e) => return error_response(500, format!("{{\"error\":\"parse info.json: {e}\"}}")),
    };
    info["chosen_up_axis"] = serde_json::Value::String(axis.to_string());
    info["chosen_up_axis_at"] = serde_json::Value::String(
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs().to_string())
            .unwrap_or_default(),
    );
    let pretty = match serde_json::to_string_pretty(&info) {
        Ok(s) => s,
        Err(e) => return error_response(500, format!("{{\"error\":\"serialize: {e}\"}}")),
    };
    if let Err(e) = std::fs::write(&info_path, pretty) {
        return error_response(500, format!("{{\"error\":\"write info.json: {e}\"}}"));
    }
    json_response(format!("{{\"ok\":true,\"mpn\":{mpn:?},\"axis\":{axis:?}}}"))
}

/// Millisecond timestamp for cache-busting an already-open app tab so a fresh
/// /load is reflected even if the user clicked the same kind before.
fn cache_bust() -> u128 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0)
}

/// Spawn `adom-symbol serve` on its port if it isn't already listening.
fn ensure_adom_symbol_server() {
    ensure_app_server("adom-symbol", ADOM_SYMBOL_PORT);
}

/// Spawn `adom-footprint serve` on its port if it isn't already listening.
fn ensure_adom_footprint_server() {
    ensure_app_server("adom-footprint", ADOM_FOOTPRINT_PORT);
}

/// Spawn `adom-lbr serve` on its port if it isn't already listening.
fn ensure_adom_lbr_server() {
    ensure_app_server("adom-lbr", ADOM_LBR_PORT);
}

/// Build a true EAGLE `.lbr` for this chip (symbol + footprint when both exist)
/// via the adom-lbr CLI, then POST the XML to adom-lbr's `/load`. Returns false
/// if generation or the load failed.
fn load_lbr_into_app(dir: &std::path::Path, mpn: &str, port: u16) -> bool {
    let sym = dir.join(format!("{mpn}.kicad_sym"));
    let md = dir.join(format!("{mpn}.kicad_mod"));
    let out_lbr = dir.join(format!("{mpn}.lbr"));
    let mut args: Vec<String> = vec!["generate".into()];
    if sym.is_file() {
        args.push("--sym".into());
        args.push(sym.to_string_lossy().to_string());
    }
    if md.is_file() {
        args.push("--fp".into());
        args.push(md.to_string_lossy().to_string());
    }
    if !sym.is_file() && !md.is_file() {
        return false;
    }
    args.push("--name".into());
    args.push(mpn.to_string());
    args.push("--output".into());
    args.push(out_lbr.to_string_lossy().to_string());
    let gen = std::process::Command::new("adom-lbr").args(&args).output();
    // generate exits 2 on lint failure but still writes the file; accept any
    // run that produced the .lbr.
    if !matches!(gen, Ok(_)) || !out_lbr.is_file() {
        return false;
    }
    let content = match std::fs::read_to_string(&out_lbr) {
        Ok(c) => c,
        Err(_) => return false,
    };
    let body = serde_json::json!({ "lbr": content }).to_string();
    let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };
    let url = format!("http://127.0.0.1:{port}/load");
    let data_arg = format!("@{}", tmp.display());
    let out = std::process::Command::new("curl")
        .args(["-fs", "-m", "30", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", &data_arg, &url])
        .output();
    let _ = std::fs::remove_file(&tmp);
    matches!(out, Ok(o) if o.status.success())
}

fn ensure_app_server(bin: &str, port: u16) {
    if port_listening(port) {
        return;
    }
    use std::process::Stdio;
    let _ = std::process::Command::new(bin)
        .args(["serve", "--port", &port.to_string()])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .stdin(Stdio::null())
        .spawn();
    for _ in 0..30 {
        if port_listening(port) {
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(200));
    }
}

/// POST a raw KiCad file to a standalone app's `/load` endpoint as JSON
/// `{ "<field>": "<file contents>", "symbolName"/"footprintName": "<mpn>" }`.
/// The app renders it server-side via service-kicad. `field` is "kicadSym" or
/// "kicadMod".
/// POST `{dir, mpn}` to an app's `/load` so it can discover all source files in
/// the chip's library folder (manufacturer .kicad_sym, ds2sf extraction, iso 3D).
fn load_dir_into_app(dir: &std::path::Path, mpn: &str, port: u16, source: &str) -> bool {
    let body = serde_json::json!({ "dir": dir.to_string_lossy(), "mpn": mpn, "source": source }).to_string();
    let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };
    let url = format!("http://127.0.0.1:{port}/load");
    let data_arg = format!("@{}", tmp.display());
    let out = std::process::Command::new("curl")
        .args(["-fs", "-m", "90", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", &data_arg, &url])
        .output();
    let _ = std::fs::remove_file(&tmp);
    matches!(out, Ok(o) if o.status.success())
}

fn load_kicad_into_app(path: &std::path::Path, mpn: &str, port: u16, field: &str) -> bool {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => return false,
    };
    let name_key = if field == "kicadSym" { "symbolName" } else { "footprintName" };
    let body = serde_json::json!({ field: content, name_key: mpn }).to_string();
    let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };
    let url = format!("http://127.0.0.1:{port}/load");
    let data_arg = format!("@{}", tmp.display());
    let out = std::process::Command::new("curl")
        .args([
            "-fs", "-m", "70", "-X", "POST",
            "-H", "Content-Type: application/json",
            "--data-binary", &data_arg,
            &url,
        ])
        .output();
    let _ = std::fs::remove_file(&tmp);
    matches!(out, Ok(o) if o.status.success())
}

fn ensure_adom_pdf_viewer_server() {
    if port_listening(ADOM_PDF_VIEWER_PORT) { return; }
    use std::process::Stdio;
    let _ = std::process::Command::new("node")
        .args([
            "/home/adom/project/adom-pdf-viewer/server.mjs",
            "--port", &ADOM_PDF_VIEWER_PORT.to_string(),
        ])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .stdin(Stdio::null())
        .spawn();
    for _ in 0..30 {
        if port_listening(ADOM_PDF_VIEWER_PORT) { break; }
        std::thread::sleep(std::time::Duration::from_millis(200));
    }
}

/// Resolve the dashboard URL that pup (running on the user's Windows
/// machine) can actually reach. Reads $ADOM_PROXY_HOST or
/// $ADOM_CONTAINER_SLUG, set by `chip-fetcher dashboard ensure`.
fn pup_visible_base_url() -> String {
    proxy_url_for_port(8786).trim_end_matches('/').to_string()
}

/// Same logic as pup_visible_base_url but for an arbitrary port — used so
/// adom-step (8851) and adom-pdf-viewer (8981) are reachable from pup
/// through the same wiki-proxy host pattern.
fn proxy_url_for_port(port: u16) -> String {
    if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
        return format!("https://{host}/proxy/{port}/");
    }
    if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {
        return format!("https://{slug}.adom.cloud/proxy/{port}/");
    }
    // Hydrogen / code-server exposes the public proxy template here, e.g.
    // https://<slug>.adom.cloud/proxy/{{port}}/ — substitute the real port.
    if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") {
        if tpl.contains("{{port}}") {
            return tpl.replace("{{port}}", &port.to_string());
        }
    }
    format!("http://127.0.0.1:{port}/")
}

fn urlencode(s: &str) -> String {
    let mut out = String::new();
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {
                out.push(b as char);
            }
            _ => out.push_str(&format!("%{:02X}", b)),
        }
    }
    out
}

// POST /api/reveal {"mpn": "<MPN>"} → reveals library/<MPN>/ in VS Code Explorer.
// Empty mpn (or omitted) reveals the library root. Path is resolved against
// library::root() and rejected if it escapes via .. or contains a slash.
fn reveal_in_vscode(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let parsed: serde_json::Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
    };
    let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();
    let target = if mpn.is_empty() {
        crate::library::root()
    } else {
        if mpn.contains('/') || mpn.contains("..") || mpn.contains('\\') {
            return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());
        }
        let dir = crate::library::root().join(mpn);
        if !dir.exists() {
            return error_response(404, format!("{{\"error\":\"no library folder for mpn {:?}\"}}", mpn));
        }
        dir
    };
    let path_str = target.to_string_lossy().to_string();
    match std::process::Command::new("adom-vscode")
        .args(["reveal", &path_str])
        .output()
    {
        Ok(out) if out.status.success() => {
            json_response(format!("{{\"ok\":true,\"path\":{:?}}}", path_str))
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr).to_string();
            error_response(500, format!("{{\"error\":\"adom-vscode failed\",\"stderr\":{:?}}}", stderr))
        }
        Err(e) => error_response(
            500,
            format!("{{\"error\":\"adom-vscode not runnable: {}\"}}", e),
        ),
    }
}

fn urldecode(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'%' && i + 2 < bytes.len() {
            if let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i+1..i+3]).unwrap_or(""), 16) {
                out.push(b as char);
                i += 3;
                continue;
            }
        } else if bytes[i] == b'+' {
            out.push(' ');
            i += 1;
            continue;
        }
        out.push(bytes[i] as char);
        i += 1;
    }
    out
}

fn error_response(code: u16, body: String) -> Response<std::io::Cursor<Vec<u8>>> {
    let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
    Response::from_string(body).with_status_code(code).with_header(h)
}

fn html_response(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let h = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();
    Response::from_string(body.to_string()).with_header(h)
}

fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> {
    let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
    Response::from_string(body).with_header(h)
}

/// Brutal self-audit: for every chip (the active project if set), report ✓/✗ for
/// EVERY expected artifact — datasheet, 3D STEP, KiCad symbol+footprint, the card
/// thumbnails (symbol.svg / footprint.svg / 3d-iso.png), the Fusion/EAGLE .lbr,
/// and info.json. A chip is "complete" ONLY if every REQUIRED box is green. This
/// is the tab that stops anyone (human or AI) from calling it done when it isn't.
fn validate_json() -> Response<std::io::Cursor<Vec<u8>>> {
    use serde_json::json;
    let root = crate::library::root();
    let active = crate::projects::get_active();
    let project_mpns: Option<Vec<String>> = active.as_ref().map(|p| crate::projects::mpns_for_project(p));

    let valid_pdf = |p: std::path::PathBuf| -> bool {
        std::fs::File::open(&p).ok().and_then(|mut f| {
            use std::io::Read;
            let mut b = [0u8; 4];
            f.read_exact(&mut b).ok().map(|_| &b == b"%PDF")
        }).unwrap_or(false)
    };
    let valid_step = |p: std::path::PathBuf| -> bool {
        std::fs::read_to_string(&p).ok().map(|s| s.contains("ISO-10303")).unwrap_or(false)
    };
    let nonempty = |p: std::path::PathBuf, min: u64| -> bool {
        std::fs::metadata(&p).map(|m| m.len() >= min).unwrap_or(false)
    };

    let mut chips = Vec::new();
    let (mut total, mut complete) = (0u32, 0u32);
    if let Ok(rd) = std::fs::read_dir(&root) {
        let mut dirs: Vec<_> = rd.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).collect();
        dirs.sort_by_key(|e| e.file_name());
        for e in dirs {
            let mpn = e.file_name().to_string_lossy().to_string();
            if let Some(ref pm) = project_mpns {
                if !pm.iter().any(|m| m == &mpn) { continue; }
            }
            let d = e.path();
            // REQUIRED — files a complete card MUST have
            let pdf = valid_pdf(d.join(format!("{mpn}.pdf")));
            let step = valid_step(d.join(format!("{mpn}.step")));
            let sym = nonempty(d.join(format!("{mpn}.kicad_sym")), 50);
            let foot = nonempty(d.join(format!("{mpn}.kicad_mod")), 50);
            let sym_svg = nonempty(d.join(format!("{mpn}-symbol.svg")), 500);
            let fp_svg = nonempty(d.join(format!("{mpn}-footprint.svg")), 500);
            let thumb3d = nonempty(d.join(format!("{mpn}-3d-iso.png")), 2000);
            // EXTRA — nice-to-have
            let lbr = nonempty(d.join(format!("{mpn}.lbr")), 50);
            let info = d.join("info.json").exists();
            // Description: the human "what is this chip" line shown on the card +
            // zoom. Tracked so a blank one is impossible to miss in the audit.
            let has_desc = std::fs::read_to_string(d.join("info.json")).ok()
                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
                .and_then(|v| v.get("description").and_then(|x| x.as_str()).map(|s| s.trim().len() >= 10))
                .unwrap_or(false);

            // adom-concur cross-source consensus: did the KiCad / EAGLE / Altium
            // / ds2sf variants of this part AGREE? Surface concur's verdict + the
            // per-axis agree/disagree tally so the audit shows consistency, not
            // just file presence.
            let consensus = std::fs::read_to_string(d.join(format!("{mpn}-concur.result.json"))).ok()
                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
                .map(|c| {
                    let axes = c.get("axes").and_then(|a| a.as_array()).cloned().unwrap_or_default();
                    let count = |v: &str| axes.iter().filter(|a| a.get("verdict").and_then(|x| x.as_str()) == Some(v)).count();
                    json!({
                        "status": c.get("status").and_then(|s| s.as_str()).unwrap_or("?"),
                        "sources": c.get("sources_compared").cloned().unwrap_or(json!([])),
                        "missing": c.get("sources_missing").cloned().unwrap_or(json!([])),
                        "agree": count("agree"), "disagree": count("disagree"), "total_axes": axes.len()
                    })
                });

            let required = [pdf, step, sym, foot, sym_svg, fp_svg, thumb3d];
            let all = required.iter().all(|&b| b);
            total += 1;
            if all { complete += 1; }
            chips.push(json!({
                "mpn": mpn,
                "complete": all,
                "required": {
                    "datasheet_pdf": pdf, "step_3d": step,
                    "kicad_symbol": sym, "kicad_footprint": foot,
                    "thumb_symbol": sym_svg, "thumb_footprint": fp_svg, "thumb_3d": thumb3d
                },
                "extra": { "description": has_desc, "fusion_lbr": lbr, "info_json": info },
                "consensus": consensus,
                "sources": per_source_breakdown(&d, &mpn, step)
            }));
        }
    }
    let body = json!({
        "project": active,
        "summary": { "total": total, "complete": complete, "incomplete": total - complete },
        "required_keys": ["datasheet_pdf","step_3d","kicad_symbol","kicad_footprint","thumb_symbol","thumb_footprint","thumb_3d"],
        "extra_keys": ["description","fusion_lbr","info_json"],
        "chips": chips
    }).to_string();
    json_response(body)
}

/// Per-source-library breakdown for the Validation tab's child rows: for each
/// library we downloaded (CSE / UltraLibrarian / SnapEDA / ds2sf), which of
/// symbol / footprint / 3D did we generate from IT. 3D is special: a library
/// that shipped its OWN STEP (`<mpn>.<method>.step`) gets a normal check; one
/// that didn't gets a check with an asterisk (we used the global chip STEP).
fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) -> serde_json::Value {
    use serde_json::json;
    let has = |f: String| d.join(f).is_file();

    // The DISCOVERY source (where the CAD was found) labels the format lines —
    // CSE / SnapEDA / UltraLibrarian — NOT the content_origin ("manufacturer"),
    // because CSE is an aggregator, not the maker. Read it from file-provenance.
    let prov = std::fs::read_to_string(d.join(format!("{mpn}-file-provenance.json"))).ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
    let disc = prov.as_ref().and_then(|p| p.as_object())
        .and_then(|m| m.values().find_map(|e| e.get("discovery_source").and_then(|s| s.as_str())))
        .unwrap_or("cse");
    // Manufacturer name (for naming the maker's own site when it's truly direct).
    let manufacturer = std::fs::read_to_string(d.join("info.json")).ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| v.get("manufacturer").and_then(|m| m.as_str()).map(String::from))
        .unwrap_or_default();
    // PROVENANCE LABELING RULE (John, 2026-06-23): label by WHERE it was actually
    // downloaded, never the content_origin. ONLY the maker's OWN site counts as
    // "the manufacturer" — and then NAME the site (ti.com / analog.com / st.com).
    // The moment they hand off to UltraLibrarian / SnapMagic / Component Search
    // Engine, that aggregator is the source — NOT the manufacturer.
    let src_label = |disc: &str| crate::provenance::source_label(disc, &manufacturer);
    let src = src_label(disc);

    // Where the GLOBAL chip STEP came from — for the asterisk tooltip on lines
    // whose own library had no 3D ("we swapped in the STEP we got from <X>").
    let step_e = prov.as_ref().and_then(|p| p.get(format!("{mpn}.step")));
    let step_disc = step_e.and_then(|e| e.get("discovery_source")).and_then(|s| s.as_str()).unwrap_or(disc);
    let step_source = src_label(step_disc);
    let step_url = step_e.and_then(|e| e.get("url")).and_then(|s| s.as_str())
        .filter(|s| !s.is_empty()).map(String::from);

    // Download link for a file (relative path under /asset/<mpn>/). UI prepends
    // API_BASE. `original=true` = the raw archive/file we pulled off the internet
    // (for the user to inspect); `false` = a specific file we extracted from it.
    let dl = |name: &str, file: String, original: bool| json!({ "name": name, "file": file, "original": original });
    // The original CSE bundle zip (the exact bytes downloaded) — shared by every
    // EDA-format line, since CSE ships one archive with all formats inside.
    let cse_zip = has(format!("{mpn}.cse-bundle.zip"));

    let mut lines: Vec<serde_json::Value> = Vec::new();

    // 1) Manufacturer DATASHEET → ds2sf extraction is its OWN source. The
    //    datasheet has no 3D, so its 3D cell is the global chip STEP (asterisk).
    let ds_tried = has(format!("{mpn}-extraction.result.json"));
    let ds_sym = has(format!("{mpn}-symbol.extracted.json")) || has(format!("{mpn}-symbol.ds2sf.svg"));
    let ds_fp = has(format!("{mpn}-footprint.extracted.json")) || has(format!("{mpn}-footprint.ds2sf.svg"));
    if ds_tried || ds_sym || ds_fp {
        let mut files = vec![];
        // The datasheet PDF IS the original download for this line.
        if has(format!("{mpn}.pdf")) { files.push(dl("datasheet (PDF)", format!("{mpn}.pdf"), true)); }
        // Why is ds2sf red? Surface the extractor's own reason so the ✗ explains itself.
        let note = std::fs::read_to_string(d.join(format!("{mpn}-extraction.result.json"))).ok()
            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
            .and_then(|r| {
                let status = r.get("status").and_then(|s| s.as_str()).unwrap_or("");
                let summary = r.get("summary").and_then(|s| s.as_str()).unwrap_or("");
                if status == "ok" { None } else {
                    Some(format!("ds2sf could not extract (status: {status}). {summary}"))
                }
            });
        // The datasheet is fetched from the maker's own lit server → name the site.
        lines.push(json!({ "label": format!("PDF Datasheet from {}", src_label("manufacturer")),
            "sym": ds_sym, "fp": ds_fp, "three_d": has_global_step, "three_d_from_lib": false,
            "step_source": step_source, "step_url": step_url, "files": files, "note": note }));
    }

    // 2) One line PER EDA FORMAT in the downloaded bundle — each library has its
    //    own symbol + footprint, and (for CSE/UL/SnapEDA bundles) its own STEP.
    //    Each line carries download links to the EXACT files we used.
    let three_lib = json!(has_global_step); // CSE/UL/SnapEDA bundles ship the STEP with each format
    let orig_zip = |files: &mut Vec<serde_json::Value>| {
        if cse_zip { files.push(dl("original bundle (.zip)", format!("{mpn}.cse-bundle.zip"), true)); }
    };
    let ki = (has(format!("{mpn}.kicad_sym")), has(format!("{mpn}.kicad_mod")));
    if ki.0 || ki.1 {
        let mut files = vec![];
        if ki.0 { files.push(dl(".kicad_sym", format!("{mpn}.kicad_sym"), false)); }
        if ki.1 { files.push(dl(".kicad_mod", format!("{mpn}.kicad_mod"), false)); }
        if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }
        orig_zip(&mut files);
        lines.push(json!({ "label": format!("{src} - KiCad Library"), "sym": ki.0, "fp": ki.1,
            "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));
    }
    let al = (has(format!("{mpn}.SchLib")), has(format!("{mpn}.PcbLib")));
    if al.0 || al.1 {
        let mut files = vec![];
        if al.0 { files.push(dl(".SchLib", format!("{mpn}.SchLib"), false)); }
        if al.1 { files.push(dl(".PcbLib", format!("{mpn}.PcbLib"), false)); }
        if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }
        orig_zip(&mut files);
        lines.push(json!({ "label": format!("{src} - Altium Library"), "sym": al.0, "fp": al.1,
            "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));
    }
    // EAGLE/Fusion .lbr is a single file holding BOTH the device (symbol) + package (footprint).
    if has(format!("{mpn}.lbr")) {
        let mut files = vec![dl(".lbr", format!("{mpn}.lbr"), false)];
        if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }
        orig_zip(&mut files);
        lines.push(json!({ "label": format!("{src} - Fusion 360 Library"), "sym": true, "fp": true,
            "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));
    }
    json!(lines)
}

/// The CANONICAL sourcing-priority — the single source of truth for "what order
/// chip-fetcher searches the entire internet for electronics data." The
/// dashboard's Sourcing tab renders this verbatim, and the AI must follow it.
/// Three concerns, each with its own ordered ladder. ALWAYS manufacturer-first
/// for CAD; ALWAYS the Adom data tools (keys baked in) for metadata; the manual
/// CAD-crawl skills are the FALLBACK, in order, with LCSC dead last.
fn sources_json() -> Response<std::io::Cursor<Vec<u8>>> {
    use serde_json::json;
    let body = json!({
        "concerns": [
            {
                "concern": "1 · Identity & metadata",
                "what": "What the part IS — description, manufacturer, orderable MPN/package variants, stock, pricing, lead time, lifecycle (active/NRND/EOL).",
                "rule": "ALWAYS query the Adom data tools first — their API keys are baked into their own service containers, so they ALWAYS work with NO key needed. They also resolve the orderable MPN (base ADS1263 → ADS1263IPWR) that CAD sites need.",
                "steps": [
                    {"tier": "Adom tool", "name": "adom-parts-search", "detail": "THE umbrella — searches Mouser + DigiKey + JLCPCB IN PARALLEL, returns description, stock, pricing, lead time, lifecycle, image, datasheet URL, and every orderable package variant. Mouser-preferred (40-min drone delivery, Fort Worth).", "cmd": "adom-parts-search search <MPN>"},
                    {"tier": "Adom tool", "name": "adom-mouser", "detail": "Single-vendor deep lookup + richest stock/lifecycle. Backend keeps the key server-side (`adom-mouser serve`).", "cmd": "adom-mouser part <MPN>"},
                    {"tier": "Adom tool", "name": "adom-digikey", "detail": "DigiKey catalog — broadest coverage, parametric + lifecycle.", "cmd": "adom-digikey part <MPN>"},
                    {"tier": "Adom tool", "name": "adom-jlcpcb", "detail": "JLCPCB / LCSC catalog — assembly stock + basic/extended part class.", "cmd": "adom-jlcpcb part <MPN>"}
                ]
            },
            {
                "concern": "2 · Datasheet (PDF)",
                "what": "The manufacturer datasheet.",
                "rule": "Manufacturer's own lit server first; Adom tools give the fallback link.",
                "steps": [
                    {"tier": "Manufacturer", "name": "Manufacturer lit server", "detail": "e.g. ti.com/lit/ds/symlink/<part>.pdf, st.com, nxp.com. ALWAYS try first.", "cmd": "chip-fetcher pup-pdf-fetch <MPN> --pdf-url <url>"},
                    {"tier": "Adom tool", "name": "adom-parts-search datasheet_url", "detail": "Mouser/DigiKey expose a datasheet link when the mfr URL is unknown.", "cmd": "adom-parts-search search <MPN>"}
                ]
            },
            {
                "concern": "3 · CAD bundle (STEP + symbol + footprint)",
                "what": "The 3D STEP model + KiCad/Altium/Fusion symbol & footprint.",
                "rule": "MANUFACTURER-FIRST, always. Exhaust the maker's own CAD/Resources before any aggregator. Then fall back DOWN this ladder IN ORDER — never skip ahead. LCSC is the LAST resort; surface 'no manufacturer CAD' to the user before reaching for it.",
                "steps": [
                    {"tier": "Manufacturer", "name": "Manufacturer site", "detail": "vendor.com CAD / Design Resources tab — the authoritative STEP + footprint. ALWAYS first, even when confident.", "cmd": "chip-fetcher mfr-probe <MPN>"},
                    {"tier": "Aggregator skill", "name": "SnapMagic / SnapEDA", "detail": "Free CAD bundles, login-gated. Right after the manufacturer.", "skill": "playbooks/snapmagic.md"},
                    {"tier": "Aggregator skill", "name": "Mouser CSE (Component Search Engine / SamacSys)", "detail": "Mouser's ECAD Model / Library Loader. Same-origin XHR + Basic-auth; needs the ORDERABLE MPN (from step 1).", "cmd": "chip-fetcher cse <MPN> --mfr <name>", "skill": "playbooks/mouser-cse.md"},
                    {"tier": "Aggregator skill", "name": "DigiKey Footprints & Models", "detail": "DigiKey's /models/<id> page — mfr STEP + Ultra Librarian + SnapMagic.", "skill": "playbooks/digikey-models.md"},
                    {"tier": "Aggregator skill", "name": "Arrow", "detail": "Niche distributor CAD.", "skill": "playbooks/sourcing-ladder.md"},
                    {"tier": "Aggregator skill", "name": "UltraLibrarian / Component Search Engine", "detail": "Only when the manufacturer links there explicitly. Captcha or UL-Pro login flows.", "skill": "playbooks/ultra-librarian.md"},
                    {"tier": "Last resort", "name": "LCSC", "detail": "datasheet.lcsc.com — LAST RESORT. Avoid if possible; tell the user we couldn't find it on the manufacturer's site first.", "skill": "playbooks/sourcing-ladder.md"}
                ]
            }
        ]
    }).to_string();
    json_response(body)
}

fn library_json() -> Response<std::io::Cursor<Vec<u8>>> {
    match crate::inventory::scan() {
        Ok(lib) => json_response(serde_json::to_string(&lib).unwrap_or_default()),
        Err(e) => {
            let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
            Response::from_string(format!("{{\"error\":{:?}}}", e.to_string()))
                .with_status_code(500)
                .with_header(h)
        }
    }
}

fn projects_json() -> Response<std::io::Cursor<Vec<u8>>> {
    let store = crate::projects::load();
    json_response(serde_json::to_string(&store).unwrap_or_default())
}

fn chip_json(mpn: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    match crate::inventory::scan() {
        Ok(lib) => match lib.chips.into_iter().find(|c| c.mpn == mpn) {
            Some(c) => json_response(serde_json::to_string(&c).unwrap_or_default()),
            None => not_found(),
        },
        Err(_) => not_found(),
    }
}

fn serve_thumb(rel: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    if rel.contains("..") { return not_found(); }
    let parts: Vec<&str> = rel.splitn(2, '/').collect();
    if parts.len() != 2 { return not_found(); }
    let mpn = parts[0];
    let kind = parts[1];
    // chip-thumbnailer's canonical naming. URL keeps short kinds for nice
    // dashboard hrefs (/thumb/<MPN>/symbol|footprint|chip3d).
    // Per-source variant thumbnails: symbol.<method> / footprint.<method>
    // → <mpn>-symbol.<method>.svg (e.g. symbol.ds2sf, symbol.snapeda).
    if let Some(rest) = kind.strip_prefix("symbol.").or_else(|| kind.strip_prefix("footprint.")) {
        let pre = if kind.starts_with("symbol.") { "symbol" } else { "footprint" };
        if rest.contains('/') || rest.contains("..") { return not_found(); }
        let path = crate::library::root().join(mpn).join(format!("{mpn}-{pre}.{rest}.svg"));
        return match std::fs::read(&path) {
            Ok(b) => Response::from_data(b)
                .with_header(Header::from_bytes(&b"Content-Type"[..], &b"image/svg+xml"[..]).unwrap())
                .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=60"[..]).unwrap()),
            Err(_) => not_found(),
        };
    }
    let (filename, ct) = match kind {
        "symbol"        => (format!("{mpn}-symbol.svg"),         "image/svg+xml"),
        "footprint"     => (format!("{mpn}-footprint.svg"),      "image/svg+xml"),
        "chip3d"           => (format!("{mpn}-3d-iso.png"),           "image/png"),
        "chip3d-yup"       => (format!("{mpn}-3d-iso-yup.png"),       "image/png"),
        "chip3d-xup"       => (format!("{mpn}-3d-iso-xup.png"),       "image/png"),
        "chip3d-xdown"     => (format!("{mpn}-3d-iso-xdown.png"),     "image/png"),
        "chip3d-ydown"     => (format!("{mpn}-3d-iso-ydown.png"),     "image/png"),
        "chip3d-zdown"     => (format!("{mpn}-3d-iso-zdown.png"),     "image/png"),
        // Large variants
        "chip3d-lg"        => (format!("{mpn}-3d-iso-lg.png"),        "image/png"),
        "chip3d-yup-lg"    => (format!("{mpn}-3d-iso-yup-lg.png"),    "image/png"),
        "chip3d-xup-lg"    => (format!("{mpn}-3d-iso-xup-lg.png"),    "image/png"),
        "chip3d-xdown-lg"  => (format!("{mpn}-3d-iso-xdown-lg.png"),  "image/png"),
        "chip3d-ydown-lg"  => (format!("{mpn}-3d-iso-ydown-lg.png"),  "image/png"),
        "chip3d-zdown-lg"  => (format!("{mpn}-3d-iso-zdown-lg.png"),  "image/png"),
        _ => return not_found(),
    };
    let path: PathBuf = crate::library::root().join(mpn).join(filename);
    let bytes = match std::fs::read(&path) {
        Ok(b) => b,
        Err(_) => return not_found(),
    };
    let h = Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap();
    let cc = Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=60"[..]).unwrap();
    Response::from_data(bytes).with_header(h).with_header(cc)
}

fn serve_asset(rel: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    if rel.contains("..") { return not_found(); }
    let parts: Vec<&str> = rel.splitn(2, '/').collect();
    if parts.len() != 2 { return not_found(); }
    let path: PathBuf = crate::library::root().join(parts[0]).join(parts[1]);
    let bytes = match std::fs::read(&path) {
        Ok(b) => b,
        Err(_) => return not_found(),
    };
    let ct = guess_content_type(&path);
    let h = Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap();
    Response::from_data(bytes).with_header(h)
}

fn guess_content_type(path: &PathBuf) -> &'static str {
    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
    match ext.as_str() {
        "pdf" => "application/pdf",
        "step" | "stp" => "application/STEP",
        "json" => "application/json",
        "kicad_mod" | "kicad_sym" => "text/plain; charset=utf-8",
        "html" => "text/html; charset=utf-8",
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        _ => "application/octet-stream",
    }
}

fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string("not found").with_status_code(404)
}

/// Serve `<MPN>-footprint.authoritative.svg` from library/<MPN>/. ds2sf
/// v0.5.1+ writes this; the dashboard renders it inline as the canonical
/// 2D footprint reference next to the chip-thumbnailer 3D thumbs.
fn serve_authoritative_svg(mpn: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    if mpn.contains("..") || mpn.contains('/') {
        return not_found();
    }
    let path = crate::library::root().join(mpn).join(format!("{mpn}-footprint.authoritative.svg"));
    match std::fs::read(&path) {
        Ok(bytes) => Response::from_data(bytes)
            .with_header("Content-Type: image/svg+xml".parse::<tiny_http::Header>().unwrap())
            .with_status_code(200),
        Err(_) => not_found(),
    }
}

/// Spawn `chip-fetcher <args>` as a detached subprocess so the dashboard's
/// click returns immediately while the action runs in the background. The
/// dashboard polls `/api/library` to see the new state. Activity heartbeats
/// from the subprocess will surface mid-run progress automatically.
fn spawn_subprocess_action(
    label: &str,
    args: &[&str],
    mpn: &str,
) -> Response<std::io::Cursor<Vec<u8>>> {
    use std::process::{Command, Stdio};
    // Self-invoke by absolute path — name-independent so the rename to
    // adom-chip-fetcher (or any future rename) can't break it.
    let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into());
    let mut cmd = Command::new(exe);
    cmd.args(args)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .stdin(Stdio::null());
    match cmd.spawn() {
        Ok(_child) => {
            let _ = crate::hints::heartbeat(mpn, &format!("dashboard kicked: {label}"));
            json_response(format!(
                "{{\"ok\":true,\"action\":\"{label}\",\"mpn\":{:?},\"hint\":\"polling /api/library shows new state\"}}",
                mpn
            ))
        }
        Err(e) => error_response(500, format!("{{\"error\":\"could not spawn chip-fetcher {label}: {e}\"}}")),
    }
}

pub fn open_in_hydrogen(port: u16) -> Result<()> {
    let url = format!("http://127.0.0.1:{port}");
    println!("opening {url} in Hydrogen webview");
    let status = std::process::Command::new("adom-cli")
        .args(["workspace", "tab-open", "--url", &url, "--title", "adom-chip-fetcher"])
        .status()
        .context("running adom-cli workspace tab-open")?;
    if !status.success() {
        anyhow::bail!("adom-cli workspace tab-open returned non-zero exit");
    }
    Ok(())
}