1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977
//! Adom Nucleus, a standalone web app (tiny_http → Hydrogen webview),
//! built on the `adom-lbr` codec. Lists your wiki boards/projects (Personal /
//! Organization / Public), and on click opens a viewer (symbol + footprint + 3D,
//! plus the footprint LAYER STACKUP showing what layer each graphic becomes in
//! KiCad and Altium), adds components to your KiCad/Altium libraries, and
//! delivers self-contained KiCad projects.
//!
//! Routes:
//!   GET  /                      the UI (list + viewer)
//!   GET  /api/components        list of wiki components
//!   GET  /api/part/<slug>       {part, symbol_svg, footprint_svg, layers, glb}
//!   GET  /glb/<slug>            proxied 3D model (GLB)

use altium_codec::{export_kicad_footprint, export_kicad_symbol, AdomLbrPart, Footprint, FpGraphic, FpLayer, Graphic, Symbol};
use anyhow::Result;
use serde_json::{json, Value};
use std::io::{Cursor, Read};
use std::process::Command;
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};

fn main() {
    // `--render-board <slug>`: regenerate + upload the page's render/ assets, then
    // exit (used by the publish flow). Otherwise serve on `--port <n>` (default 8785).
    let mut port = 8785u16;
    let mut args = std::env::args().skip(1);
    while let Some(a) = args.next() {
        if a == "--port" || a == "-p" {
            if let Some(v) = args.next() {
                port = v.parse().unwrap_or(port);
            }
        } else if let Some(v) = a.strip_prefix("--port=") {
            port = v.parse().unwrap_or(port);
        } else if a == "--render-board" {
            let slug = args.next().unwrap_or_default();
            std::process::exit(if !slug.is_empty() && render_and_upload(&slug) { 0 } else { 1 });
        }
    }
    if let Err(e) = run(port) {
        eprintln!("ERROR: {e}");
        std::process::exit(1);
    }
}

/// Render a board's GLB + PCB SVG + schematic SVGs live and publish them to the
/// page's `render/` folder (with a manifest), so the Manager can serve them
/// instantly. Run on every board publish/update to keep the assets fresh.
fn render_and_upload(slug: &str) -> bool {
    eprintln!("rendering {slug} …");
    let (sheets, pcb_svg, glb) = render_board_media(slug);
    if glb.is_none() && pcb_svg.is_none() && sheets.is_empty() {
        eprintln!("nothing rendered (not a KiCad board?)");
        return false;
    }
    let work = format!("{SCRATCH}/render-{}", slug.replace(['/', ' '], "_"));
    let rdir = format!("{work}/render");
    let _ = std::fs::remove_dir_all(&work);
    if std::fs::create_dir_all(&rdir).is_err() {
        return false;
    }
    if let Some(g) = &glb {
        let _ = std::fs::write(format!("{rdir}/board.glb"), g);
    }
    if let Some(s) = &pcb_svg {
        let _ = std::fs::write(format!("{rdir}/pcb.svg"), s);
    }
    for (i, (_, svg)) in sheets.iter().enumerate() {
        let _ = std::fs::write(format!("{rdir}/sheet-{i:03}.svg"), svg);
    }
    let sheet_names: Vec<String> = sheets.iter().map(|(n, _)| n.clone()).collect();

    // ---- version archive -------------------------------------------------
    // Content-hash the just-rendered assets. A new version is archived ONLY when
    // the render actually differs from the last archived one (a deps/metadata-only
    // republish reuses the prior version → no duplicate visual version). Older
    // render/<version>/ folders on the page are preserved (we push targeted paths).
    let new_hash = {
        use std::hash::{Hash, Hasher};
        let mut h = std::collections::hash_map::DefaultHasher::new();
        if let Some(g) = &glb { g.hash(&mut h); }
        if let Some(s) = &pcb_svg { s.as_bytes().hash(&mut h); }
        for (_, svg) in &sheets { svg.as_bytes().hash(&mut h); }
        sheet_names.hash(&mut h);
        format!("{:016x}", h.finish())
    };
    let version = wiki_text(slug, "package.json")
        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
        .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
        .unwrap_or_else(|| "0.0.0".to_string());
    // prior versions[] from the existing manifest (empty on first archive / legacy pages)
    let mut versions: Vec<Value> = wiki_bytes(slug, "render/manifest.json")
        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
        .and_then(|m| m.get("versions").and_then(|a| a.as_array()).cloned())
        .unwrap_or_default();
    let latest_hash = versions.first().and_then(|v| v.get("hash").and_then(|h| h.as_str())).unwrap_or("");
    let changed = latest_hash != new_hash;
    if changed {
        // stage this version's assets under render/<version>/ and index it
        let vdir = format!("{rdir}/{version}");
        let _ = std::fs::create_dir_all(&vdir);
        if let Some(g) = &glb { let _ = std::fs::write(format!("{vdir}/board.glb"), g); }
        if let Some(s) = &pcb_svg { let _ = std::fs::write(format!("{vdir}/pcb.svg"), s); }
        for (i, (_, svg)) in sheets.iter().enumerate() { let _ = std::fs::write(format!("{vdir}/sheet-{i:03}.svg"), svg); }
        // drop any existing entry for this version string, then prepend the fresh one
        versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version.as_str()));
        versions.insert(0, json!({"version": version, "hash": new_hash,
            "glb": glb.is_some(), "pcb": pcb_svg.is_some(), "sheets": sheet_names}));
    }
    let current = versions.first().and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
        .unwrap_or_else(|| version.clone());
    let manifest = json!({
        "v": 2,
        "current": current,
        "glb": glb.is_some(),
        "pcb": pcb_svg.is_some(),
        "sheets": sheet_names,
        "versions": versions,
    });
    let _ = std::fs::write(format!("{rdir}/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap());

    // Push TARGETED paths (not the whole render/ dir) so previously-archived
    // render/<version>/ folders on the page are not clobbered.
    let bare = slug.rsplit('/').next().unwrap_or(slug);
    let mut files: Vec<String> = vec!["render/manifest.json".into()];
    if glb.is_some() { files.push("render/board.glb".into()); }
    if pcb_svg.is_some() { files.push("render/pcb.svg".into()); }
    for i in 0..sheets.len() { files.push(format!("render/sheet-{i:03}.svg")); }
    if changed { files.push(format!("render/{version}")); }
    let mut args: Vec<String> = vec!["repo".into(), "push".into(), bare.into(), "--files".into()];
    args.extend(files);
    args.push("-m".into());
    args.push(format!("render assets{}", if changed { format!(" + archive v{version}") } else { String::new() }));
    let ok = Command::new("adom-wiki").args(&args).current_dir(&work)
        .status().map(|s| s.success()).unwrap_or(false);
    let msg = if !ok { "upload FAILED".to_string() }
        else if changed { format!("uploaded render/ + archived v{version}") }
        else { "uploaded render/ (no content change → no new version)".to_string() };
    eprintln!("{msg}");
    ok
}

const WIKI: &str = "https://wiki.adom.inc";
// Hosted headless KiCad 10 (service-kicad), renders board SVG/GLB server-side,
// so board views need no local kicad-cli and no desktop bridge.
const KICAD_SVC: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";

/// The wiki component list is slow (~8s) but stable, fetch it once, serve
/// instantly after. First click blocks; the rest are snappy.
static LIST_CACHE: Mutex<Option<String>> = Mutex::new(None);

fn cached_list() -> String {
    let mut c = LIST_CACHE.lock().unwrap();
    if let Some(s) = c.as_ref() {
        return s.clone();
    }
    let s = list_components().to_string();
    *c = Some(s.clone());
    s
}

/// Follow a dotted path (`a.b.c`) into a JSON value.
fn dig<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
    let mut cur = v;
    for k in path.split('.') {
        cur = cur.get(k)?;
    }
    Some(cur)
}

/// The signed-in wiki identity (username + org names) from `adom-wiki whoami`,
/// cached. Tolerant of JSON-shape differences across CLI versions; if it can't
/// be read it leaves the identity EMPTY rather than guessing, so the Personal and
/// Organization tabs simply show nothing instead of mislabeling every user.
static WHOAMI: Mutex<Option<(String, Vec<String>)>> = Mutex::new(None);
fn whoami() -> (String, Vec<String>) {
    if let Some(v) = WHOAMI.lock().unwrap().clone() {
        return v;
    }
    let mut me = String::new();
    let mut orgs: Vec<String> = vec![];
    if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() {
        if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {
            me = ["data.user.username", "data.user.name", "data.user.handle", "user.username", "data.username", "username"]
                .iter()
                .find_map(|p| dig(&v, p).and_then(|x| x.as_str()).filter(|s| !s.is_empty()))
                .unwrap_or_default()
                .to_string();
            if let Some(a) = dig(&v, "data.orgs").or_else(|| v.get("orgs")).and_then(|o| o.as_array()) {
                orgs = a.iter().filter_map(|o| o.get("name").and_then(|s| s.as_str()).or_else(|| o.as_str()).filter(|s| !s.is_empty()).map(String::from)).collect();
            }
        }
    }
    let res = (me, orgs);
    *WHOAMI.lock().unwrap() = Some(res.clone());
    res
}

// Per-user "starred" comes straight from the wiki page data (pending the wiki API
// adding a per-user `starred` field, tracked in adom/wiki issues). Starred boards
// pin to the top of each list; there is no star toggle in this app (you star on
// the wiki). Until the field lands, `starred` is simply absent → false for all.

pub fn run(port: u16) -> Result<()> {
    // Loopback by default: /api/apply writes files and runs commands on the
    // user's machine via the bridge, so it must not be LAN-reachable.
    // ADOM_LBR_BIND=0.0.0.0 opts back in (e.g. Hydrogen Desktop port-forward).
    let bind = std::env::var("ADOM_LBR_BIND").unwrap_or_else(|_| "127.0.0.1".into());
    let server = Server::http(format!("{bind}:{port}"))
        .map_err(|e| anyhow::anyhow!("bind {bind}:{port}: {e}"))?;
    println!("OK: Adom Nucleus on http://localhost:{port}");
    println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");
    println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");
    // Handle each request on its own thread. Many endpoints block on the desktop
    // bridge (folder listing, library scans, renders); a single-threaded loop would
    // serialize them, so e.g. the folder browser could wait tens of seconds behind
    // the Add-Project library scan. All shared state is Mutex-guarded, so concurrent
    // handlers are safe.
    for mut req in server.incoming_requests() {
        std::thread::spawn(move || {
            let method = req.method().clone();
            let url = req.url().to_string();
            let mut body = String::new();
            if matches!(method, Method::Post) {
                // /api/apply bodies are small JSON, cap far above any real payload.
                let _ = req.as_reader().take(1024 * 1024).read_to_string(&mut body);
            }
            let resp = route(&method, &url, &body);
            let _ = req.respond(resp);
        });
    }
    Ok(())
}

fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {
    let path = url.split('?').next().unwrap_or(url);
    match (method, path) {
        (Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),
        // toast channel: the AI narrates over the running UI with `POST /ui/toast`
        // ({message,type}); the frontend drains the queue via `GET /ui/toasts`.
        (Method::Post, "/ui/toast") => {
            let v: serde_json::Value = serde_json::from_str(body).unwrap_or(json!({}));
            let msg = v.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string();
            let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("info").to_string();
            if !msg.is_empty() { toast_queue().lock().unwrap().push(json!({"message": msg, "type": ty})); }
            json_resp("{\"ok\":true}")
        }
        (Method::Get, "/ui/toasts") => {
            let drained: Vec<serde_json::Value> = std::mem::take(&mut *toast_queue().lock().unwrap());
            json_resp(&json!({"toasts": drained}).to_string())
        }
        // ── embedded Tools (chip-fetcher-lite, kicad-library-manager): Nucleus
        // launches each Node tool on demand and reverse-proxies it under
        // /tools/<slug>/ so it renders in an iframe on Nucleus's own origin (the
        // tools use document.baseURI-relative URLs, so a base-path proxy just works). ──
        (Method::Post, p) if p.starts_with("/tools/launch/") => {
            let slug = &p["/tools/launch/".len()..];
            match ensure_tool(slug) {
                Some(_) => json_resp(&json!({"ok": true, "base": format!("tools/{slug}/")}).to_string()),
                None => Response::from_string(json!({"ok": false, "error": "could not start tool"}).to_string()).with_status_code(502).with_header(hdr("application/json")),
            }
        }
        (m, p) if p.starts_with("/tools/") => {
            let after = &p["/tools/".len()..];
            let (slug, rest) = after.split_once('/').unwrap_or((after, ""));
            let query = url.split_once('?').map(|(_, q)| q).unwrap_or("");
            proxy_tool(slug, rest, query, m, body)
        }
        (Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()),
        (Method::Get, "/api/env") => json_resp(&api_env().to_string()),
        (Method::Get, "/api/libraries") => json_resp(&user_libraries().to_string()),
        (Method::Get, "/api/pick-folder") => json_resp(&pick_folder().to_string()),
        (Method::Get, p) if p.starts_with("/api/pick-folder-result") => {
            let tok = url.split_once("?f=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
            json_resp(&pick_folder_result(&tok).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/project-diff") => {
            let param = |k: &str| url.split_once(&format!("{k}=")).map(|(_, v)| urldecode(v.split('&').next().unwrap_or(v))).unwrap_or_default();
            json_resp(&project_diff(&param("board"), &param("dest")).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/in-library") => {
            let slugs: Vec<String> = url.split_once("?slugs=")
                .map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q)))
                .unwrap_or_default()
                .split(',').filter(|s| !s.is_empty()).map(String::from).collect();
            let matched: std::collections::HashMap<String, Value> = components_in_libs(&slugs)
                .into_iter().map(|(s, by, lib, fp)| (s, json!({"by": by, "lib": lib, "fpLib": fp}))).collect();
            json_resp(&json!(matched).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/suggest-path") => {
            let prefix = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
            json_resp(&suggest_path(&prefix).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/list-dirs") => {
            let pp = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
            json_resp(&list_dirs(&pp).to_string())
        }
        (Method::Get, "/") => html(INDEX_HTML),
        (Method::Get, "/pm/version") => Response::from_string(
            format!("{{\"version\":\"{}\"}}", env!("CARGO_PKG_VERSION"))).with_header(hdr("application/json")),
        // Update check: compares the running binary's version against the latest
        // published on the wiki (cached ~10min) so the UI can flag an available update.
        (Method::Get, "/api/update") => json_resp(&update_status().to_string()),
        (Method::Get, "/loader.gif") => Response::from_data(LOADER_GIF.to_vec())
            .with_header(hdr("image/gif"))
            .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=86400"[..]).unwrap()),
        (Method::Get, "/api/components") => {
            // ?refresh=1 busts the server-side cache so newly-pushed components appear
            if url.contains("refresh=1") {
                *LIST_CACHE.lock().unwrap() = None;
            }
            json_resp(&cached_list())
        }
        (Method::Get, p) if p.starts_with("/api/part/") => {
            let slug = &p["/api/part/".len()..];
            json_resp(&get_part(slug).to_string())
        }
        (Method::Get, p) if p.starts_with("/part-embed/") => {
            // Interactive symbol/footprint viewer from adom-symbol/adom-footprint `embed`,
            // served for an <iframe>. Forms: /part-embed/<slug>/symbol | /part-embed/<slug>/footprint
            let rest = &p["/part-embed/".len()..];
            let (slug, kind) = rest.rsplit_once('/').unwrap_or((rest, ""));
            match part_embed(slug, kind) {
                Some(html) => Response::from_string(html).with_header(hdr("text/html; charset=utf-8")),
                None => Response::from_string("<p style='font:13px system-ui;color:#8b93a3;padding:20px'>viewer not available</p>")
                    .with_header(hdr("text/html; charset=utf-8")).with_status_code(404),
            }
        }
        (Method::Get, p) if p.starts_with("/api/board/") => {
            let slug = &p["/api/board/".len()..];
            json_resp(&board_deps(slug).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/project/") => {
            let slug = &p["/api/project/".len()..];
            json_resp(&project_boards(slug).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/board-activity/") => {
            let slug = &p["/api/board-activity/".len()..];
            json_resp(&board_activity(slug).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/board-eda/") => {
            let slug = &p["/api/board-eda/".len()..];
            json_resp(&json!({"eda": board_eda_cached(slug)}).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/board-upaxis/") => {
            // The board GLB's declared up-axis (from step2glb's stamp). Version-aware.
            // Fetched once per board by the frontend, which caches it; not in the polled
            // board-media path. Absent stamp → null → frontend applies no rotation.
            let (path, ver) = split_ver(url);
            let slug = &path["/api/board-upaxis/".len()..];
            let glb = ver
                .and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))
                .or_else(|| wiki_bytes(slug, "render/board.glb"))
                .or_else(|| page_model_glb(slug));
            let up = glb.as_deref().and_then(glb_up_axis);
            json_resp(&json!({"up_axis": up}).to_string())
        }
        (Method::Get, p) if p.starts_with("/api/board-media/") => {
            let slug = &p["/api/board-media/".len()..];
            json_resp(&board_media(slug).to_string())
        }
        (Method::Get, p) if p.starts_with("/board-hero/") => {
            // Proxy a page's hero image (used as the project 3D-view fallback). Resolve
            // the hero descriptor → its image URL → serve the bytes. Works for billboard
            // SVG heroes and plain-image heroes alike, and avoids a cross-origin fetch.
            let slug = &p["/board-hero/".len()..];
            let hero = curl(&format!("{WIKI}/api/v1/pages/{slug}/hero"))
                .and_then(|b| serde_json::from_slice::<Value>(&b).ok());
            let ct = hero.as_ref().and_then(|h| h.get("content_type").and_then(|c| c.as_str()))
                .unwrap_or("image/png").to_string();
            let img = hero.as_ref()
                .and_then(|h| h.get("url").and_then(|u| u.as_str()))
                .and_then(|u| { let full = if u.starts_with("http") { u.to_string() } else { format!("{WIKI}{u}") }; curl(&full) });
            match img {
                Some(bytes) => Response::from_data(bytes).with_header(hdr(&ct)),
                None => Response::from_string("no hero").with_status_code(404),
            }
        }
        (Method::Get, p) if p.starts_with("/org-avatar/") => {
            // Proxy an organization's real avatar. Org avatars live in Carbon/Hydrogen; the
            // public Carbon API (carbon.adom.inc/orgs/<org>) returns an avatar_url on the
            // public cdn.adom.inc, which we fetch and serve (avoids a cross-origin fetch).
            let org = &p["/org-avatar/".len()..];
            let org = org.split(['/', '?']).next().unwrap_or(org);
            let img = curl(&format!("https://carbon.adom.inc/orgs/{org}"))
                .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
                .and_then(|m| m.get("avatar_url").and_then(|u| u.as_str()).map(String::from))
                .and_then(|u| curl(&u));
            match img {
                Some(bytes) => Response::from_data(bytes).with_header(hdr("image/png")),
                None => Response::from_string("no avatar").with_status_code(404),
            }
        }
        (Method::Get, p) if p.starts_with("/board-svg/") => {
            // forms: /board-svg/<slug>/pcb  or  /board-svg/<slug>/sch/<idx>  [?v=<version>]
            let (path, ver) = split_ver(url);
            let segs: Vec<&str> = path["/board-svg/".len()..].splitn(3, '/').collect();
            let slug = segs.first().copied().unwrap_or("");
            let sub = |dir: &str| match segs.get(1).copied() {
                Some("pcb") => Some(format!("{dir}pcb.svg")),
                Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).map(|i| format!("{dir}sheet-{i:03}.svg")),
                _ => None,
            };
            // a selected PAST version → serve from its render/<version>/ archive;
            // else the live in-memory render; else the current stored top-level render/.
            let svg = ver.as_ref()
                .and_then(|v| sub(&format!("render/{v}/")))
                .and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())
                .or_else(|| cached_board(slug).and_then(|(sheets, pcb, _)| match segs.get(1).copied() {
                    Some("pcb") => pcb,
                    Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).and_then(|i| sheets.get(i).map(|(_, s)| s.clone())),
                    _ => None,
                }))
                .or_else(|| sub("render/").and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok()));
            match svg {
                Some(s) => Response::from_string(s).with_header(hdr("image/svg+xml")),
                None => Response::from_string("not rendered").with_status_code(404),
            }
        }
        // /board-view/<slug>/enrich?mpn=&lcsc=, stock/price/MPN lookup on behalf of an
        // EMBEDDED viewer (an embed is a static HTML blob with no server of its own, and
        // its fetch("enrich") resolves here). Delegates to the viewer's own `enrich` verb.
        (Method::Get, p) if p.starts_with("/board-view/") && p.contains("/enrich") => {
            Response::from_string(board_view_enrich(url)).with_header(hdr("application/json"))
        }
        (Method::Get, p) if p.starts_with("/board-view/") => {
            // Interactive PCB / schematic view via adom-layout-viewer / adom-schematic-viewer.
            // forms: /board-view/<slug>/pcb  or  /board-view/<slug>/sch  [?v=<version>]
            let (path, ver) = split_ver(url);
            let segs: Vec<&str> = path["/board-view/".len()..].splitn(2, '/').collect();
            let slug = segs.first().copied().unwrap_or("");
            let kind = match segs.get(1).copied() { Some("pcb") => "pcb", Some("sch") => "sch", _ => "" };
            // ?sheet=<file>: render a specific hierarchical child sheet instead of the root
            let sheet = url.split_once('?').and_then(|(_, q)| q.split('&')
                .find_map(|kv| kv.strip_prefix("sheet="))).map(|s| pct(s))
                .filter(|s| !s.is_empty() && !s.contains("..") && !s.contains('/') && s.ends_with(".kicad_sch"));
            match render_board_view_sheet(slug, kind, ver.as_deref(), sheet.as_deref()) {
                Some(h) => Response::from_string(h).with_header(hdr("text/html; charset=utf-8")),
                None => Response::from_string("<p style='font:13px system-ui;color:#8b93a3;padding:24px'>interactive view unavailable (no KiCad file on this page)</p>").with_header(hdr("text/html; charset=utf-8")).with_status_code(404),
            }
        }
        (Method::Get, p) if p.starts_with("/board-glb/") => {
            let (path, ver) = split_ver(url);
            let slug = &path["/board-glb/".len()..];
            // A selected PAST version → its archived render/<version>/board.glb. Otherwise
            // serve the SAME 3D the wiki page shows: the page's model_3d GLB (Drew's
            // molecule-publish emits `<slug>.glb` and points model_3d_path at it), so the
            // Manager's 3D matches the wiki page. Fall back to live cache, then render/board.glb.
            let glb = ver.as_ref()
                .and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))
                .or_else(|| page_model_glb(slug))
                .or_else(|| cached_board(slug).and_then(|(_, _, g)| g))
                .or_else(|| wiki_bytes(slug, "render/board.glb"));
            match glb {
                Some(g) => Response::from_data(g).with_header(hdr("model/gltf-binary")),
                None => Response::from_string("not rendered").with_status_code(404),
            }
        }
        (Method::Get, p) if p.starts_with("/glb/") => {
            let slug = &p["/glb/".len()..];
            match list_page_files(slug).iter().find(|n| n.ends_with(".glb")).and_then(|n| wiki_bytes(slug, n)) {
                Some(b) => Response::from_data(b).with_header(hdr("model/gltf-binary")),
                None => Response::from_string("no glb").with_status_code(404),
            }
        }
        _ => Response::from_string("not found").with_status_code(404),
    }
}

// ---------------------------------------------------------------------------
// Wiki access (via curl, no HTTP-client dep)
// ---------------------------------------------------------------------------

/// The wiki API token (same source the adom-wiki CLI uses), cached. Without it,
/// unauthenticated requests return only PUBLIC pages, so private/org component
/// pages would never be listed. Read once; None if unavailable.
static WIKI_TOKEN: Mutex<Option<Option<String>>> = Mutex::new(None);
fn wiki_token() -> Option<String> {
    let mut c = WIKI_TOKEN.lock().unwrap();
    if let Some(v) = c.as_ref() {
        return v.clone();
    }
    let t = std::fs::read_to_string("/var/run/adom/api-key").ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .or_else(|| std::env::var("ADOM_API_KEY").ok().filter(|s| !s.is_empty()));
    *c = Some(t.clone());
    t
}

fn curl(url: &str) -> Option<Vec<u8>> {
    // `--fail`: on an HTTP error (e.g. a missing file's 404) the wiki returns a JSON
    // error body; without --fail curl hands us those bytes and callers mistake the
    // error object for real content (a 404 manifest read as a valid render/ folder).
    // With --fail curl exits non-zero and emits nothing, so we correctly return None.
    let mut args: Vec<String> = vec!["-s".into(), "-f".into(), "-L".into(), "--max-time".into(), "12".into()];
    // authenticate wiki requests so private/org pages are included (issue #120)
    if url.contains("wiki.adom.inc") {
        if let Some(tok) = wiki_token() {
            args.push("-H".into());
            args.push(format!("Authorization: Bearer {tok}"));
        }
    }
    args.push(url.to_string());
    let out = Command::new("curl").args(&args).output().ok()?;
    if out.status.success() && !out.stdout.is_empty() {
        Some(out.stdout)
    } else {
        None
    }
}

fn wiki_text(slug: &str, file: &str) -> Option<String> {
    let url = format!("{WIKI}/api/v1/pages/{slug}/files/{file}");
    curl(&url).map(|b| String::from_utf8_lossy(&b).to_string())
}
fn wiki_bytes(slug: &str, file: &str) -> Option<Vec<u8>> {
    curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{file}"))
}

/// `a > b` for dotted numeric versions ("0.8.7" > "0.8.6"), tolerant of pre-release
/// suffixes and missing components (treated as 0).
fn semver_gt(a: &str, b: &str) -> bool {
    let parse = |s: &str| -> (u64, u64, u64) {
        let mut it = s.trim().trim_start_matches('v').split('.').map(|x| {
            x.split(|c: char| !c.is_ascii_digit()).next().unwrap_or("0").parse::<u64>().unwrap_or(0)
        });
        (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
    };
    parse(a) > parse(b)
}

/// Running version vs the latest published on the wiki (dist-tags.latest), cached for
/// ~10 minutes so the header badge doesn't hit the registry on every poll.
static UPDATE_CACHE: Mutex<Option<(std::time::Instant, Value)>> = Mutex::new(None);
fn update_status() -> Value {
    if let Some((t, v)) = UPDATE_CACHE.lock().unwrap().as_ref() {
        if t.elapsed().as_secs() < 600 { return v.clone(); }
    }
    let current = env!("CARGO_PKG_VERSION");
    let latest = curl(&format!("{WIKI}/api/packages/adom/adom-nucleus/dist-tags"))
        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
        .and_then(|v| dig(&v, "dist_tags.latest").and_then(|s| s.as_str()).map(String::from));
    let update = latest.as_deref().map(|l| semver_gt(l, current)).unwrap_or(false);
    let v = json!({
        "current": current,
        "latest": latest,
        "update": update,
        "url": format!("{WIKI}/adom/adom-nucleus"),
    });
    *UPDATE_CACHE.lock().unwrap() = Some((std::time::Instant::now(), v.clone()));
    v
}

/// The page's model_3d GLB, the exact 3D file the wiki page displays. Drew's
/// molecule-publish emits `<slug>.glb` and sets `model_3d_path` to it, so serving
/// this keeps the Manager's 3D view consistent with the wiki page. Prefers the
/// explicit `model_3d_path` from package.json, else the `<slug>.glb` convention.
fn page_model_glb(slug: &str) -> Option<Vec<u8>> {
    if let Some(txt) = wiki_text(slug, "package.json") {
        if let Ok(v) = serde_json::from_str::<Value>(&txt) {
            if let Some(p) = v.get("model_3d_path").and_then(|x| x.as_str()).filter(|s| !s.is_empty()) {
                let p = p.trim_start_matches("./").trim_start_matches('/');
                if let Some(b) = wiki_bytes(slug, p) {
                    return Some(b);
                }
            }
        }
    }
    wiki_bytes(slug, &format!("{slug}.glb"))
}

// Read the up-axis a molecule GLB declares in its glTF header. step2glb --molecule
// stamps asset.extras.adomMolecule.upAxis ("z") on anchored/normalized GLBs; raw
// (un-pipelined) Fusion exports carry nothing. The 3D viewer uses this to decide the
// Z-up→Y-up rotation per board instead of guessing by EDA. Returns e.g. Some("z").
fn glb_up_axis(bytes: &[u8]) -> Option<String> {
    if bytes.len() < 20 || &bytes[0..4] != b"glTF" {
        return None;
    }
    let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
    let end = 20usize.checked_add(json_len)?;
    if bytes.len() < end {
        return None;
    }
    let j: Value = serde_json::from_slice(&bytes[20..end]).ok()?;
    j.get("asset")?.get("extras")?.get("adomMolecule")?
        .get("upAxis")?.as_str().map(|s| s.to_lowercase())
}

/// Split a request path into (path_without_query, the `?v=<version>` value if present).
/// Used by the board asset routes to serve a past version from `render/<version>/`.
fn split_ver(p: &str) -> (&str, Option<String>) {
    match p.split_once('?') {
        Some((path, q)) => {
            let v = q.split('&').find_map(|kv| kv.strip_prefix("v=")).map(|s| s.to_string());
            (path, v.filter(|s| !s.is_empty() && !s.contains("..") && !s.contains('/')))
        }
        None => (p, None),
    }
}

/// Uppercase-MPN filename stem for a slug (files are named `<MPN>.ext`).
fn up(slug: &str) -> String {
    slug.to_uppercase()
}

/// List component pages from the wiki API (fast + reliable; `adompkg` is dead).
fn list_components() -> Value {
    let mut items = Vec::new();
    // The wiki caps each list response at ~200 rows regardless of `limit`, and there
    // are now hundreds of components, a single fetch drops most of them (boards
    // included). Page through with `offset` until we've collected every component.
    let mut offset = 0usize;
    loop {
        let body = match curl(&format!("{WIKI}/api/v1/pages?type=component&limit=200&offset={offset}")) {
            Some(b) => b,
            None => break,
        };
        let v = match serde_json::from_slice::<Value>(&body) {
            Ok(v) => v,
            Err(_) => break,
        };
        let pages = match v.get("pages").and_then(|p| p.as_array()) {
            Some(p) if !p.is_empty() => p.clone(),
            _ => break,
        };
        let n = pages.len();
        for pg in &pages {
            let slug = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("").to_string();
            if slug.is_empty() {
                continue;
            }
            let title = pg.get("title").and_then(|s| s.as_str()).unwrap_or(&slug).to_string();
            let brief = pg.get("brief").and_then(|s| s.as_str()).unwrap_or("").to_string();
            // tags may arrive as a JSON-string or a real array
            let tags = pg.get("tags").map(|t| match t.as_str() {
                Some(s) => serde_json::from_str::<Value>(s).unwrap_or_else(|_| json!([])),
                None => t.clone(),
            }).unwrap_or_else(|| json!([]));
            let owner = pg.get("author_name").and_then(|s| s.as_str()).unwrap_or("").to_string();
            let org = pg.get("org_name").and_then(|s| s.as_str()).unwrap_or("").to_string();
            let is_starred = pg.get("starred").and_then(|s| s.as_bool()).unwrap_or(false);
            let visibility = pg.get("visibility").and_then(|s| s.as_str()).unwrap_or("").to_string();
            let version = pg.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string();
            let score = pg.get("composite_score").and_then(|s| s.as_f64()).unwrap_or(0.0);
            items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred, "visibility": visibility, "version": version, "score": score}));
        }
        offset += n;
        let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;
        // stop when we've read everything, the page came back short, or as a hard cap
        if offset >= total || n < 200 || offset >= 5000 {
            break;
        }
    }
    if items.is_empty() {
        for s in ["cl21a476mqynnne", "cq03saf4702t5e", "lqm18pnr47mfhd", "machinepinlargestandard", "machinecontactmedium"] {
            items.push(json!({"slug": s, "title": up(s), "brief": ""}));
        }
    }
    // Fold the EDA type into every BOARD item up front, computed concurrently.
    // The server is single-threaded, so lazy per-row `/api/board-eda` fetches would
    // otherwise serialize N wiki file-listings (KiCad tags trickling in one by one).
    let board_slugs: Vec<String> = items.iter().filter_map(|it| {
        let is_board = it.get("tags").and_then(|t| t.as_array()).is_some_and(|a| {
            a.iter().any(|x| matches!(x.as_str().unwrap_or("").to_lowercase().as_str(), "board" | "molecule" | "circuit"))
        });
        if is_board { it.get("slug").and_then(|s| s.as_str()).map(String::from) } else { None }
    }).collect();
    let eda_map: std::collections::HashMap<String, Vec<&'static str>> = std::thread::scope(|sc| {
        let handles: Vec<_> = board_slugs.iter().map(|s| {
            let s = s.clone();
            sc.spawn(move || { let e = board_eda_cached(&s); (s, e) })
        }).collect();
        handles.into_iter().filter_map(|h| h.join().ok()).collect()
    });
    for it in items.iter_mut() {
        if let Some(e) = it.get("slug").and_then(|s| s.as_str()).and_then(|s| eda_map.get(s)) {
            it["eda"] = json!(e);
        }
    }
    let (me, orgs) = whoami();
    json!({ "status": "ok", "components": items, "me": me, "orgs": orgs,
        "hints": ["click a component to open its viewer"] })
}

/// Fetch a component and build its adom-lbr part FRESH on every call (nothing is
static EMBED_CACHE: Mutex<Option<std::collections::HashMap<String, String>>> = Mutex::new(None);

/// Self-contained INTERACTIVE symbol/footprint viewer HTML from `adom-symbol embed` /
/// `adom-footprint embed` (pan/zoom + pin-info / layers panel). We export the part's
/// KiCad source to a temp file and run the embed CLI. Cached per (slug, kind) since the
/// CLI takes a beat and the HTML is stable for a given part.
fn part_embed(slug: &str, kind: &str) -> Option<String> {
    if kind != "symbol" && kind != "footprint" {
        return None;
    }
    let key = format!("{slug}/{kind}");
    if let Some(m) = EMBED_CACHE.lock().unwrap().as_ref() {
        if let Some(h) = m.get(&key) {
            return Some(h.clone());
        }
    }
    let mpn = up(slug);
    let (part, _) = load_part(slug, &mpn).ok()?;
    let dir = std::env::temp_dir().join(format!("nucleus-embed-{slug}"));
    std::fs::create_dir_all(&dir).ok()?;
    let (text, ext, tool) = match kind {
        "symbol" => (export_kicad_symbol(&part.symbol), "kicad_sym", "adom-symbol"),
        _ => (export_kicad_footprint(&part.footprint), "kicad_mod", "adom-footprint"),
    };
    let src = dir.join(format!("{mpn}.{ext}"));
    let out = dir.join(format!("{kind}.html"));
    std::fs::write(&src, text).ok()?;
    let ok = Command::new(tool)
        .args(["embed", "--file", src.to_str()?, "--out", out.to_str()?])
        .output()
        .ok()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !ok {
        return None;
    }
    let raw = std::fs::read_to_string(&out).ok()?;
    // Fit the embed into the PM's narrow 3-up cards: in the card, hide the info sidebar
    // (<aside>) AND the "adom-symbol/footprint" top bar so the viewer gets the whole card;
    // both reappear only when the card is fullscreened (iframe grows past the breakpoint).
    // Also shrink the top-left zoom controls (their default 30px is chunky in a small card).
    let fit = "<style id=\"nucleus-fit\">\
        @media(max-width:639px){aside{display:none!important}header{display:none!important}}\
        .tools,.av-tools{gap:2px!important;padding:2px!important;left:8px!important;top:8px!important}\
        .tools button,.av-tools button{width:22px!important;height:22px!important}\
        .tools button svg,.av-tools button svg{width:13px!important;height:13px!important}\
        </style>";
    let html = match raw.find("</head>") {
        Some(i) => format!("{}{}{}", &raw[..i], fit, &raw[i..]),
        None => format!("{fit}{raw}"),
    };
    EMBED_CACHE
        .lock()
        .unwrap()
        .get_or_insert_with(Default::default)
        .insert(key, html.clone());
    Some(html)
}

/// cached), then render symbol/footprint SVG on the fly. The canonical adom-lbr
/// JSON is the primary source; if the page has none, we import the KiCad files.
fn get_part(slug: &str) -> Value {
    let mpn = up(slug);
    let (part, src): (AdomLbrPart, &str) = match load_part(slug, &mpn) {
        Ok(v) => v,
        Err(e) => {
            return json!({"status":"error","error":e,
                "hints":["the page needs an adom-lbr JSON, or <MPN>.kicad_sym + <MPN>.kicad_mod"]})
        }
    };
    let has_glb = list_page_files(slug).iter().any(|n| n.ends_with(".glb"));
    json!({
        "status": "ok",
        "slug": slug,
        "mpn": part.mpn,
        "source": src,                       // "adom-lbr json" or "kicad (generated)"
        "symbol_svg": symbol_svg(&part.symbol),   // rendered on the fly, every click
        "footprint_svg": footprint_svg(&part.footprint, &pin_names(&part.symbol)),
        "layers": layer_stackup(&part.footprint),
        "glb": has_glb,
        "pins": part.symbol.pins.len(),
        "pads": part.footprint.pads.len(),
    })
}

/// A board/molecule's component dependencies, the other wiki components it's
/// built from. Read from the board's **`package.json` `dependencies`** (the same
/// npm-style manifest the wiki dependency graph is built from, so the two agree).
/// Values may be `{slug: ver}`, `[slug,…]`, or `[{slug|ref|name}, …]`; owner
/// prefixes (`adom/foo`) are stripped to the slug.
/// A page's package.json dependencies as bare slugs (owner prefix stripped).
fn deps_of(slug: &str) -> Vec<String> {
    let bare = |k: &str| k.rsplit('/').next().unwrap_or(k).trim().to_string();
    if let Some(txt) = wiki_text(slug, "package.json") {
        if let Ok(v) = serde_json::from_str::<Value>(&txt) {
            return match v.get("dependencies") {
                Some(Value::Object(m)) => m.keys().map(|k| bare(k)).collect(),
                Some(Value::Array(a)) => a
                    .iter()
                    .filter_map(|d| {
                        d.as_str().map(|s| s.to_string()).or_else(|| {
                            d.get("slug").or_else(|| d.get("ref")).or_else(|| d.get("name")).and_then(|x| x.as_str()).map(|s| s.to_string())
                        })
                    })
                    .map(|s| bare(&s))
                    .collect(),
                _ => vec![],
            };
        }
    }
    vec![]
}

/// Components on a board. Architecture: a board depends on a component LIBRARY,
/// and the library depends on the components. Resolve one hop, any dependency
/// that itself has dependencies is a library and is expanded to its components;
/// a dependency with none is treated as a direct component (backward-compatible
/// with boards that still list components directly).
/// A circuit's bill of materials, derived from the `adom-block.json` on its page
/// (the neutral design-block model). Aggregates the block's component instances by
/// (value, lcsc, footprint) with a quantity and the reference designators. A part
/// links to a wiki component page only when one is resolved (`page`); otherwise it's
/// an informative BOM line. This is how `circuit`-tagged pages get their dependencies
/// without every passive needing its own wiki page.
fn circuit_bom(slug: &str) -> Vec<Value> {
    let block: Value = match wiki_bytes(slug, "adom-block.json").and_then(|b| serde_json::from_slice(&b).ok()) {
        Some(v) => v,
        None => return vec![],
    };
    let instances = match block.get("instances").and_then(|i| i.as_array()) {
        Some(a) => a,
        None => return vec![],
    };
    // aggregate by (value, lcsc, footprint)
    let mut order: Vec<String> = vec![];
    let mut agg: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
    for inst in instances {
        let get = |k: &str| inst.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
        let (value, lcsc, footprint) = (get("value"), get("lcsc"), get("footprint"));
        let refdes = get("id");
        let key = format!("{value}|{lcsc}|{footprint}");
        let entry = agg.entry(key.clone()).or_insert_with(|| {
            order.push(key.clone());
            json!({
                "value": if value.is_empty() { Value::Null } else { json!(value) },
                "lcsc": if lcsc.is_empty() { Value::Null } else { json!(lcsc) },
                "footprint": if footprint.is_empty() { Value::Null } else { json!(footprint) },
                "mpn": inst.get("mpn").cloned().unwrap_or(Value::Null),
                "page": inst.get("page").cloned().unwrap_or(Value::Null),
                "refs": json!([]),
                "qty": 0,
            })
        });
        entry["qty"] = json!(entry["qty"].as_u64().unwrap_or(0) + 1);
        if let Some(a) = entry["refs"].as_array_mut() { if !refdes.is_empty() { a.push(json!(refdes)); } }
    }
    order.into_iter().filter_map(|k| agg.remove(&k)).collect()
}

fn board_deps(slug: &str) -> Value {
    let mut components: Vec<String> = vec![];
    let mut seen = std::collections::HashSet::new();
    let own_lib = format!("{slug}-library");
    for d in deps_of(slug) {
        let sub = deps_of(&d);
        if sub.is_empty() {
            // The page's own library (a <slug>-library that lists no components yet, as
            // with a circuit whose BOM lives in its block model) is surfaced via the
            // "Libraries" expander, not as a fake component row. Skip it so the BOM shows.
            if d == own_lib { continue; }
            if seen.insert(d.clone()) {
                components.push(d);
            }
        } else {
            for c in sub {
                if seen.insert(c.clone()) {
                    components.push(c);
                }
            }
        }
    }
    let items: Vec<Value> = components.into_iter().map(|s| json!({"slug": s})).collect();
    // No resolved component pages? fall back to the block-model BOM (circuit pages).
    let bom = if items.is_empty() { circuit_bom(slug) } else { vec![] };
    // the page's git commit history (the wiki's Activity tab)
    let log = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
        .and_then(|v| v.get("log").cloned())
        .unwrap_or_else(|| json!([]));
    json!({ "status": "ok", "board": slug, "deps": items, "bom": bom, "log": log,
        "hints": ["board → component library → components (the library's dependencies)"] })
}

/// A PROJECT's direct board dependencies, a page tagged `project` depends on
/// boards (not components), so unlike `board_deps` these are NOT expanded. The
/// Manager lists them, and clicking one opens it as a normal board.
fn project_boards(slug: &str) -> Value {
    let boards: Vec<Value> = deps_of(slug).into_iter().map(|s| json!({"slug": s})).collect();
    let log = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
        .and_then(|v| v.get("log").cloned())
        .unwrap_or_else(|| json!([]));
    json!({ "status": "ok", "project": slug, "boards": boards, "log": log })
}

/// The board's EDA toolchain, inferred from the files on its page:
/// "kicad" | "altium" | "fusion" | "".
/// Every EDA toolchain whose files are present on the board, a board can carry
/// more than one (e.g. a KiCad PCB alongside a Fusion/Eagle `.brd` + `.lbr`), so
/// this returns ALL matches, in a stable order, not just the first.
fn board_eda(slug: &str) -> Vec<&'static str> {
    let files = list_page_files(slug);
    let has = |exts: &[&str]| files.iter().any(|n| { let l = n.to_lowercase(); exts.iter().any(|e| l.ends_with(e)) });
    let mut out = Vec::new();
    if has(&[".kicad_pcb", ".kicad_sch", ".kicad_pro"]) {
        out.push("kicad");
    }
    if has(&[".pcbdoc", ".schdoc", ".prjpcb", ".intlib", ".pcblib", ".schlib"]) {
        out.push("altium");
    }
    if has(&[".fbrd", ".fsch", ".brd", ".sch", ".f3d", ".fusion", ".lbr"]) {
        out.push("fusion");
    }
    out
}

/// `board_eda` cached across requests. The EDA set never changes for a given
/// board within a session, and the raw call costs a wiki file-listing round-trip;
/// caching turns the left-list's per-row EDA into a one-time cost.
static EDA_CACHE: Mutex<Option<std::collections::HashMap<String, Vec<&'static str>>>> = Mutex::new(None);
fn board_eda_cached(slug: &str) -> Vec<&'static str> {
    {
        let mut g = EDA_CACHE.lock().unwrap();
        if let Some(e) = g.get_or_insert_with(Default::default).get(slug) {
            return e.clone();
        }
    }
    let e = board_eda(slug);
    EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e.clone());
    e
}

/// The board's commit history enriched with what each commit changed: components
/// added/removed (footprint reference designators on the PCB) and whether the PCB
/// or schematic changed. Fetches each commit's .kicad_pcb + .kicad_sch via ?ref=.
fn board_activity(slug: &str) -> Value {
    let log: Vec<Value> = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
        .and_then(|v| v.get("log").and_then(|l| l.as_array().cloned()))
        .unwrap_or_default();
    if log.is_empty() {
        return json!({ "activity": [] });
    }
    let files = list_page_files(slug);
    let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();
    let sch = files.iter().find(|n| n.ends_with(".kicad_sch")).cloned();
    let at = |file: &Option<String>, hash: &str| -> Option<String> {
        file.as_ref().and_then(|f| curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{f}?ref={hash}")).and_then(|b| String::from_utf8(b).ok()))
    };
    // component reference designators placed on the PCB
    let refs = |pcb: &str| -> std::collections::HashSet<String> {
        pcb.split("(property \"Reference\" \"").skip(1)
            .filter_map(|c| c.split('"').next())
            .filter(|r| !r.is_empty() && *r != "REF**")
            .map(String::from)
            .collect()
    };
    let hashes: Vec<String> = log.iter().filter_map(|c| c.get("hash").and_then(|h| h.as_str()).map(String::from)).collect();
    let pcbs: Vec<Option<String>> = hashes.iter().map(|h| at(&pcb, h)).collect();
    let schs: Vec<Option<String>> = hashes.iter().map(|h| at(&sch, h)).collect();
    let mut out = vec![];
    for i in 0..log.len() {
        let mut e = log[i].clone();
        // compare against the previous (older) commit, the next entry in the log
        if i + 1 < log.len() {
            e["pcb_changed"] = json!(pcbs[i] != pcbs[i + 1]);
            e["sch_changed"] = json!(schs[i] != schs[i + 1]);
            if let (Some(n), Some(o)) = (&pcbs[i], &pcbs[i + 1]) {
                let (rn, ro) = (refs(n), refs(o));
                e["comp_added"] = json!(rn.difference(&ro).count());
                e["comp_removed"] = json!(ro.difference(&rn).count());
            }
        }
        out.push(e);
    }
    json!({ "activity": out })
}

/// List the file names on a page (robust to any MPN→filename convention, the
/// slug can't be uppercased to the filename when the MPN has `/`, `.`, etc.).
fn list_page_files(slug: &str) -> Vec<String> {
    let body = match curl(&format!("{WIKI}/api/v1/pages/{slug}/files")) {
        Some(b) => b,
        None => return vec![],
    };
    let v: Value = match serde_json::from_slice(&body) {
        Ok(v) => v,
        Err(_) => return vec![],
    };
    v.get("files")
        .and_then(|f| f.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|f| f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).map(String::from))
                .collect()
        })
        .unwrap_or_default()
}

/// Load a part, preferring the canonical adom-lbr JSON on the page; else generate
/// it from the KiCad files. Discovers the ACTUAL filenames from the page listing
/// (never guesses from the slug). Returns the part + where it came from.
fn load_part(slug: &str, _mpn: &str) -> Result<(AdomLbrPart, &'static str), String> {
    let files = list_page_files(slug);
    let find = |suf: &str| files.iter().find(|n| n.ends_with(suf)).cloned();
    // 1) canonical adom-lbr JSON
    if let Some(name) = find(".adom-lbr.json").or_else(|| find(".adomlbr.json")) {
        if let Some(txt) = wiki_text(slug, &name) {
            match serde_json::from_str::<AdomLbrPart>(&txt) {
                Ok(p) => return Ok((p, "adom-lbr json")),
                Err(e) => return Err(format!("{name} is not valid adom-lbr JSON: {e}")),
            }
        }
    }
    // 2) fall back to the KiCad source files
    match (
        find(".kicad_sym").and_then(|n| wiki_text(slug, &n)),
        find(".kicad_mod").and_then(|n| wiki_text(slug, &n)),
    ) {
        (Some(s), Some(m)) => altium_codec::import_kicad(&s, &m, &up(slug))
            .map(|p| (p, "kicad (generated)"))
            .map_err(|e| e.to_string()),
        _ => Err(format!("no adom-lbr JSON or KiCad files on {slug}")),
    }
}

// ---------------------------------------------------------------------------
// Layer stackup, the cross-EDA mapping (KiCad + Altium)
// ---------------------------------------------------------------------------

fn role_name(l: &FpLayer) -> &'static str {
    match l {
        FpLayer::Silk => "Silkscreen",
        FpLayer::Courtyard => "Courtyard",
        FpLayer::Fab => "Fabrication / Assembly",
        FpLayer::Copper => "Copper",
        FpLayer::Other { .. } => "Other",
    }
}
fn kicad_layer(l: &FpLayer) -> &'static str {
    match l {
        FpLayer::Silk => "F.SilkS",
        FpLayer::Courtyard => "F.CrtYd",
        FpLayer::Fab => "F.Fab",
        FpLayer::Copper => "F.Cu",
        FpLayer::Other { .. } => "F.Fab",
    }
}
/// Barrett's house convention (from the C0402 reference): outline+silk → Mech13
/// "Assembly Top", courtyard → Mech15 "Courtyard Top".
fn altium_layer(l: &FpLayer) -> &'static str {
    match l {
        FpLayer::Silk => "Mechanical 13 (Assembly Top)",
        FpLayer::Fab => "Mechanical 13 (Assembly Top)",
        FpLayer::Courtyard => "Mechanical 15 (Courtyard Top)",
        FpLayer::Copper => "Top Layer",
        FpLayer::Other { .. } => "Mechanical 1",
    }
}

/// CSS-safe token per layer role, used to class SVG elements so the footprint
/// view can toggle layers on/off.
fn layer_key(l: &FpLayer) -> &'static str {
    match l {
        FpLayer::Silk => "silk",
        FpLayer::Courtyard => "courtyard",
        FpLayer::Fab => "fab",
        FpLayer::Copper => "copper",
        FpLayer::Other { .. } => "other",
    }
}

fn layer_stackup(fp: &Footprint) -> Value {
    use std::collections::BTreeMap;
    // count graphics per role (+ the pad copper layer)
    let mut counts: BTreeMap<&str, (FpLayer, usize)> = BTreeMap::new();
    if !fp.pads.is_empty() {
        counts.insert("Copper", (FpLayer::Copper, fp.pads.len()));
    }
    for g in &fp.graphics {
        let l = match g {
            FpGraphic::Line { layer, .. }
            | FpGraphic::Rect { layer, .. }
            | FpGraphic::Circle { layer, .. }
            | FpGraphic::Arc { layer, .. } => *layer,
        };
        let e = counts.entry(role_name(&l)).or_insert((l, 0));
        e.1 += 1;
    }
    let rows: Vec<Value> = counts
        .values()
        .map(|(l, n)| {
            json!({
                "role": role_name(l),
                "key": layer_key(l),
                "count": n,
                "kicad": kicad_layer(l),
                "altium": altium_layer(l),
            })
        })
        .collect();
    Value::Array(rows)
}

// ---------------------------------------------------------------------------
// SVG renderers (symbol in mils→viewport, footprint in mm)
// ---------------------------------------------------------------------------

/// Layer → SVG colour (KiCad-ish palette for the footprint view).
fn layer_color(l: &FpLayer) -> &'static str {
    match l {
        FpLayer::Silk => "#d8b4fe",
        FpLayer::Courtyard => "#4ade80",
        FpLayer::Fab => "#f472b6",
        FpLayer::Copper => "#f59e0b",
        FpLayer::Other { .. } => "#94a3b8",
    }
}

fn symbol_svg(sym: &Symbol) -> String {
    // bounds in mils over pins + graphics
    let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    let mut upd = |x: f64, y: f64| {
        a = a.min(x);
        b = b.min(y);
        c = c.max(x);
        d = d.max(y);
    };
    for p in &sym.pins {
        upd(p.x as f64, p.y as f64);
    }
    for g in &sym.graphics {
        match g {
            Graphic::Rect { x1, y1, x2, y2, .. } => { upd(*x1, *y1); upd(*x2, *y2); }
            Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => { upd(cx - r, cy - r); upd(cx + r, cy + r); }
            Graphic::Polyline { points, .. } => { for pt in points { upd(pt[0], pt[1]); } }
        }
    }
    if a > c { return empty_svg("no symbol"); }
    let pad = 50.0;
    let (a, b, c, d) = (a - pad, b - pad, c + pad, d + pad);
    // SVG y-down; symbol y-up → flip y
    let fy = |y: f64| -y;
    let mut body = String::new();
    for g in &sym.graphics {
        match g {
            Graphic::Rect { x1, y1, x2, y2, filled, .. } => {
                let fill = if *filled { "#e35d5d" } else { "none" };
                body += &format!("<rect x='{:.1}' y='{:.1}' width='{:.1}' height='{:.1}' fill='{}' stroke='#e35d5d' stroke-width='4'/>",
                    x1.min(*x2), fy(y1.max(*y2)), (x2 - x1).abs(), (y2 - y1).abs(), fill);
            }
            Graphic::Circle { cx, cy, r, .. } => {
                body += &format!("<circle cx='{:.1}' cy='{:.1}' r='{:.1}' fill='none' stroke='#e35d5d' stroke-width='4'/>", cx, fy(*cy), r);
            }
            Graphic::Arc { cx, cy, r, start, end, .. } => {
                let p = |deg: f64| { let t = deg.to_radians(); (cx + r * t.cos(), fy(cy + r * t.sin())) };
                let (sx, sy) = p(*start); let (ex, ey) = p(*end);
                let large = if ((end - start + 360.0) % 360.0) > 180.0 { 1 } else { 0 };
                body += &format!("<path d='M{:.1} {:.1} A{:.1} {:.1} 0 {} 0 {:.1} {:.1}' fill='none' stroke='#e35d5d' stroke-width='4'/>", sx, sy, r, r, large, ex, ey);
            }
            Graphic::Polyline { points, .. } => {
                let pts: String = points.iter().map(|pt| format!("{:.1},{:.1} ", pt[0], fy(pt[1]))).collect();
                body += &format!("<polyline points='{}' fill='none' stroke='#e35d5d' stroke-width='4'/>", pts.trim());
            }
        }
    }
    // Show pin numbers/names on ICs; hide on passives (respect the source flag,
    // else default by pin count, same rule the KiCad exporter uses).
    let passive = sym.pins.len() <= 2;
    let show_num = !sym.pin_numbers_hidden.unwrap_or(passive);
    let show_name = !sym.pin_names_hidden.unwrap_or(passive);
    for p in &sym.pins {
        let len = p.length.unwrap_or(200) as f64;
        let (dx, dy) = match (p.rotation / 90) % 4 { 0 => (len, 0.0), 1 => (0.0, len), 2 => (-len, 0.0), _ => (0.0, -len) };
        let (x1, y1) = (p.x as f64, fy(p.y as f64));          // free (connection) end
        let (x2, y2) = (p.x as f64 + dx, fy(p.y as f64 + dy)); // body end, SVG space
        body += &format!(
            "<g class='pingroup' data-num='{}' data-name='{}'>\
             <line class='pinvis' x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='#dfe4ec' stroke-width='4'/>\
             <line x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='transparent' stroke-width='26' pointer-events='stroke'/></g>",
            esc_attr(&p.number), esc_attr(&p.name), x1, y1, x2, y2, x1, y1, x2, y2
        );
        let ln = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt().max(1.0);
        let (ux, uy) = ((x2 - x1) / ln, (y2 - y1) / ln); // free→body unit
        let (px, py) = (-uy, ux);
        // KiCad's default pin text is 1.27mm = 50 mil; match that (nudge with length).
        let fs = (len * 0.5).clamp(45.0, 60.0);
        if show_num {
            body += &format!(
                "<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#9fb0c8' text-anchor='middle' dominant-baseline='central'>{}</text>",
                x1 + ux * ln * 0.5 + px * fs * 0.75, y1 + uy * ln * 0.5 + py * fs * 0.75, fs, esc_attr(&p.number)
            );
        }
        if show_name && p.name != "~" && !p.name.is_empty() {
            let anchor = if ux > 0.3 { "start" } else if ux < -0.3 { "end" } else { "middle" };
            body += &format!(
                "<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#e6e9ef' text-anchor='{}' dominant-baseline='central'>{}</text>",
                x2 + ux * fs * 0.5, y2 + uy * fs * 0.5, fs, anchor, esc_attr(&p.name)
            );
        }
    }
    format!(
        "<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.0} {:.0} {:.0} {:.0}' style='width:100%;height:100%'>{}</svg>",
        a, -d, c - a, d - b, body
    )
}

/// number → pin name, so a pad can show the connected pin's name on hover.
fn pin_names(sym: &Symbol) -> std::collections::HashMap<String, String> {
    sym.pins
        .iter()
        .filter(|p| !p.name.is_empty() && p.name != "~")
        .map(|p| (p.number.clone(), p.name.clone()))
        .collect()
}

fn footprint_svg(fp: &Footprint, names: &std::collections::HashMap<String, String>) -> String {
    let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    let mut upd = |x: f64, y: f64| { a = a.min(x); b = b.min(y); c = c.max(x); d = d.max(y); };
    for p in &fp.pads {
        upd(p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0);
        upd(p.x_mm + p.w_mm / 2.0, p.y_mm + p.h_mm / 2.0);
    }
    for g in &fp.graphics {
        match g {
            FpGraphic::Line { x1, y1, x2, y2, .. } | FpGraphic::Rect { x1, y1, x2, y2, .. } => { upd(*x1, *y1); upd(*x2, *y2); }
            FpGraphic::Circle { cx, cy, r, .. } | FpGraphic::Arc { cx, cy, r, .. } => { upd(cx - r, cy - r); upd(cx + r, cy + r); }
        }
    }
    if a > c { return empty_svg("no footprint"); }
    let pad = 0.4;
    let (a, b, c, d) = (a - pad, b - pad, c + pad, d + pad);
    let mut body = String::new();
    // graphics first (under pads)
    for g in &fp.graphics {
        let (layer, s) = match g {
            FpGraphic::Line { layer, x1, y1, x2, y2, width } =>
                (layer, format!("<line class='ly-{}' x1='{:.3}' y1='{:.3}' x2='{:.3}' y2='{:.3}' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), x1, y1, x2, y2, width.max(0.05))),
            FpGraphic::Rect { layer, x1, y1, x2, y2, width } =>
                (layer, format!("<rect class='ly-{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), x1.min(*x2), y1.min(*y2), (x2-x1).abs(), (y2-y1).abs(), width.max(0.05))),
            FpGraphic::Circle { layer, cx, cy, r, width } =>
                (layer, format!("<circle class='ly-{}' cx='{:.3}' cy='{:.3}' r='{:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), cx, cy, r, width.max(0.05))),
            FpGraphic::Arc { layer, cx, cy, r, start, end, width } => {
                let p = |deg: f64| { let t = deg.to_radians(); (cx + r * t.cos(), cy + r * t.sin()) };
                let (sx, sy) = p(*start); let (ex, ey) = p(*end);
                (layer, format!("<path class='ly-{}' d='M{:.3} {:.3} A{:.3} {:.3} 0 0 1 {:.3} {:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), sx, sy, r, r, ex, ey, width.max(0.05)))
            }
        };
        body += &s.replace("{}", layer_color(layer));
    }
    for p in &fp.pads {
        let col = layer_color(&FpLayer::Copper);
        let rx = if p.shape == "circle" || p.shape == "roundrect" { p.w_mm.min(p.h_mm) * 0.25 } else { 0.0 };
        // the connected pin name (if the symbol has one) becomes the pad's label
        let name = names.get(&p.number).cloned().unwrap_or_default();
        body += &format!("<rect class='ly-copper pad' data-num='{}' data-name='{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' rx='{:.3}' fill='{}' opacity='0.9'/>",
            esc_attr(&p.number), esc_attr(&name), p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0, p.w_mm, p.h_mm, rx, col);
    }
    format!(
        "<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.3} {:.3} {:.3} {:.3}' style='width:100%;height:100%'>{}</svg>",
        a, b, c - a, d - b, body
    )
}

fn esc_attr(s: &str) -> String {
    s.replace('&', "&amp;").replace('\'', "&#39;").replace('"', "&quot;").replace('<', "&lt;")
}

fn empty_svg(msg: &str) -> String {
    format!("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 40'><text x='50' y='24' text-anchor='middle' fill='#888' font-size='8'>{msg}</text></svg>")
}

// ---------------------------------------------------------------------------
// Add to Library, convert selected components → the user's KiCad libraries,
// delivered to their machine via the Adom Desktop bridge.
// ---------------------------------------------------------------------------

// TODO: detect from the bridge rather than hard-coding this user's home.
const SCRATCH: &str = "/tmp/adom-lbr-apply";

/// Run a command on the user's machine via the bridge, returning the command's
/// own stdout (not the adom-desktop JSON envelope). None if empty / bridge down.
fn ad_run(cmd: &str) -> Option<String> {
    let raw = ad_shell_out(cmd)?;
    let v: Value = serde_json::from_str(&raw).ok()?;
    v.get("stdout").and_then(|s| s.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}

/// The user's machine `(os, home)`, detected via the bridge and cached. Files are
/// delivered to the user's REAL machine (Windows/macOS/Linux), not this Linux
/// container, so delivery paths must be built for it.
static MACHINE: Mutex<Option<(String, String)>> = Mutex::new(None);
fn machine() -> (String, String) {
    if let Some(v) = MACHINE.lock().unwrap().clone() {
        return v;
    }
    // a Unix shell expands `$HOME` to an absolute /path; a Windows cmd shell does not.
    let res = match ad_run("echo $HOME").filter(|h| h.starts_with('/')) {
        Some(home) => {
            let os = if ad_run("uname -s").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };
            (os.to_string(), home)
        }
        None => {
            let home = ad_run("echo %USERPROFILE%").filter(|h| h.contains(':')).unwrap_or_else(|| "C:\\Users\\Default".into());
            ("windows".to_string(), home)
        }
    };
    *MACHINE.lock().unwrap() = Some(res.clone());
    res
}

/// The path separator for the user's OS.
fn sep() -> &'static str {
    if machine().0 == "windows" { "\\" } else { "/" }
}

/// Join parts onto the user's home directory with the OS-appropriate separator.
fn home_path(parts: &[&str]) -> String {
    let mut p = machine().1.trim_end_matches(['/', '\\']).to_string();
    for x in parts {
        p.push_str(sep());
        p.push_str(x);
    }
    p
}

/// A KiCad library/project subdir on the user's machine (`sub` = Symbols /
/// Footprints / Projects).
fn kicad_dir(sub: &str) -> String {
    home_path(&["Documents", "KiCad", sub])
}

/// Everything before the last path separator (the containing directory).
fn parent_dir(path: &str) -> String {
    match path.rfind(['/', '\\']) {
        Some(i) => path[..i].to_string(),
        None => ".".to_string(),
    }
}

/// GET /api/env, the user's machine OS + home + OS-appropriate default delivery
/// folders, so the UI's "Deliver project to" picker isn't hard-coded to Linux.
fn api_env() -> Value {
    let (os, home) = machine();
    json!({
        "os": os,
        "home": home,
        "sep": sep(),
        "dest_folders": [
            kicad_dir("Projects"),
            home_path(&["Documents"]),
            home_path(&["Downloads"]),
            home_path(&["Desktop"]),
        ],
    })
}

/// The KiCad config dir on the user's machine (holds the global lib tables).
fn kicad_config_base() -> String {
    let (os, home) = machine();
    match os.as_str() {
        "macos" => format!("{home}/Library/Preferences/kicad"),
        "windows" => format!("{home}\\AppData\\Roaming\\kicad"),
        _ => format!("{home}/.config/kicad"),
    }
}

/// True for a KiCad-bundled standard library (as opposed to the user's own) -
/// identified by a `${KICAD*_…_DIR}` variable or an install-dir path.
fn is_stock_kicad_lib(uri: &str) -> bool {
    let u = uri.to_lowercase();
    uri.starts_with("${KICAD")
        || u.contains("/usr/share/kicad")
        || u.contains("/usr/lib/kicad")
        || u.contains("program files\\kicad")
        || u.contains("program files/kicad")
        || u.contains("/applications/kicad")
}

/// Parse `(lib (name "X") … (uri "Y"))` entries out of a KiCad lib-table.
fn parse_lib_table(text: &str) -> Vec<Value> {
    text.split("(lib ")
        .skip(1)
        .filter_map(|chunk| {
            let name = chunk.find("(name \"").and_then(|i| chunk[i + 7..].split('"').next())?;
            let uri = chunk.find("(uri \"").and_then(|i| chunk[i + 6..].split('"').next())?;
            if name.is_empty() {
                return None;
            }
            Some(json!({ "name": name, "uri": uri }))
        })
        .collect()
}

/// GET /api/libraries, the user's REAL KiCad symbol + footprint libraries (name
/// + on-disk path) read from their global sym-lib-table / fp-lib-table via the
/// bridge, so placement suggests the user's own libraries, never hard-coded ones.
fn user_libraries() -> Value {
    let (os, _) = machine();
    let base = kicad_config_base();
    let read = |table: &str| -> Vec<Value> {
        let cmd = if os == "windows" {
            format!("for /d %d in (\"{base}\\*\") do @type \"%d\\{table}\" 2>nul")
        } else {
            format!("cat {base}/*/{table} 2>/dev/null")
        };
        // merge across KiCad versions, first occurrence of each name wins
        let mut seen = std::collections::HashSet::new();
        ad_run(&cmd)
            .map(|t| parse_lib_table(&t))
            .unwrap_or_default()
            .into_iter()
            .filter(|l| {
                let n = l.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string();
                let uri = l.get("uri").and_then(|x| x.as_str()).unwrap_or("");
                // keep only the user's PERSONAL libraries: drop KiCad's bundled
                // standard libs and the built-in default-table pointer.
                n != "KiCad" && !is_stock_kicad_lib(uri) && seen.insert(n)
            })
            .collect()
    };
    json!({ "symLibs": read("sym-lib-table"), "fpLibs": read("fp-lib-table") })
}

/// Minimal percent-decoding for query values (`%20`, `+`, `%2F`, …).
fn urldecode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' && i + 3 <= b.len() {
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
                out.push(v);
                i += 3;
                continue;
            }
        }
        out.push(if b[i] == b'+' { b' ' } else { b[i] });
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// GET /api/pick-folder, pop a NATIVE folder-picker dialog on the user's machine
/// (via the bridge) and return the chosen directory. Blocks until the user picks
/// or cancels (empty path = cancelled / no dialog available).
fn pick_folder() -> Value {
    let (os, _) = machine();
    let id = unique_id();
    // A native folder dialog is MODAL and blocks until the user picks/cancels. If we
    // ran it synchronously through the bridge, the bridge's shell_execute would wait
    // for it and WEDGE the whole AD command channel. So launch it DETACHED, have it
    // write the chosen path (or __CANCELLED__) to a temp file, and let the frontend
    // poll /api/pick-folder-result. shell_execute returns instantly; AD stays free.
    // The token is just the id; each side rebuilds the temp path itself (on Windows
    // inside PowerShell via GetTempPath, since cmd mangles %TEMP%/forward-slash paths).
    let launch = match os.as_str() {
        "windows" => {
            // inner: the TopMost dialog (owner form pulls it to the foreground), writing the result
            let inner = format!(
                "$ProgressPreference='SilentlyContinue'\n\
                 Add-Type -AssemblyName System.Windows.Forms\n\
                 $o=New-Object System.Windows.Forms.Form\n\
                 $o.TopMost=$true;$o.ShowInTaskbar=$false;$o.Opacity=0\n\
                 $o.Show();$o.Activate()\n\
                 $d=New-Object System.Windows.Forms.FolderBrowserDialog\n\
                 $d.Description='Select the project folder'\n\
                 $r=$d.ShowDialog($o)\n\
                 $o.Close()\n\
                 $out=if($r -eq [System.Windows.Forms.DialogResult]::OK){{$d.SelectedPath}}else{{'__CANCELLED__'}}\n\
                 $p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'\n\
                 Set-Content -LiteralPath $p -Value $out -NoNewline -Encoding UTF8");
            let inner_b64 = b64(&utf16le(&inner));
            // outer: fire the inner off detached, then exit immediately so shell_execute
            // returns. The inner carries -STA (needed for the dialog); the OUTER must NOT
            // (an -STA outer adds ~7s of startup here). Base64-encoded so cmd/PS don't echo it.
            let outer = format!("Start-Process -WindowStyle Hidden powershell -ArgumentList @('-NoProfile','-STA','-EncodedCommand','{inner_b64}')");
            format!("powershell -NoProfile -EncodedCommand {}", b64(&utf16le(&outer)))
        }
        "macos" => format!("(osascript -e 'POSIX path of (choose folder)' > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),
        _ => format!("(zenity --file-selection --directory > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),
    };
    let _ = ad_shell_out(&launch);
    json!({ "token": id })
}

/// GET /api/pick-folder-result?f=<id>, poll the detached folder dialog: no file
/// yet = still open (`pending`); `__CANCELLED__`/empty = cancelled; else the path.
/// Reads (and deletes) the temp file so a stale result can't linger.
fn pick_folder_result(token: &str) -> Value {
    // the token is our own generated id, keep only its safe characters
    let id: String = token.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
    if id.is_empty() {
        return json!({ "cancelled": true });
    }
    let (os, _) = machine();
    let content = if os == "windows" {
        // read+delete entirely inside PowerShell (cmd can't read the forward-slash %TEMP% path)
        ad_run(&ps_command(&format!(
            "$p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'; \
             if(Test-Path -LiteralPath $p){{[Console]::Out.Write((Get-Content -LiteralPath $p -Raw)); Remove-Item -LiteralPath $p -Force}}else{{[Console]::Out.Write('__PENDING__')}}")))
    } else {
        let tmp = format!("/tmp/apm_pick_{id}.txt");
        let c = ad_run(&format!("cat '{tmp}' 2>/dev/null"));
        if c.is_some() {
            let _ = ad_shell_out(&format!("rm -f '{tmp}'"));
        }
        c.or_else(|| Some("__PENDING__".into()))
    };
    match content.as_deref().map(str::trim) {
        None | Some("__PENDING__") => json!({ "pending": true }),
        Some("") | Some("__CANCELLED__") => json!({ "cancelled": true }),
        Some(path) => json!({ "path": path }),
    }
}

/// GET /api/list-dirs?p=<abspath>, the sub-folders of a directory on the user's
/// machine (each with its full path) plus the parent, powering the in-app folder
/// browser. Empty p starts at the home directory. This replaces the native OS
/// folder dialog (which can't render from a detached bridge process and can't be
/// run modally without wedging the bridge), and, unlike a browser file input, it
/// yields the real absolute path, which delivery needs.
fn list_dirs(path: &str) -> Value {
    let (os, home) = machine();
    let base = if path.trim().is_empty() { home } else { path.to_string() };
    if os == "windows" {
        let esc = base.replace('\'', "''");
        let script = format!(
            "$ErrorActionPreference='SilentlyContinue'; \
             $i=Get-Item -LiteralPath '{esc}'; \
             if(-not $i){{[Console]::Out.Write('@@ERR@@'); return}}; \
             $full=$i.FullName; $par=''; if($i.Parent){{$par=$i.Parent.FullName}}; \
             $names=Get-ChildItem -LiteralPath $full -Directory -Force | ForEach-Object {{ $_.Name + [char]9 + $_.FullName }} | Sort-Object; \
             [Console]::Out.Write($full + [char]10 + $par + [char]10 + '@@' + [char]10 + ($names -join [char]10))");
        let out = ad_run(&ps_command(&script)).unwrap_or_default().replace('\r', "");
        if out.is_empty() || out.contains("@@ERR@@") {
            return json!({ "path": base, "parent": "", "dirs": [] });
        }
        let (head, rest) = out.split_once("\n@@\n").unwrap_or((out.as_str(), ""));
        let mut hl = head.splitn(2, '\n');
        let full = hl.next().unwrap_or(&base).to_string();
        let par = hl.next().unwrap_or("").to_string();
        let dirs: Vec<Value> = rest.split('\n').filter(|l| !l.trim().is_empty())
            .filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))
            .collect();
        json!({ "path": full, "parent": par, "dirs": dirs })
    } else {
        let q = sh_quote(&base);
        let full = ad_run(&format!("cd {q} 2>/dev/null && pwd")).unwrap_or_else(|| base.clone());
        let fq = sh_quote(&full);
        let par = ad_run(&format!("dirname {fq}")).filter(|p| *p != full).unwrap_or_default();
        let out = ad_run(&format!("for d in {fq}/*/ ; do [ -d \"$d\" ] && printf '%s\\t%s\\n' \"$(basename \"$d\")\" \"${{d%/}}\" ; done 2>/dev/null")).unwrap_or_default();
        let dirs: Vec<Value> = out.lines()
            .filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))
            .collect();
        json!({ "path": full, "parent": par, "dirs": dirs })
    }
}

/// GET /api/suggest-path?p=<prefix>, existing directories on the user's machine
/// whose path starts with the typed prefix (for the delivery autosuggest).
fn suggest_path(prefix: &str) -> Value {
    let (os, _) = machine();
    let clean: String = prefix.chars().filter(|c| !"\"'`;|&\n\r$".contains(*c)).collect();
    let cmd = if os == "windows" {
        format!("dir /b /ad \"{clean}*\" 2>nul")
    } else {
        format!("for d in {clean}*/ ; do [ -d \"$d\" ] && echo \"$d\" ; done 2>/dev/null")
    };
    let dirs: Vec<String> = ad_run(&cmd)
        .map(|t| t.lines().map(|l| l.trim_end_matches(['/', '\\']).to_string()).filter(|l| !l.is_empty()).take(14).collect())
        .unwrap_or_default();
    json!({ "dirs": dirs })
}

const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py");

/// The `.kicad_mod` filenames present in a directory on the user's machine.
fn list_kicad_mods(dir: &str) -> Vec<String> {
    let (os, _) = machine();
    let out = if os == "windows" {
        ad_run(&format!("dir /b \"{}\\*.kicad_mod\" 2>nul", dir.replace('"', "")))
    } else {
        ad_run(&format!("ls {}/*.kicad_mod 2>/dev/null", sh_quote(dir)))
    };
    out.map(|o| {
        o.lines()
            .filter_map(|l| l.rsplit(['/', '\\']).next().map(|s| s.trim().to_string()))
            .filter(|s| s.ends_with(".kicad_mod"))
            .collect()
    })
    .unwrap_or_default()
}

/// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`,
/// `.kicad_pcb`, root + hierarchical `.kicad_sch`). When `project_only`, make the
/// project self-contained: extract the embedded symbols/footprints into a project
/// library, re-point every `lib_id`/footprint reference to it, and write the
/// project-local sym/fp-lib-tables. Then deliver everything to `dest`.
fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String, Vec<String>) {
    let dest = if dest.trim().is_empty() { format!("{}{}{board}", kicad_dir("Projects"), sep()) } else { dest.trim().to_string() };
    let work = format!("{SCRATCH}/proj-{}", board.replace(['/', ' '], "_"));
    let _ = std::fs::remove_dir_all(&work);
    let _ = std::fs::create_dir_all(&work);
    let mut got = vec![];
    let listing = match curl(&format!("{WIKI}/api/v1/pages/{board}/files")) {
        Some(b) => b,
        None => return (dest, got),
    };
    let v: Value = match serde_json::from_slice(&listing) {
        Ok(v) => v,
        Err(_) => return (dest, got),
    };
    let files = v.get("files").and_then(|f| f.as_array()).cloned().unwrap_or_default();
    let file_names: Vec<String> = files.iter()
        .filter_map(|f| f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).map(String::from))
        .collect();
    let shapes_dir = file_names.iter().find(|n| n.ends_with(".3dshapes")).cloned();
    let mut names = vec![];
    let mut models: std::collections::HashSet<String> = std::collections::HashSet::new();
    let mut proj_models: std::collections::HashSet<String> = std::collections::HashSet::new();
    for name in &file_names {
        if name.ends_with(".kicad_pro") || name.ends_with(".kicad_pcb") || name.ends_with(".kicad_sch") {
            if let Some(bytes) = wiki_bytes(board, name) {
                let base = name.rsplit('/').next().unwrap_or(name).to_string();
                // Rewrite the PCB's custom (model "…") refs to portable ${KIPRJMOD}/3d/<base>
                // so the recipient's KiCad finds the bundled STEPs, not the uploader's
                // absolute paths (issue #6). Collect what to download.
                let data: Vec<u8> = if name.ends_with(".kicad_pcb") {
                    match String::from_utf8(bytes) {
                        Ok(pcb) => {
                            let (out, m, pm) = normalize_pcb_models(&pcb);
                            models.extend(m);
                            proj_models.extend(pm);
                            out.into_bytes()
                        }
                        Err(e) => e.into_bytes(),
                    }
                } else {
                    bytes
                };
                if std::fs::write(format!("{work}/{base}"), data).is_ok() {
                    names.push(base);
                }
            }
        }
    }
    if names.is_empty() {
        return (dest, got);
    }
    // Download the 3D models the board references into work/3d/ so they ship with the
    // project and the ${KIPRJMOD}/3d/<base> refs resolve on the recipient's machine.
    stage_board_models(board, &work, &models, &proj_models, shapes_dir.as_deref());
    ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));

    if project_only {
        // self-contain: run the Python helper, which rewrites files in `work`,
        // creates <board>.kicad_sym / .pretty / lib-tables, and reports the split.
        let libnick = board.replace(['/', ' '], "_");
        let script = format!("{work}/_selfcontain.py");
        let _ = std::fs::write(&script, PYTHON_SELFCONTAIN);
        let out = Command::new("python3")
            .arg(&script)
            .arg(&work)
            .arg(&libnick)
            .args(&names)
            .output();
        if let Ok(o) = out {
            if let Ok(v) = serde_json::from_slice::<Value>(&o.stdout) {
                let root: Vec<String> = v.get("root").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
                let pretty: Vec<String> = v.get("pretty").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
                if !root.is_empty() {
                    ad_send(&root, &dest);
                }
                if !pretty.is_empty() {
                    let pdir = format!("{dest}/{libnick}.pretty");
                    // if the project library already exists, update it as a DIFF: send
                    // the current footprints (add/replace), then delete any .kicad_mod
                    // that's no longer part of the board.
                    let keep: std::collections::HashSet<String> = pretty.iter().filter_map(|p| p.rsplit(['/', '\\']).next().map(String::from)).collect();
                    let stale: Vec<String> = list_kicad_mods(&pdir).into_iter().filter(|f| !keep.contains(f)).collect();
                    ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));
                    ad_send(&pretty, &pdir);
                    for f in stale {
                        ad_shell(&format!("rm -f {}", sh_quote(&format!("{pdir}/{f}"))));
                    }
                }
                got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();
                got.extend(deliver_models(&work, &dest)); // ship the STEPs the board references
                return (dest, got);
            }
        }
    }

    // personal mode (or self-contain failed): deliver the files as fetched.
    let locals: Vec<String> = names.iter().map(|n| format!("{work}/{n}")).collect();
    ad_send(&locals, &dest);
    let mut names = names;
    names.extend(deliver_models(&work, &dest)); // ship the STEPs the board references
    (dest, names)
}

/// Single-quote a string for safe interpolation into an `sh -c` command run on
/// the user's machine. Everything is inert inside single quotes except `'`,
/// which becomes the standard `'\''` splice.
fn sh_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', r"'\''"))
}

/// Library nicknames become file/dir names on the user's machine and are
/// interpolated into shell commands, restrict to a boring identifier charset.
fn valid_lib(s: &str) -> bool {
    !s.is_empty()
        && s.len() <= 64
        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
}

/// Wiki slugs (`owner/name`): same conservative charset plus `/`.
fn valid_slug(s: &str) -> bool {
    !s.is_empty()
        && s.len() <= 128
        && !s.contains("..")
        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
}

/// Pull a file off the user's machine to the container; None if it doesn't
/// exist OR the pull failed, callers that overwrite must disambiguate via
/// `ad_file_exists` before treating None as "absent".
fn ad_pull(remote: &str) -> Option<Vec<u8>> {
    let _ = std::fs::create_dir_all(SCRATCH);
    let args = json!({"filePaths": [remote], "saveTo": SCRATCH}).to_string();
    Command::new("adom-desktop").args(["pull_file", &args]).output().ok()?;
    let base = remote.rsplit('/').next()?;
    std::fs::read(format!("{SCRATCH}/{base}")).ok()
}

/// Send container files to a directory on the user's machine.
fn ad_send(files: &[String], dest: &str) -> bool {
    let args = json!({"filePaths": files, "dest": dest}).to_string();
    Command::new("adom-desktop")
        .args(["send_files", &args])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Ship the board's bundled 3D models (staged into `work/3d/` by stage_board_models)
/// to `<dest>/3d/`, so the delivered project's rewritten `${KIPRJMOD}/3d/<base>` model
/// refs resolve on the recipient's machine (issue #6). Returns the delivered filenames.
fn deliver_models(work: &str, dest: &str) -> Vec<String> {
    let m3 = format!("{work}/3d");
    let paths: Vec<String> = match std::fs::read_dir(&m3) {
        Ok(rd) => rd.filter_map(|e| e.ok()).map(|e| e.path().to_string_lossy().to_string()).collect(),
        Err(_) => return vec![],
    };
    if paths.is_empty() {
        return vec![];
    }
    let d3 = format!("{dest}/3d");
    ad_shell(&format!("mkdir -p {}", sh_quote(&d3)));
    if ad_send(&paths, &d3) {
        paths.iter().filter_map(|p| p.rsplit(['/', '\\']).next().map(|n| format!("3d/{n}"))).collect()
    } else {
        vec![]
    }
}

fn ad_shell(cmd: &str) {
    let args = json!({"command": cmd}).to_string();
    let _ = Command::new("adom-desktop").args(["shell_execute", &args]).output();
}

/// Run a shell command on the user's machine and return its captured reply
/// (whole adom-desktop stdout); None if the bridge call itself failed.
// The desktop bridge (AD) processes shell commands one at a time. Firing several
// at once (now that the HTTP server is multi-threaded) makes concurrent filesystem
// searches thrash the disk and each balloon 5-10x. Serialize them here so every
// bridge call stays fast; the server still handles local/cached requests in parallel.
static BRIDGE_LOCK: Mutex<()> = Mutex::new(());
fn ad_shell_out(cmd: &str) -> Option<String> {
    let _guard = BRIDGE_LOCK.lock().unwrap();
    let args = json!({"command": cmd}).to_string();
    let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?;
    if !out.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&out.stdout).to_string())
}

/// Base64 (standard alphabet, padded).
fn b64(data: &[u8]) -> String {
    const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
    for c in data.chunks(3) {
        let (b0, b1, b2) = (c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0));
        out.push(T[(b0 >> 2) as usize] as char);
        out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
        out.push(if c.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' });
        out.push(if c.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' });
    }
    out
}

fn utf16le(s: &str) -> Vec<u8> {
    s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()
}

/// Wrap a PowerShell script as a `-EncodedCommand` invocation. The AD Windows
/// shell is cmd; passing `powershell -Command "…"` lets the double quotes survive
/// to PowerShell, which then treats the script as a string literal and ECHOES it
/// instead of running it. Base64 UTF-16LE has no quotes/spaces to mangle, so the
/// script runs verbatim regardless of the intervening cmd/JSON quoting.
fn ps_command(script: &str) -> String {
    format!("powershell -NoProfile -STA -EncodedCommand {}", b64(&utf16le(script)))
}

/// A process-unique id (millis + counter) for temp filenames.
fn unique_id() -> String {
    static C: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let n = C.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
    format!("{t}_{n}")
}

/// Board media rendered by the hosted service-kicad container (KiCad 10) and
/// cached per board. Tuple: (schematic sheets `[(name, svg)]`, PCB svg, board GLB).
type BoardMedia = (Vec<(String, String)>, Option<String>, Option<Vec<u8>>);
static BOARD_MEDIA: Mutex<Option<std::collections::HashMap<String, BoardMedia>>> = Mutex::new(None);
static BOARD_RENDERING: Mutex<Option<std::collections::HashSet<String>>> = Mutex::new(None);

fn cached_board(slug: &str) -> Option<BoardMedia> {
    BOARD_MEDIA.lock().unwrap().as_ref().and_then(|m| m.get(slug).cloned())
}

/// POST a KiCad file to service-kicad and return the rendered asset bytes.
/// `kind` = "pcb" | "sch", `fmt` = "svg" | "glb". None on any failure.
fn kicad_export(slug: &str, kind: &str, fmt: &str, query: &str, input: &[u8]) -> Option<Vec<u8>> {
    let _ = std::fs::create_dir_all(SCRATCH);
    let tmp = format!("{SCRATCH}/kx-{}-{kind}-{fmt}.in", slug.replace('/', "_"));
    std::fs::write(&tmp, input).ok()?;
    let url = format!("{KICAD_SVC}/kicad/{kind}/export/{fmt}{query}");
    let out = Command::new("curl")
        .args(["-s", "--max-time", "150", "-X", "POST", "--data-binary", &format!("@{tmp}"), &url])
        .output()
        .ok()?;
    let _ = std::fs::remove_file(&tmp);
    // the service returns the asset directly on success; proxy errors are short text
    if out.status.success() && out.stdout.len() > 64 && !out.stdout.starts_with(b"error code") {
        Some(out.stdout)
    } else {
        None
    }
}

/// Render a board's full hierarchical schematic via service-kicad's project
/// endpoint: fetch every .kicad_sch (+ .kicad_pro) from the wiki with real
/// filenames, tar them, POST, untar one SVG per sheet (root sheet first).
fn render_sch_project(slug: &str) -> Vec<(String, String)> {
    let files = list_page_files(slug);
    let schs: Vec<&String> = files.iter().filter(|n| n.ends_with(".kicad_sch")).collect();
    if schs.is_empty() {
        return vec![];
    }
    let pro = files.iter().find(|n| n.ends_with(".kicad_pro"));
    let key = slug.replace('/', "_");
    let dir = format!("{SCRATCH}/sch-{key}");
    let _ = std::fs::remove_dir_all(&dir);
    if std::fs::create_dir_all(&dir).is_err() {
        return vec![];
    }
    for n in schs.iter().copied().chain(pro) {
        if let Some(b) = wiki_bytes(slug, n) {
            let base = n.rsplit('/').next().unwrap_or(n);
            let _ = std::fs::write(format!("{dir}/{base}"), b);
        }
    }
    let tar_path = format!("{SCRATCH}/sch-{key}.tar");
    if !Command::new("tar").args(["-cf", &tar_path, "-C", &dir, "."]).status().map(|s| s.success()).unwrap_or(false) {
        return vec![];
    }
    let tar_bytes = std::fs::read(&tar_path).unwrap_or_default();
    let out_tar = match kicad_export(slug, "sch", "svg-project", "", &tar_bytes) {
        Some(b) => b,
        None => return vec![],
    };
    let outdir = format!("{SCRATCH}/schout-{key}");
    let _ = std::fs::remove_dir_all(&outdir);
    let _ = std::fs::create_dir_all(&outdir);
    let outtar = format!("{SCRATCH}/schout-{key}.tar");
    if std::fs::write(&outtar, &out_tar).is_err() {
        return vec![];
    }
    let _ = Command::new("tar").args(["-xf", &outtar, "-C", &outdir]).status();
    let pro_stem = pro.map(|n| n.rsplit('/').next().unwrap_or(n).trim_end_matches(".kicad_pro").to_string());
    let mut sheets: Vec<(String, String)> = vec![];
    if let Ok(rd) = std::fs::read_dir(&outdir) {
        for e in rd.flatten() {
            let p = e.path();
            if p.extension().and_then(|s| s.to_str()) == Some("svg") {
                if let Ok(s) = std::fs::read_to_string(&p) {
                    // kicad names them "<Project>" (root) and "<Project>-<Sheet>"; clean up
                    let raw = p.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet").to_string();
                    let name = if pro_stem.as_deref() == Some(raw.as_str()) {
                        "Root".to_string()
                    } else if let Some(st) = &pro_stem {
                        raw.strip_prefix(&format!("{st}-")).unwrap_or(&raw).to_string()
                    } else {
                        raw
                    };
                    sheets.push((name, s));
                }
            }
        }
    }
    // Root sheet first, then the rest alphabetically
    sheets.sort_by(|(a, _), (b, _)| (b == "Root").cmp(&(a == "Root")).then_with(|| a.cmp(b)));
    sheets
}

/// Render the board GLB with the REAL component 3D models. The .kicad_pcb refers
/// to standard KiCad models by `${KICAD9_3DMODEL_DIR}/…` (the service has those)
/// and to custom models by absolute user paths (the service can't). The custom
/// models are bundled on the wiki page's `.3dshapes`; fetch them, rewrite the
/// absolute refs to `${KIPRJMOD}/3d/<name>`, tar the PCB + models, and POST to
/// service-kicad's glb-package endpoint (which sets KIPRJMOD + --subst-models).
/// Rewrite every custom `(model "…")` ref in a .kicad_pcb to a portable
/// `${KIPRJMOD}/3d/<basename>` and collect what to stage. Custom = anything NOT a
/// `${KICAD*_3DMODEL_DIR}` standard ref: a machine-absolute Unix path (/home/…), a
/// Windows path (C:\Users\…), OR a project-relative path (<Board>.3dshapes/…). This
/// makes the board machine-independent, the same fix both the GLB render and the
/// delivered project need (issue #6: uploader absolute paths leak to the recipient).
/// Returns (rewritten_pcb, custom-model basenames, `${KIPRJMOD}/<rel>` project models).
fn normalize_pcb_models(
    pcb: &str,
) -> (String, std::collections::HashSet<String>, std::collections::HashSet<String>) {
    let mut models = std::collections::HashSet::new();
    let mut proj_models = std::collections::HashSet::new();
    let mut out = String::with_capacity(pcb.len());
    let mut rest = pcb;
    while let Some(pos) = rest.find("(model \"") {
        out.push_str(&rest[..pos + 8]);
        let after = &rest[pos + 8..];
        match after.find('"') {
            Some(end) => {
                let path = &after[..end];
                if path.starts_with("${") {
                    if let Some(rel) = path.replace('\\', "/").strip_prefix("${KIPRJMOD}/") {
                        proj_models.insert(rel.to_string());
                    }
                    out.push_str(path);
                } else {
                    let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
                    models.insert(base.to_string());
                    out.push_str(&format!("${{KIPRJMOD}}/3d/{base}"));
                }
                rest = &after[end..];
            }
            None => break,
        }
    }
    out.push_str(rest);
    (out, models, proj_models)
}

/// Download a board's custom + project-relative 3D models from its wiki page into
/// `<dir>` (custom → `<dir>/3d/<base>`, project-relative → `<dir>/<rel>`), so a
/// `${KIPRJMOD}`-rewritten .kicad_pcb resolves its models locally. A model may live in
/// the page's `.3dshapes` folder, a `3d/` folder, or at the referenced relative path.
fn stage_board_models(
    slug: &str,
    dir: &str,
    models: &std::collections::HashSet<String>,
    proj_models: &std::collections::HashSet<String>,
    shapes_dir: Option<&str>,
) {
    let _ = std::fs::create_dir_all(format!("{dir}/3d"));
    for base in models {
        let candidates: Vec<String> = shapes_dir
            .iter()
            .map(|sd| format!("{sd}/{base}"))
            .chain([format!("3d/{base}"), base.clone()])
            .collect();
        for cand in candidates {
            if let Some(mb) = wiki_bytes(slug, &cand) {
                let _ = std::fs::write(format!("{dir}/3d/{base}"), mb);
                break;
            }
        }
    }
    for rel in proj_models {
        if let Some(mb) = wiki_bytes(slug, rel) {
            let dest = format!("{dir}/{rel}");
            if let Some(parent) = std::path::Path::new(&dest).parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            let _ = std::fs::write(&dest, mb);
        }
    }
}

fn render_board_glb(slug: &str) -> Option<Vec<u8>> {
    let files = list_page_files(slug);
    let pcb_name = files.iter().find(|n| n.ends_with(".kicad_pcb"))?;
    let shapes_dir = files.iter().find(|n| n.ends_with(".3dshapes")).cloned();
    let pcb = String::from_utf8(wiki_bytes(slug, pcb_name)?).ok()?;
    let (out, models, proj_models) = normalize_pcb_models(&pcb);
    // Show DNP (Do-Not-Populate) parts in the 3D view. KiCad's 3D export omits DNP
    // footprints by default, so a part left/mis-flagged DNP (e.g. a main IC) renders
    // blank with no body, surprising in a design-browsing tool. Drop the `dnp` attr
    // token so every placed footprint gets its model.
    let out = out.replace(" dnp)", ")").replace(" dnp ", " ");
    // assemble the bundle: board.kicad_pcb (rewritten) + 3d/<custom models>
    let key = slug.replace('/', "_");
    let bundle = format!("{SCRATCH}/glb-{key}");
    let _ = std::fs::remove_dir_all(&bundle);
    std::fs::create_dir_all(format!("{bundle}/3d")).ok()?;
    std::fs::write(format!("{bundle}/board.kicad_pcb"), &out).ok()?;
    stage_board_models(slug, &bundle, &models, &proj_models, shapes_dir.as_deref());
    let targz = format!("{SCRATCH}/glb-{key}.tar.gz");
    if !Command::new("tar").args(["-czf", &targz, "-C", &bundle, "."]).status().map(|s| s.success()).unwrap_or(false) {
        return None;
    }
    let bytes = std::fs::read(&targz).ok()?;
    let raw = kicad_export(slug, "pcb", "glb-package", "?F=--include-silkscreen&F=--include-pads", &bytes)?;
    // kicad-cli emits one primitive per part feature (a complex board is thousands
    // of draw calls) and no compression, so a dense board like the USB3->Ethernet
    // molecule loads and orbits slowly. Collapse it before publishing.
    Some(optimize_glb(&raw, &key))
}

/// Make the raw kicad-cli board GLB lean for the 3D viewer: merge primitives by
/// material (thousands of draw calls -> a few dozen), weld/prune, and apply
/// KHR_mesh_quantization (Babylon-native, needs no decoder — NOT draco, per the
/// molecule pipeline's "don't draco molecule-sized GLBs" rule). Geometry is
/// unchanged (same triangles). Falls back to the raw bytes if the optimizer or
/// node is unavailable, so a board still renders even without the toolchain.
fn optimize_glb(bytes: &[u8], key: &str) -> Vec<u8> {
    // Locate glb_optimize.mjs: env override, then next to the binary, then the
    // install share dir (where install.sh puts it + its node_modules), then the
    // dev checkout. First that exists wins; if none, return the raw GLB unchanged.
    let mut cands: Vec<String> = Vec::new();
    if let Ok(p) = std::env::var("ADOM_GLB_OPTIMIZE") { cands.push(p); }
    if let Ok(exe) = std::env::current_exe() {
        if let Some(d) = exe.parent() { cands.push(d.join("glb_optimize.mjs").to_string_lossy().into_owned()); }
    }
    if let Ok(home) = std::env::var("HOME") {
        cands.push(format!("{home}/.local/share/adom-nucleus/glb_optimize.mjs"));
    }
    cands.push("/home/adom/project/glb_optimize.mjs".to_string());
    let opt = match cands.into_iter().find(|p| std::path::Path::new(p).exists()) {
        Some(p) => p, None => return bytes.to_vec(),
    };
    let tin = format!("{SCRATCH}/glbopt-{key}-in.glb");
    let tout = format!("{SCRATCH}/glbopt-{key}-out.glb");
    if std::fs::write(&tin, bytes).is_err() { return bytes.to_vec(); }
    let _ = std::fs::remove_file(&tout);
    let ok = Command::new("node").args([opt.as_str(), &tin, &tout]).status().map(|s| s.success()).unwrap_or(false);
    let out = if ok { std::fs::read(&tout).ok().filter(|b| b.len() > 100) } else { None };
    let _ = std::fs::remove_file(&tin); let _ = std::fs::remove_file(&tout);
    out.unwrap_or_else(|| bytes.to_vec())
}

/// Fetch a board's .kicad_pcb + full hierarchical schematic and render the
/// schematic sheets, the PCB SVG, and the board GLB via service-kicad.
fn toast_queue() -> &'static Mutex<Vec<serde_json::Value>> {
    static Q: std::sync::OnceLock<Mutex<Vec<serde_json::Value>>> = std::sync::OnceLock::new();
    Q.get_or_init(|| Mutex::new(Vec::new()))
}

fn board_view_cache() -> &'static Mutex<std::collections::HashMap<String, String>> {
    static C: std::sync::OnceLock<Mutex<std::collections::HashMap<String, String>>> = std::sync::OnceLock::new();
    C.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}

/// Render the interactive PCB / schematic view HTML by shelling the standalone
/// viewers (`adom-layout-viewer` / `adom-schematic-viewer` `embed`) on the board's
/// raw KiCad file (fetched from the wiki page). Cached per (slug, kind, version).
fn render_board_view(slug: &str, kind: &str, ver: Option<&str>) -> Option<String> {
    render_board_view_sheet(slug, kind, ver, None)
}

/// `sheet` selects a specific hierarchical child `.kicad_sch` (by basename) instead
/// of the project's root sheet, so a viewer can navigate into sub-sheets.
fn render_board_view_sheet(slug: &str, kind: &str, ver: Option<&str>, sheet: Option<&str>) -> Option<String> {
    let key = format!("{slug}|{kind}|{}|{}", ver.unwrap_or("cur"), sheet.unwrap_or(""));
    if let Some(h) = board_view_cache().lock().unwrap().get(&key) { return Some(h.clone()); }
    let files = list_page_files(slug);
    // Source files for EVERY supported EDA, best-first. The viewers detect the
    // format from the file itself, so the binary only depends on pcb-vs-sch:
    //   KiCad .kicad_pcb/.kicad_sch · Altium .PcbDoc/.SchDoc · Fusion/EAGLE .brd/.sch
    let (exts, bin) = match kind {
        "pcb" => (["kicad_pcb", "pcbdoc", "brd"].as_slice(), "adom-layout-viewer"),
        "sch" => (["kicad_sch", "schdoc", "sch"].as_slice(), "adom-schematic-viewer"),
        _ => return None,
    };
    let has_ext = |n: &str, e: &str| n.rsplit('.').next().map(|x| x.eq_ignore_ascii_case(e)).unwrap_or(false);
    // for schematics prefer the ROOT sheet (the one matching the project stem)
    let root_stem = files.iter()
        .find(|n| has_ext(n, "kicad_pro") || has_ext(n, "prjpcb"))
        .and_then(|n| n.rsplit('/').next())
        .and_then(|n| n.rsplit_once('.'))
        .map(|(stem, _)| stem.to_string());
    let mut fname = None;
    // an explicit child sheet wins: match the requested basename on the page
    if kind == "sch" {
        if let Some(want) = sheet {
            fname = files.iter().find(|n| n.rsplit('/').next().unwrap_or(n).eq_ignore_ascii_case(want)).cloned();
        }
    }
    if fname.is_none() {
        for e in exts {
            if kind == "sch" {
                if let Some(st) = &root_stem {
                    if let Some(f) = files.iter().find(|n| {
                        let base = n.rsplit('/').next().unwrap_or(n);
                        has_ext(base, e) && base.rsplit_once('.').map(|(s, _)| s == st).unwrap_or(false)
                    }) { fname = Some(f.clone()); break; }
                }
            }
            if let Some(f) = files.iter().find(|n| has_ext(n, e)) { fname = Some(f.clone()); break; }
        }
    }
    let fname = fname?;
    let ext = fname.rsplit('.').next().unwrap_or("bin").to_string();
    let bytes = wiki_bytes(slug, &fname)?;
    let dir = format!("{SCRATCH}/view-{}", slug.replace(['/', ' '], "_"));
    std::fs::create_dir_all(&dir).ok()?;
    // include the sheet basename so concurrent requests for different child sheets
    // don't race on one temp file
    let tag = sheet.map(|s| s.trim_end_matches(".kicad_sch")).unwrap_or("root");
    let inp = format!("{dir}/{kind}-{tag}.{ext}");
    let outp = format!("{dir}/{kind}-{tag}.html");
    std::fs::write(&inp, &bytes).ok()?;
    // For a KiCad schematic, also stage every OTHER .kicad_sch on the page next to
    // the input file and pass --sheet-dir, so the viewer bundles all child sheets
    // into the embed and hierarchical navigation is instant (no reload).
    let mut args: Vec<String> = vec!["embed".into(), "--file".into(), inp.clone(), "--out".into(), outp.clone()];
    if kind == "sch" && ext == "kicad_sch" {
        for n in files.iter().filter(|n| has_ext(n, "kicad_sch")) {
            let bn = n.rsplit('/').next().unwrap_or(n);
            if bn == inp.rsplit('/').next().unwrap_or(&inp) { continue; }
            if let Some(b) = wiki_bytes(slug, n) { let _ = std::fs::write(format!("{dir}/{bn}"), b); }
        }
        // Stage the AUTHENTIC pre-rendered sheet SVGs (molecule-publish renders the
        // FULL project, so these have real KiCad fonts and the hierarchical root is
        // complete) as `<sheetfile>.base.svg`, which the viewer uses verbatim instead
        // of re-exporting single files via service-kicad (which can't render a lone
        // hierarchical root). render/manifest.json lists sheet NAMES in sheet-NNN order.
        stage_authentic_sheets(slug, &dir, &inp, &bytes);
        args.push("--sheet-dir".into()); args.push(dir.clone());
    }
    let ok = Command::new(bin).args(&args).status().map(|s| s.success()).unwrap_or(false);
    if !ok { eprintln!("board-view: {bin} embed failed for {slug}"); return None; }
    let html = std::fs::read_to_string(&outp).ok()?;
    board_view_cache().lock().unwrap().insert(key, html.clone());
    Some(html)
}

/// Fetch the page's authentic pre-rendered `render/sheet-NNN.svg` and stage each as
/// `<dir>/<sheetfile>.base.svg` so the viewer renders every sheet from the real
/// KiCad export. `render/manifest.json` gives sheet NAMES in sheet-NNN order; we map
/// name -> file via the root's own `(sheet (property "Sheetname")(property "Sheetfile"))`.
fn stage_authentic_sheets(slug: &str, dir: &str, root_inp: &str, root_bytes: &[u8]) {
    // manifest sheet names, in sheet-NNN.svg order
    let names: Vec<String> = match wiki_bytes(slug, "render/manifest.json")
        .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
        .and_then(|m| m.get("sheets").and_then(|s| s.as_array()).map(|a|
            a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()))
    { Some(v) => v, None => return };
    // name -> child file, parsed from the root sheet (no regex crate here). A sheet
    // symbol carries `(property "Sheetname" "X")` then `(property "Sheetfile" "x")`.
    let root_txt = String::from_utf8_lossy(root_bytes);
    let quoted_after = |hay: &str, key: &str| -> Option<(String, usize)> {
        let k = format!("\"{key}\"");
        let ki = hay.find(&k)? + k.len();
        let q1 = hay[ki..].find('"')? + ki + 1;
        let q2 = hay[q1..].find('"')? + q1;
        Some((hay[q1..q2].to_string(), q2))
    };
    let mut name_file: std::collections::HashMap<String, String> = std::collections::HashMap::new();
    let mut cur = &root_txt[..];
    while let Some((name, end)) = quoted_after(cur, "Sheetname") {
        let rest = &cur[end..];
        // Sheetfile must be within the same sheet block (nearby)
        if let Some((file, fend)) = quoted_after(&rest[..rest.len().min(600)], "Sheetfile") {
            name_file.insert(name, file);
            cur = &rest[fend..];
        } else { break; }
    }
    let root_base = root_inp.rsplit('/').next().unwrap_or(root_inp).to_string();
    for (i, name) in names.iter().enumerate() {
        let svg = match wiki_bytes(slug, &format!("render/sheet-{i:03}.svg")) { Some(s) => s, None => continue };
        // index 0 (the "Root") -> the root input file; others -> the mapped child file
        let target = if i == 0 { root_base.clone() } else { match name_file.get(name) { Some(f) => f.clone(), None => continue } };
        let _ = std::fs::write(format!("{dir}/{target}.base.svg"), svg);
    }
}

/// Serve `/enrich` for an embedded viewer: run `adom-schematic-viewer enrich` and
/// return its JSON. Cached per part so repeated hovers are instant (and so we don't
/// hammer JLCPCB from a mousemove handler).
fn board_view_enrich(url: &str) -> String {
    let q = url.split_once('?').map(|(_, q)| q).unwrap_or("");
    let get = |k: &str| -> String {
        q.split('&')
            .find_map(|kv| kv.strip_prefix(&format!("{k}=")))
            .map(|v| pct(v))
            .unwrap_or_default()
    };
    let (mpn, lcsc) = (get("mpn"), get("lcsc"));
    if mpn.is_empty() && lcsc.is_empty() { return "{\"error\":\"no query\"}".into(); }
    let key = format!("enrich|{mpn}|{lcsc}");
    if let Some(hit) = board_view_cache().lock().unwrap().get(&key) { return hit.clone(); }
    let out = Command::new("adom-schematic-viewer")
        .args(["enrich", "--mpn", &mpn, "--lcsc", &lcsc])
        .output().ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .filter(|s| s.starts_with('{'))
        .unwrap_or_else(|| "{\"error\":\"lookup failed\"}".into());
    board_view_cache().lock().unwrap().insert(key, out.clone());
    out
}

/// minimal percent-decode (+ as space) for query values
fn pct(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = String::with_capacity(s.len());
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'+' => { out.push(' '); i += 1; }
            b'%' if i + 2 < b.len() => {
                match u8::from_str_radix(&s[i + 1..i + 3], 16) {
                    Ok(v) => { out.push(v as char); i += 3; }
                    Err(_) => { out.push('%'); i += 1; }
                }
            }
            c => { out.push(c as char); i += 1; }
        }
    }
    out
}

fn render_board_media(slug: &str) -> BoardMedia {
    let files = list_page_files(slug);
    let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();
    // schematic: render the whole hierarchical project so the root's sheet
    // symbols resolve (a single-file render can't), one SVG per sheet, root first
    let sheets = render_sch_project(slug);
    let pcb_bytes = pcb.as_ref().and_then(|n| wiki_bytes(slug, n));
    // draw order = z-order (last on top): bottom/inner copper first (dimmed in
    // the UI), then F.Cu on top (kept opaque = the solid top layer), then the
    // silk/edge/fab docs. No F.Mask (its magenta would paint the pads purple).
    let pcb_svg = pcb_bytes.as_ref().and_then(|b| kicad_export(slug, "pcb", "svg", "?layers=B.Cu,In2.Cu,In1.Cu,F.Cu,F.Silkscreen,Edge.Cuts", b)).and_then(|b| String::from_utf8(b).ok());
    // GLB with the real component 3D models (bundle the custom .3dshapes models)
    let glb = render_board_glb(slug);
    (sheets, pcb_svg, glb)
}

/// The pre-rendered assets published to a board's `render/` folder, if any
/// (manifest = which assets exist + the ordered sheet names). When present the
/// Manager serves them directly, no service-kicad, no wait, instead of
/// rendering live. Regenerated on every board publish (`--render-board`).
fn stored_render(slug: &str) -> Option<Value> {
    wiki_bytes(slug, "render/manifest.json")
        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
        // must actually look like a manifest, not e.g. a 404 error object
        .filter(|m| m.get("v").is_some() || m.get("glb").is_some() || m.get("sheets").is_some())
}

/// {rendering, has_3d, has_sch, has_pcb}. With pre-rendered assets it returns
/// immediately; otherwise rendering runs in a background thread (a board GLB can
/// take ~a minute) so the single-threaded server stays responsive and the UI
/// polls until `rendering` is false.
fn board_media(slug: &str) -> Value {
    // fast path: assets pre-rendered to the page's render/ folder at publish time
    if let Some(m) = stored_render(slug) {
        let sheets: Vec<String> = m.get("sheets").and_then(|s| s.as_array())
            .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
            .unwrap_or_default();
        return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": true, "has_assets": true, "sheets": sheets,
            "has_3d": m.get("glb").and_then(|b| b.as_bool()).unwrap_or(false),
            "has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false),
            // version archive (empty on legacy flat manifests → the UI shows single-current)
            "versions": m.get("versions").cloned().unwrap_or_else(|| json!([])),
            "current": m.get("current").cloned().unwrap_or(Value::Null)});
    }
    // no stored render/ folder from here on, the board live-renders instead.
    if let Some((sheets, pcb, glb)) = cached_board(slug) {
        let names: Vec<String> = sheets.into_iter().map(|(n, _)| n).collect();
        return json!({"rendering": false, "is_kicad": true, "has_render": false, "has_assets": true, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});
    }
    // only KiCad boards (with a .kicad_pcb / .kicad_sch) can be rendered
    let files = list_page_files(slug);
    let has = |ext: &str| files.iter().any(|n| n.ends_with(ext));
    let is_kicad = has(".kicad_pcb") || has(".kicad_sch");
    // the assets the viewer needs to display the board even WITHOUT a stored render/
    // folder: the KiCad source to render the PCB + hierarchical schematic (3D comes
    // from a page .glb / model_3d_path, else is live-rendered from the .kicad_pcb).
    // Only when these are absent is the board truly un-renderable → show the red bar.
    let has_assets = has(".kicad_pcb") && has(".kicad_sch");
    if !is_kicad {
        // Not a KiCad board (EAGLE/Fusion .brd/.sch, or STEP/GLB-only). We can't
        // render the 2D PCB/schematic (service-kicad is KiCad-only), but if the page
        // ships a model GLB we can still show the 3D, served by the board-glb route's
        // <slug>.glb fallback. So report has_3d from the GLB rather than giving up.
        let has_3d = has(".glb");
        return json!({"rendering": false, "is_kicad": false, "has_render": false,
            "has_assets": has_3d, "sheets": [], "has_3d": has_3d, "has_pcb": false});
    }
    let start = {
        let mut g = BOARD_RENDERING.lock().unwrap();
        let set = g.get_or_insert_with(std::collections::HashSet::new);
        set.insert(slug.to_string())
    };
    if start {
        let s = slug.to_string();
        std::thread::spawn(move || {
            let v = render_board_media(&s);
            BOARD_MEDIA.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert(s.clone(), v);
            if let Some(set) = BOARD_RENDERING.lock().unwrap().as_mut() {
                set.remove(&s);
            }
        });
    }
    json!({"rendering": true, "is_kicad": true, "has_render": false, "has_assets": has_assets, "sheets": [], "has_3d": false, "has_pcb": false})
}

/// Does a file exist on the user's machine? None = couldn't determine
/// (bridge/desktop error), callers must treat that as "assume it exists".
fn ad_file_exists(path: &str) -> Option<bool> {
    let out = ad_shell_out(&format!("[ -f {} ] && echo __EXISTS__ || echo __ABSENT__", sh_quote(path)))?;
    if out.contains("__EXISTS__") {
        Some(true)
    } else if out.contains("__ABSENT__") {
        Some(false)
    } else {
        None
    }
}

/// Extract the top-level `(symbol "NAME" …)` block (balanced parens) from a
/// single-symbol .kicad_sym → (name, block).
fn extract_symbol_block(t: &str) -> Option<(String, String)> {
    let i = t.find("(symbol \"")?;
    let name = t[i + 9..].split('"').next()?.to_string();
    let b = t.as_bytes();
    let mut depth = 0i32;
    for j in i..b.len() {
        match b[j] {
            b'(' => depth += 1,
            b')' => {
                depth -= 1;
                if depth == 0 {
                    return Some((name, t[i..=j].to_string()));
                }
            }
            _ => {}
        }
    }
    None
}

/// Merge symbol blocks into an existing (or new) `.kicad_sym`, skipping names
/// already present.
fn merge_symbols(existing: Option<String>, blocks: &[(String, String)]) -> String {
    let base = existing.filter(|s| s.contains("kicad_symbol_lib")).unwrap_or_else(|| {
        // Default tier (V8), this lib lands on the user's desktop, whose
        // KiCad (9.0.9) rejects files newer than itself.
        let kv = altium_codec::kicad_export::KicadVersion::default();
        format!(
            "(kicad_symbol_lib\n\t(version {})\n\t(generator \"adom-lbr\")\n\t(generator_version \"{}\")\n)\n",
            kv.sym_version(),
            kv.generator_version().unwrap_or("8.0")
        )
    });
    let mut names = std::collections::HashSet::new();
    let mut idx = 0;
    while let Some(p) = base[idx..].find("(symbol \"") {
        let s = idx + p + 9;
        match base[s..].find('"') {
            Some(e) => {
                names.insert(base[s..s + e].to_string());
                idx = s + e;
            }
            None => break,
        }
    }
    let mut add = String::new();
    for (name, block) in blocks {
        if !names.contains(name) {
            add.push('\t');
            add.push_str(block);
            add.push('\n');
        }
    }
    let cut = base.rfind(')').unwrap_or(base.len());
    format!("{}{}{}", &base[..cut], add, &base[cut..])
}

/// POST /api/apply, body {items:[{slug,symLib,fpLib}], target, project?, board?}.
/// Converts each component, merges symbols into the mapped KiCad symbol library,
/// and drops footprints into the footprint `.pretty`, all on the user's machine.
// Live progress for the background apply, polled by the UI via /api/apply-progress.
static PROGRESS: Mutex<Option<Value>> = Mutex::new(None);
fn set_progress(v: Value) {
    *PROGRESS.lock().unwrap() = Some(v);
}
fn get_progress() -> Value {
    PROGRESS.lock().unwrap().clone().unwrap_or_else(|| json!({"stage":"idle","done":true}))
}

/// POST /api/apply, validate, then run the work on a background thread so the UI
/// can poll live stage updates. Returns immediately with `{status:"started"}`.
fn start_apply(body: &str) -> Value {
    let req: Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return json!({"status":"error","error":format!("bad body: {e}")}),
    };
    if req.get("target").and_then(|t| t.as_str()).unwrap_or("kicad") != "kicad" {
        return json!({"status":"error","error":"only KiCad add-to-library is implemented right now"});
    }
    // One apply at a time, concurrent runs share SCRATCH and the PROGRESS cell.
    {
        let p = PROGRESS.lock().unwrap();
        if let Some(v) = p.as_ref() {
            if v.get("done").and_then(|d| d.as_bool()) == Some(false) {
                return json!({"status":"error","error":"an apply is already running",
                              "hint":"poll /api/apply-progress until done:true, then retry"});
            }
        }
    }
    let board = req.get("board").and_then(|b| b.as_str()).map(String::from);
    if let Some(b) = &board {
        if !valid_slug(b) {
            return json!({"status":"error","error":format!("invalid board slug: {b:?}")});
        }
    }
    let mut bad: Vec<String> = vec![];
    let items: Vec<(String, String, String)> = req
        .get("items")
        .and_then(|i| i.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|it| {
                    let slug = it.get("slug").and_then(|s| s.as_str())?.to_string();
                    if slug.is_empty() {
                        return None;
                    }
                    let sym = it.get("symLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();
                    let fp = it.get("fpLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();
                    // These become paths + shell args on the user's machine.
                    if !valid_slug(&slug) || !valid_lib(&sym) || !valid_lib(&fp) {
                        bad.push(slug);
                        return None;
                    }
                    Some((slug, sym, fp))
                })
                .collect()
        })
        .unwrap_or_default();
    if !bad.is_empty() {
        return json!({"status":"error",
                      "error":format!("invalid slug or library name on: {}", bad.join(", ")),
                      "hint":"library names may only contain [A-Za-z0-9_.-]"});
    }
    if items.is_empty() && board.is_none() {
        return json!({"status":"error","error":"no components selected"});
    }
    let dest = req.get("dest").and_then(|d| d.as_str()).unwrap_or("").to_string();
    if dest.contains(['\n', '\r', '\0']) {
        return json!({"status":"error","error":"invalid dest path"});
    }
    let project_only = req.get("project").and_then(|p| p.as_bool()).unwrap_or(false);
    let total = items.len();
    set_progress(json!({"stage":"Starting…","current":0,"total":total,"done":false}));
    std::thread::spawn(move || run_apply(items, board, dest, project_only));
    json!({"status":"started","total":total})
}

/// Every symbol name already present anywhere in the user's symbol libraries,
/// mapped to the library that has it, so an existing symbol is never re-added.
/// Best-effort (Unix grep / Windows Select-String); empty on failure.
fn existing_symbol_index(files: &[String]) -> std::collections::HashMap<String, String> {
    let mut idx = std::collections::HashMap::new();
    let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();
    if files.is_empty() {
        return idx;
    }
    let (os, _) = machine();
    let out = if os == "windows" {
        let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");
        ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '\\(symbol \"([^\"]+)\"' | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Groups[1].Value }}")))
    } else {
        let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");
        ad_run(&format!("grep -HoE '\\(symbol \"[^\"]+\"' {list} 2>/dev/null"))
    };
    if let Some(out) = out {
        for line in out.lines() {
            let (file, name) = if os == "windows" {
                match line.rsplit_once('\t') { Some((f, n)) => (f, n.trim().to_string()), None => continue }
            } else {
                match line.split_once(":(symbol \"") { Some((f, r)) => (f, r.trim_end_matches('"').to_string()), None => continue }
            };
            if !name.is_empty() {
                let lib = file.rsplit(['/', '\\']).next().unwrap_or(file).trim_end_matches(".kicad_sym").to_string();
                idx.entry(name).or_insert(lib);
            }
        }
    }
    idx
}

/// Which of the given MPNs appear (case-insensitively) in the user's symbol
/// libraries → a map from the found MPN (lowercased) to the library it's in.
/// Catches parts whose library symbol embeds the manufacturer part number.
fn mpns_present_in_libs(files: &[String], mpns: &[String]) -> std::collections::HashMap<String, String> {
    let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();
    let pats: Vec<String> = mpns.iter()
        .map(|m| m.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect::<String>())
        .filter(|s| s.len() >= 4)
        .collect();
    if files.is_empty() || pats.is_empty() {
        return Default::default();
    }
    let (os, _) = machine();
    let pat = pats.join("|");
    let out = if os == "windows" {
        let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");
        ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '(?i)({pat})' -AllMatches | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Value }}")))
    } else {
        let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");
        ad_run(&format!("grep -HioE '({pat})' {list} 2>/dev/null"))
    };
    // parse "<file>:<match>" (unix, split on the LAST ':') / "<path>\t<match>" (windows)
    let mut map = std::collections::HashMap::new();
    if let Some(out) = out {
        for line in out.lines() {
            let split = if os == "windows" { line.rsplit_once('\t') } else { line.rsplit_once(':') };
            if let Some((file, m)) = split {
                let m = m.trim().to_lowercase();
                if m.is_empty() {
                    continue;
                }
                let lib = file.rsplit(['/', '\\']).next().unwrap_or(file).trim_end_matches(".kicad_sym").to_string();
                map.entry(m).or_insert(lib);
            }
        }
    }
    map
}

/// Normalize a value/name for fuzzy compare: lowercase, µ→u, alphanumerics only.
fn norm_val(s: &str) -> String {
    s.to_lowercase().replace(['µ', 'μ'], "u").chars().filter(|c| c.is_ascii_alphanumeric()).collect()
}

/// The imperial size code in a package string (0402/0603/0805/1206/…), or "".
fn pkg_size(package: &str) -> String {
    let b = package.as_bytes();
    let mut i = 0;
    while i + 4 <= b.len() {
        if b[i..i + 4].iter().all(|c| c.is_ascii_digit())
            && (i == 0 || !b[i - 1].is_ascii_digit())
            && (i + 4 == b.len() || !b[i + 4].is_ascii_digit())
        {
            return package[i..i + 4].to_string();
        }
        i += 1;
    }
    String::new()
}

/// Derive a (normalized-value, package) signature from a component's symbol name
/// and footprint name, the wiki symbols encode it (`C_10uF_0402`, footprint
/// `C_0402_1005Metric`) even when the part JSON's value/package fields are blank.
/// None when there's no package code (e.g. an IC), those match by MPN only.
fn value_package_sig(sym_name: &str, fp_name: &str) -> Option<(String, String)> {
    let pkg = {
        let p = pkg_size(fp_name);
        if p.is_empty() { pkg_size(sym_name) } else { p }
    };
    if pkg.is_empty() {
        return None;
    }
    // the value token: the first name segment with a digit that isn't the package
    let val = sym_name
        .split(|c: char| !c.is_ascii_alphanumeric() && c != '.')
        .find(|t| t.chars().any(|c| c.is_ascii_digit()) && pkg_size(t).is_empty())?;
    let v = norm_val(val);
    if v.len() >= 2 { Some((v, pkg)) } else { None }
}

/// Footprint name (normalized) → the user's `.pretty` library that contains it.
fn footprint_index(dirs: &[String]) -> std::collections::HashMap<String, String> {
    let mut idx = std::collections::HashMap::new();
    let dirs: Vec<&String> = dirs.iter().filter(|d| !d.is_empty()).collect();
    if dirs.is_empty() {
        return idx;
    }
    let (os, _) = machine();
    let out = if os == "windows" {
        let cmds: Vec<String> = dirs.iter().map(|d| format!("dir /b /s \"{}\\*.kicad_mod\" 2>nul", d.replace('"', ""))).collect();
        ad_run(&cmds.join(" & "))
    } else {
        let quoted = dirs.iter().map(|d| sh_quote(d)).collect::<Vec<_>>().join(" ");
        ad_run(&format!("for d in {quoted}; do ls \"$d\"/*.kicad_mod 2>/dev/null; done"))
    };
    if let Some(out) = out {
        for line in out.lines() {
            let p = line.trim();
            if p.is_empty() {
                continue;
            }
            let fpname = p.rsplit(['/', '\\']).next().unwrap_or(p).trim_end_matches(".kicad_mod");
            let lib = parent_dir(p).rsplit(['/', '\\']).next().unwrap_or("").trim_end_matches(".pretty").to_string();
            if !fpname.is_empty() {
                idx.entry(norm_val(fpname)).or_insert_with(|| lib.clone());
                // also index the base part name (before the package suffix), e.g.
                // "BMA580" from "BMA580_WLCSP-6", so stub parts match by base-part.
                if let Some(base) = fpname.split('_').next() {
                    if base != fpname && base.len() >= 3 {
                        idx.entry(norm_val(base)).or_insert_with(|| lib.clone());
                    }
                }
            }
        }
    }
    idx
}

/// Which of the given component slugs are ALREADY in the user's libraries, with
/// the reason and the SYMBOL + FOOTPRINT library each is found in, so neither a
/// duplicate symbol nor a duplicate footprint gets added. Symbol match: MPN (grep
/// or a lib symbol name that PREFIXES the MPN, covering stub parts), exact symbol
/// name, else value + package. Footprint match: the `.kicad_mod` is present in a
/// `.pretty`. Returns (slug, reason, symLib, fpLib), either lib may be empty.
fn components_in_libs(slugs: &[String]) -> Vec<(String, String, String, String)> {
    let libs = user_libraries();
    let uris = |key: &str| -> Vec<String> {
        libs.get(key).and_then(|a| a.as_array())
            .map(|a| a.iter().filter_map(|l| l.get("uri").and_then(|u| u.as_str()).map(String::from)).collect())
            .unwrap_or_default()
    };
    let sym_files = uris("symLibs");
    let fp_dirs = uris("fpLibs");
    if (sym_files.is_empty() && fp_dirs.is_empty()) || slugs.is_empty() {
        return vec![];
    }
    let mpn_map = mpns_present_in_libs(&sym_files, &slugs.iter().map(|s| up(s)).collect::<Vec<_>>());
    let norm_index: std::collections::HashMap<String, String> =
        existing_symbol_index(&sym_files).into_iter().map(|(n, l)| (norm_val(&n), l)).collect();
    let fp_index = footprint_index(&fp_dirs);
    let mut out = vec![];
    for slug in slugs {
        let nmpn = norm_val(&up(slug));
        let part = load_part(slug, &up(slug)).ok().map(|(p, _)| p);
        // symbol library this part already lives in (if any) + why
        let (reason, sym_lib): (&str, String) = if let Some(lib) = mpn_map.get(&up(slug).to_lowercase()) {
            ("mpn", lib.clone())
        } else if let Some((_, lib)) = norm_index.iter().find(|(n, _)| n.len() >= 5 && nmpn.starts_with(n.as_str())) {
            ("mpn", lib.clone())
        } else if let Some(p) = &part {
            let sn = norm_val(&p.symbol.name);
            if sn.len() >= 3 && norm_index.contains_key(&sn) {
                ("symbol", norm_index[&sn].clone())
            } else if let Some((v, pk)) = value_package_sig(&p.symbol.name, &p.footprint.name) {
                match norm_index.iter().find(|(n, _)| n.contains(&v) && n.contains(&pk)) {
                    Some((_, lib)) => ("value+package", lib.clone()),
                    None => ("", String::new()),
                }
            } else {
                ("", String::new())
            }
        } else {
            ("", String::new())
        };
        // footprint library this part's .kicad_mod already lives in (if any) -
        // by exact footprint name, else a footprint base name that PREFIXES the MPN
        let fp_lib = part.as_ref()
            .and_then(|p| fp_index.get(&norm_val(&p.footprint.name)).cloned())
            .or_else(|| fp_index.iter().find(|(n, _)| n.len() >= 5 && nmpn.starts_with(n.as_str())).map(|(_, l)| l.clone()))
            .unwrap_or_default();
        if !sym_lib.is_empty() || !fp_lib.is_empty() {
            let reason = if sym_lib.is_empty() { "footprint" } else { reason };
            out.push((slug.clone(), reason.to_string(), sym_lib, fp_lib));
        }
    }
    out
}

/// Diff a board's components against its self-contained project library at `dest`
/// on the user's machine. Returns which components are being ADDED (not yet in the
/// project lib), KEPT (already there), and which library symbols would be REMOVED
/// (in the lib, no longer on the board). `exists` = the project lib was found.
fn project_diff(board: &str, dest: &str) -> Value {
    let libnick = board.replace(['/', ' '], "_");
    let s = sep();
    let mut lib_path = format!("{}{s}{libnick}.kicad_sym", dest.trim_end_matches(['/', '\\']));
    // if it's not at the given destination, search the user's Documents for it
    // (the default path guess can be wrong, e.g. "KiCad" vs "KiCAD")
    if ad_file_exists(&lib_path) != Some(true) {
        let (os, home) = machine();
        let search = if os == "windows" {
            format!("dir /b /s \"{home}\\Documents\\{libnick}.kicad_sym\" 2>nul")
        } else {
            format!("find {home}/Documents -maxdepth 6 -name '{libnick}.kicad_sym' 2>/dev/null | head -1")
        };
        if let Some(found) = ad_run(&search).filter(|f| !f.trim().is_empty()) {
            lib_path = found.lines().next().unwrap_or("").trim().to_string();
        }
    }
    // top-level symbol names already in the project lib (skip _N_N unit sub-symbols)
    let existing: Vec<String> = ad_pull(&lib_path)
        .and_then(|b| String::from_utf8(b).ok())
        .map(|t| {
            let mut names = vec![];
            let mut idx = 0;
            while let Some(p) = t[idx..].find("(symbol \"") {
                let start = idx + p + 9;
                match t[start..].find('"') {
                    Some(e) => {
                        let raw = &t[start..start + e];
                        let unit = { let tail: Vec<&str> = raw.rsplitn(3, '_').collect(); tail.len() == 3 && tail[0].chars().all(|c| c.is_ascii_digit()) && tail[1].chars().all(|c| c.is_ascii_digit()) && !tail[0].is_empty() && !tail[1].is_empty() };
                        if !unit {
                            names.push(raw.to_string());
                        }
                        idx = start + e;
                    }
                    None => break,
                }
            }
            names
        })
        .unwrap_or_default();
    let exists = !existing.is_empty() || ad_file_exists(&lib_path) == Some(true);
    // The project library names its symbols the way the board's schematic does
    // (generic "C"/"R"/"BMA580"), and neither carries MPNs, so compare the library
    // against the board's actual SCHEMATIC symbol set, not the components' names.
    let sch_syms = board_schematic_symbols(board);
    let slugs: Vec<String> = board_deps(board).get("deps").and_then(|d| d.as_array())
        .map(|a| a.iter().filter_map(|x| x.get("slug").and_then(|s| s.as_str()).map(String::from)).collect())
        .unwrap_or_default();
    // library symbols no longer used anywhere on the board → flagged for deletion
    let remove: Vec<String> = existing.iter().filter(|n| !sch_syms.contains(&norm_val(n))).cloned().collect();
    // the project library is built from THIS board: if it already exists every
    // component is already in it (kept); otherwise every component is created (added).
    let (add, keep) = if exists { (Vec::<String>::new(), slugs) } else { (slugs, Vec::<String>::new()) };
    json!({ "exists": exists, "add": add, "keep": keep, "remove": remove })
}

/// The set of symbol names (normalized) the board's schematic actually uses, the
/// name after the `lib_id "Lib:Name"` colon, across every `.kicad_sch` on the page.
fn board_schematic_symbols(board: &str) -> std::collections::HashSet<String> {
    let mut syms = std::collections::HashSet::new();
    for name in list_page_files(board).iter().filter(|n| n.ends_with(".kicad_sch")) {
        if let Some(txt) = wiki_text(board, name) {
            for chunk in txt.split("(lib_id \"").skip(1) {
                if let Some(id) = chunk.split('"').next() {
                    let n = id.rsplit(':').next().unwrap_or(id);
                    if !n.is_empty() {
                        syms.insert(norm_val(n));
                    }
                }
            }
        }
    }
    syms
}

/// The actual work, converts + merges + delivers, updating PROGRESS at each step.
fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: String, project_only: bool) {
    let _ = std::fs::create_dir_all(SCRATCH);
    use std::collections::BTreeMap;
    let mut by_symlib: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let mut by_fplib: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (slug, sym, fp) in &items {
        by_symlib.entry(sym.clone()).or_default().push(slug.clone());
        by_fplib.entry(fp.clone()).or_default().push(slug.clone());
    }
    // resolve each library NAME to its real on-disk path from the user's lib tables;
    // a name not in their tables (a brand-new library) falls back to the KiCad dir.
    let libs = user_libraries();
    let path_map = |key: &str| -> std::collections::HashMap<String, String> {
        libs.get(key).and_then(|a| a.as_array()).map(|a| {
            a.iter().filter_map(|l| Some((l.get("name")?.as_str()?.to_string(), l.get("uri")?.as_str()?.to_string()))).collect()
        }).unwrap_or_default()
    };
    let sym_paths = path_map("symLibs");
    let fp_paths = path_map("fpLibs");
    // for "integrate into my libraries": index every symbol/footprint already in
    // the user's libraries so an existing one is skipped instead of duplicated.
    // (Project-only mode builds a fresh isolated library, nothing to dedup.)
    // which components are ALREADY in the user's libraries: match by MPN (which
    // some library symbols embed), else by value + package. Skip those entirely
    // (both symbol and footprint) so nothing gets duplicated.
    // No blanket "already in a library" skip: the target library is pre-filled in
    // the UI with wherever the part already lives, so merge_symbols (per-target
    // dedup) keeps it out when unchanged and adds it when the user picks a
    // different library. Changing the dropdown → a new symbol/footprint there.
    let skipped: Vec<Value> = vec![];
    let total = items.len();
    let mut done_n = 0usize;
    let mut added: Vec<Value> = vec![];
    let mut errors: Vec<String> = vec![];
    let prog = |stage: String, cur: usize| set_progress(json!({"stage":stage,"current":cur,"total":total,"done":false}));

    for (symlib, slugs) in &by_symlib {
        let mut blocks: Vec<(String, String)> = vec![];
        for slug in slugs {
            done_n += 1;
            prog(format!("Converting {slug} → {symlib}"), done_n);
            match load_part(slug, &up(slug)) {
                Ok((part, _)) => {
                    if let Some(b) = extract_symbol_block(&export_kicad_symbol(&part.symbol)) {
                        blocks.push(b);
                    }
                }
                Err(e) => errors.push(format!("{slug}: {e}")),
            }
        }
        prog(format!("Merging {} symbol(s) into {symlib}", blocks.len()), done_n);
        let remote = sym_paths.get(symlib).cloned().unwrap_or_else(|| format!("{}{}{symlib}.kicad_sym", kicad_dir("Symbols"), sep()));
        let existing = ad_pull(&remote).map(|b| String::from_utf8_lossy(&b).to_string());
        // NEVER overwrite a library we failed to read. ad_pull returns None for
        // both "absent" and "pull failed", only proceed with a fresh library
        // when the desktop confirms the file genuinely doesn't exist.
        if existing.is_none() {
            match ad_file_exists(&remote) {
                Some(false) => {} // genuinely absent, start a fresh library
                Some(true) => {
                    errors.push(format!(
                        "{symlib}.kicad_sym exists on the desktop but could not be pulled, skipped to avoid overwriting it"
                    ));
                    continue;
                }
                None => {
                    errors.push(format!(
                        "could not verify {symlib}.kicad_sym on the desktop (bridge error), skipped to avoid overwriting it"
                    ));
                    continue;
                }
            }
        }
        let merged = merge_symbols(existing.clone(), &blocks);
        // Never-shrink guard: the merge only appends, so the result must hold at
        // least as many symbols as the library already had.
        let n_syms = |s: &str| s.matches("(symbol \"").count();
        if let Some(ex) = &existing {
            if n_syms(&merged) < n_syms(ex) {
                errors.push(format!("{symlib}.kicad_sym merge would shrink the library, aborted"));
                continue;
            }
        }
        // write back to the library's real file (send its basename to its dir)
        let base = remote.rsplit(['/', '\\']).next().unwrap_or("lib.kicad_sym").to_string();
        let local = format!("{SCRATCH}/{base}");
        if let Err(e) = std::fs::write(&local, &merged) {
            errors.push(format!("failed to stage {symlib}.kicad_sym locally: {e}"));
            continue;
        }
        if ad_send(&[local], &parent_dir(&remote)) {
            for (name, _) in &blocks {
                added.push(json!({"symbol": name, "symLib": symlib}));
            }
        } else {
            errors.push(format!("failed to write {symlib}.kicad_sym"));
        }
    }

    for (fplib, slugs) in &by_fplib {
        prog(format!("Delivering footprints → {fplib}.pretty"), total);
        let dest = fp_paths.get(fplib).cloned().unwrap_or_else(|| format!("{}{}{fplib}.pretty", kicad_dir("Footprints"), sep()));
        ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));
        let mut files = vec![];
        for slug in slugs {
            if let Ok((part, _)) = load_part(slug, &up(slug)) {
                let name = if part.footprint.name.is_empty() { up(slug) } else { part.footprint.name.clone() };
                let local = format!("{SCRATCH}/{}.kicad_mod", name.replace(['/', ' '], "_"));
                match std::fs::write(&local, export_kicad_footprint(&part.footprint)) {
                    Ok(()) => files.push(local),
                    Err(e) => errors.push(format!("{slug}: failed to stage footprint locally: {e}")),
                }
            }
        }
        if !files.is_empty() && !ad_send(&files, &dest) {
            errors.push(format!("failed to deliver footprints to {fplib}.pretty"));
        }
    }

    let project = match &board {
        Some(b) => {
            prog(if project_only { "Building self-contained project (symbols, footprints, lib-tables)…".into() } else { "Fetching KiCad project files (schematic, PCB, sheets)…".to_string() }, total);
            let (folder, files) = fetch_project_files(b, &dest, project_only);
            json!({"folder": folder, "files": files})
        }
        None => json!(null),
    };

    let result = json!({"status":"ok","count":added.len(),"added":added,"skipped":skipped,"errors":errors,"project":project});
    set_progress(json!({"stage":"Done","current":total,"total":total,"done":true,"result":result}));
}

// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------

fn hdr(ct: &str) -> Header {
    Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()
}
fn html(s: &str) -> Response<Cursor<Vec<u8>>> {
    Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))
}
fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> {
    Response::from_string(s).with_header(hdr("application/json"))
}

// ── embedded-tool launch + reverse proxy ──────────────────────────────────────
// Nucleus is the single entry point; each tool is a Node app it launches on demand
// and proxies under /tools/<slug>/ so it renders in an iframe on Nucleus's origin.
fn tool_spec(slug: &str) -> Option<(u16, &'static str)> {
    match slug {
        "klm" => Some((7823, "kicad-library-manager")),
        "cfl" => Some((7821, "chip-fetcher-lite")),
        _ => None,
    }
}
// Prefer the dep-installed copy (~/.claude/skills/<name>), fall back to the dev source.
fn tool_dir(name: &str) -> Option<std::path::PathBuf> {
    let home = std::env::var("HOME").ok()?;
    let installed = std::path::PathBuf::from(format!("{home}/.claude/skills/{name}"));
    if installed.join("server.js").exists() { return Some(installed); }
    let src = std::path::PathBuf::from(format!("/home/adom/project/{name}"));
    if src.join("server.js").exists() { return Some(src); }
    None
}
fn port_open(port: u16) -> bool {
    std::net::TcpStream::connect_timeout(&std::net::SocketAddr::from(([127, 0, 0, 1], port)), std::time::Duration::from_millis(300)).is_ok()
}
// Ensure the tool's Node server is up (spawn `node server.js` with PORT set if not).
fn ensure_tool(slug: &str) -> Option<u16> {
    let (port, name) = tool_spec(slug)?;
    if port_open(port) { return Some(port); }
    let dir = tool_dir(name)?;
    let _ = Command::new("node").arg(dir.join("server.js"))
        .env("PORT", port.to_string()).current_dir(&dir)
        .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
        .spawn();
    for _ in 0..30 { if port_open(port) { return Some(port); } std::thread::sleep(std::time::Duration::from_millis(200)); }
    None
}
// Minimal HTTP/1.0 reverse proxy to the tool's localhost port. Handles binary
// bodies (GLB models); Connection:close means read-to-EOF gets the whole response.
fn proxy_tool(slug: &str, rest: &str, query: &str, method: &Method, body: &str) -> Response<Cursor<Vec<u8>>> {
    use std::io::Write;
    let port = match tool_spec(slug) { Some((p, _)) => p, None => return Response::from_string("{\"error\":\"unknown tool\"}").with_status_code(404).with_header(hdr("application/json")) };
    let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
    let mut stream = match std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(600)) {
        Ok(s) => s,
        Err(_) => return Response::from_string("{\"error\":\"tool not running\"}").with_status_code(502).with_header(hdr("application/json")),
    };
    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(60)));
    let path = if query.is_empty() { format!("/{rest}") } else { format!("/{rest}?{query}") };
    let m = match method { Method::Post => "POST", Method::Put => "PUT", Method::Delete => "DELETE", _ => "GET" };
    let req = format!("{m} {path} HTTP/1.0\r\nHost: localhost\r\nAccept: */*\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
    if stream.write_all(req.as_bytes()).is_err() { return Response::from_string("{\"error\":\"proxy write failed\"}").with_status_code(502).with_header(hdr("application/json")); }
    let mut buf = Vec::new();
    let _ = stream.read_to_end(&mut buf);
    let sep = buf.windows(4).position(|w| w == b"\r\n\r\n");
    let (head, bodyb): (&[u8], &[u8]) = match sep { Some(i) => (&buf[..i], &buf[i + 4..]), None => (&buf[..], &[][..]) };
    let head_str = String::from_utf8_lossy(head);
    let code = head_str.lines().next().and_then(|l| l.split_whitespace().nth(1)).and_then(|c| c.parse::<u16>().ok()).unwrap_or(200);
    let ct = head_str.lines().find(|l| l.to_ascii_lowercase().starts_with("content-type:"))
        .and_then(|l| l.split_once(':')).map(|(_, v)| v.trim().to_string())
        .unwrap_or_else(|| "application/octet-stream".into());
    Response::from_data(bodyb.to_vec()).with_status_code(code).with_header(hdr(&ct))
}

const INDEX_HTML: &str = include_str!("manage.html");
const LOADER_GIF: &[u8] = include_bytes!("loader.gif");