12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939
//! Adom Project Manager — 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);
    // The board GLB from service-kicad is a raw kicad-cli export: glTF-standard
    // Y-up and carrying no up-axis stamp (issue #218). Optimize it (quantize +
    // the +90°X Z-up rotation) and stamp `adomMolecule.upAxis = "z"`, so BOTH the
    // wiki 3D viewer (assumes Z-up) and APM (keys on the stamp) render it flat —
    // no hand-patching, no consumer inferring orientation from geometry.
    let glb = glb.map(|g| finalize_board_glb(&g));
    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());
    // Read the existing manifest as our MERGE BASE (issue #218 problem 2). We only
    // overwrite the keys the pipeline owns and preserve everything else — top-level
    // keys a downstream tool added (`viewers`, `viewers_path`, …) and unknown keys
    // on each versions[] entry. A wholesale rewrite silently destroyed those; a
    // 404/error body that isn't really a manifest falls back to an empty base.
    let existing: serde_json::Map<String, Value> = wiki_bytes(slug, "render/manifest.json")
        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
        .and_then(|m| m.as_object().cloned())
        .filter(|m| m.contains_key("v") || m.contains_key("glb") || m.contains_key("versions"))
        .unwrap_or_default();
    // Did the render change vs the last archived version? (drives version archival)
    let latest_hash = existing.get("versions").and_then(|a| a.as_array())
        .and_then(|a| a.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>/
        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); }
    }
    let manifest = merge_render_manifest(&existing, &version, &new_hash, changed,
        glb.is_some(), pcb_svg.is_some(), &sheet_names);
    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 Project Manager 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()),
        (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, "/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>]
            //
            // The slug itself CONTAINS a '/' when it is owner-qualified (`adom/usb3-…`,
            // which is every org board), so parse the view selector off the END. Splitting
            // from the front took `adom` as the whole slug and 404'd both panes.
            let (path, ver) = split_ver(url);
            let rest = &path["/board-svg/".len()..];
            let (slug, kind, sch_idx): (&str, &str, usize) = if let Some(s) = rest.strip_suffix("/pcb") {
                (s, "pcb", 0)
            } else if let Some((head, i)) = rest.rsplit_once('/') {
                match (head.strip_suffix("/sch"), i.parse::<usize>().ok()) {
                    (Some(s), Some(n)) => (s, "sch", n),
                    _ => ("", "", 0),
                }
            } else {
                ("", "", 0)
            };
            let sub = |dir: &str| match kind {
                "pcb" => Some(format!("{dir}pcb.svg")),
                "sch" => Some(format!("{dir}sheet-{sch_idx: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 kind {
                    "pcb" => pcb,
                    "sch" => sheets.get(sch_idx).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),
            }
        }
        (Method::Get, p) if p.starts_with("/board-viewer/") => {
            // forms: /board-viewer/<slug>/<name>  [?v=<version>]
            // Serves the self-contained INTERACTIVE viewer HTML (adom-layout-viewer /
            // adom-schematic-viewer `embed` output): real net highlighting, layer
            // toggles incl. inner copper, component hover. These are archived per board
            // version under viewers/<version>/, mirroring render/<version>/.
            //
            // With ?v= we deliberately do NOT fall back to the current viewer: a board
            // version that predates the viewer archive must show its own static SVG
            // rather than silently render a DIFFERENT revision of the board.
            let (path, ver) = split_ver(url);
            let rest = &path["/board-viewer/".len()..];
            let (slug, name) = match rest.rsplit_once('/') { Some(x) => x, None => ("", "") };
            let safe = !name.is_empty()
                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
            let html = if !safe || slug.is_empty() {
                None
            } else if let Some(v) = ver.as_ref() {
                wiki_bytes(slug, &format!("viewers/{v}/{name}.html"))
            } else {
                wiki_bytes(slug, &format!("viewers/{name}.html"))
            };
            match html {
                Some(b) => Response::from_data(b).with_header(hdr("text/html; charset=utf-8")),
                None => Response::from_string("no viewer").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}"))
}

/// 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())
}

/// Build the render manifest MERGE-SAFELY (issue #218 problem 2). Starts from the
/// existing manifest so keys the pipeline does not own survive — unknown top-level
/// keys (`viewers`, `viewers_path`, …) a downstream tool added, and unknown keys on
/// each versions[] entry — overwriting only `v`/`current`/`glb`/`pcb`/`sheets`/
/// `versions[].{version,hash,glb,pcb,sheets}`. When `changed`, the current version's
/// entry is MERGED onto any prior entry for that version (so its extra keys survive)
/// and re-prepended, marking it the newest.
fn merge_render_manifest(
    existing: &serde_json::Map<String, Value>,
    version: &str, new_hash: &str, changed: bool,
    has_glb: bool, has_pcb: bool, sheet_names: &[String],
) -> Value {
    let mut versions: Vec<Value> = existing.get("versions")
        .and_then(|a| a.as_array()).cloned().unwrap_or_default();
    if changed {
        let mut entry = versions.iter()
            .find(|v| v.get("version").and_then(|x| x.as_str()) == Some(version))
            .and_then(|v| v.as_object().cloned())
            .unwrap_or_default();
        entry.insert("version".into(), json!(version));
        entry.insert("hash".into(), json!(new_hash));
        entry.insert("glb".into(), json!(has_glb));
        entry.insert("pcb".into(), json!(has_pcb));
        entry.insert("sheets".into(), json!(sheet_names));
        versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version));
        versions.insert(0, Value::Object(entry));
    }
    let current = versions.first()
        .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
        .unwrap_or_else(|| version.to_string());
    let mut manifest = existing.clone();
    manifest.insert("v".into(), json!(2));
    manifest.insert("current".into(), json!(current));
    manifest.insert("glb".into(), json!(has_glb));
    manifest.insert("pcb".into(), json!(has_pcb));
    manifest.insert("sheets".into(), json!(sheet_names));
    manifest.insert("versions".into(), json!(versions));
    Value::Object(manifest)
}

/// Make a raw kicad-cli board GLB safe for every consumer (issue #218 problem 1):
/// optimize it if the gltf-transform helper is installed (quantize + bake-friendly
/// merges, and its own +90°X Z-up rotation), then guarantee it is Z-up AND carries
/// `asset.extras.adomMolecule.upAxis = "z"`. Never fails — falls back to the input
/// bytes so a missing `node`/optimizer only costs size, never correctness.
fn finalize_board_glb(glb: &[u8]) -> Vec<u8> {
    let optimized = optimize_glb(glb).unwrap_or_else(|| glb.to_vec());
    ensure_zup_stamp(&optimized)
}

/// Locate the installed `glb_optimize.mjs` (shipped beside the binary by install.sh,
/// or in ~/.local/bin) and run it: `node glb_optimize.mjs <in> <out>`. Returns the
/// optimized bytes, or None if node/script/deps are unavailable or it fails.
fn optimize_glb(glb: &[u8]) -> Option<Vec<u8>> {
    let script = std::env::current_exe().ok()
        .and_then(|p| p.parent().map(|d| d.join("glb_optimize.mjs")))
        .filter(|p| p.exists())
        .or_else(|| {
            let home = std::env::var("HOME").ok()?;
            let p = std::path::PathBuf::from(home).join(".local/bin/glb_optimize.mjs");
            p.exists().then_some(p)
        })?;
    let _ = std::fs::create_dir_all(SCRATCH);
    let id = unique_id();
    let inp = format!("{SCRATCH}/opt-{id}.in.glb");
    let outp = format!("{SCRATCH}/opt-{id}.out.glb");
    std::fs::write(&inp, glb).ok()?;
    let ok = Command::new("node").arg(&script).arg(&inp).arg(&outp)
        .status().map(|s| s.success()).unwrap_or(false);
    let out = if ok { std::fs::read(&outp).ok() } else { None };
    let _ = std::fs::remove_file(&inp);
    let _ = std::fs::remove_file(&outp);
    out.filter(|b| b.len() > 20 && &b[0..4] == b"glTF")
}

/// Guarantee a binary GLB is Z-up and stamped `adomMolecule.upAxis = "z"`.
/// Idempotent: if the stamp is already present the bytes are returned unchanged. If
/// the content is rotated (an `adom_zup` node from the optimizer) but unstamped, only
/// the stamp is added. Otherwise every scene root is reparented under a new node
/// rotated +90° about X (maps +Y → +Z) and the stamp is written. On any parse
/// failure the input is returned untouched. Only the JSON chunk is rewritten; the
/// BIN chunk (and any trailing chunks) are preserved verbatim.
fn ensure_zup_stamp(glb: &[u8]) -> Vec<u8> {
    let orig = glb.to_vec();
    if glb.len() < 20 || &glb[0..4] != b"glTF" {
        return orig;
    }
    let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
    let json_start = 20usize;
    let json_end = match json_start.checked_add(json_len) {
        Some(e) if e <= glb.len() => e,
        _ => return orig,
    };
    // chunk 0 type must be JSON (0x4E4F534A)
    if u32::from_le_bytes([glb[16], glb[17], glb[18], glb[19]]) != 0x4E4F_534A {
        return orig;
    }
    let mut j: Value = match serde_json::from_slice(&glb[json_start..json_end]) {
        Ok(v) => v,
        Err(_) => return orig,
    };
    let already_stamped = j.get("asset").and_then(|a| a.get("extras"))
        .and_then(|e| e.get("adomMolecule")).and_then(|m| m.get("upAxis"))
        .and_then(|u| u.as_str()).map(|s| s.eq_ignore_ascii_case("z")).unwrap_or(false);
    if already_stamped {
        return orig;
    }
    let obj = match j.as_object_mut() {
        Some(o) => o,
        None => return orig,
    };
    // Rotate to Z-up unless already rotated (optimizer left an `adom_zup` node).
    let has_zup = obj.get("nodes").and_then(|n| n.as_array())
        .map(|a| a.iter().any(|n| n.get("name").and_then(|s| s.as_str()) == Some("adom_zup")))
        .unwrap_or(false);
    if !has_zup {
        // Plan the reparenting from an immutable read (one +90°X wrapper per scene),
        // then apply it — never holding two mutable sub-borrows of `obj` at once.
        let scenes_roots = j_scenes_roots(obj);
        let mut nodes_len = obj.get("nodes").and_then(|n| n.as_array()).map(|a| a.len()).unwrap_or(0);
        let mut new_nodes: Vec<Value> = Vec::new();
        let mut scene_updates: Vec<(usize, usize)> = Vec::new();
        for (scene_idx, roots) in scenes_roots {
            if roots.is_empty() { continue; }
            let new_idx = nodes_len;
            new_nodes.push(json!({
                "name": "adom_zup",
                "rotation": [0.707_106_78_f64, 0.0, 0.0, 0.707_106_78_f64],
                "children": roots,
            }));
            scene_updates.push((scene_idx, new_idx));
            nodes_len += 1;
        }
        if !new_nodes.is_empty() {
            if let Some(nodes) = obj.entry("nodes").or_insert_with(|| json!([])).as_array_mut() {
                nodes.extend(new_nodes);
            }
            if let Some(scenes) = obj.get_mut("scenes").and_then(|s| s.as_array_mut()) {
                for (si, ni) in scene_updates {
                    if let Some(sc) = scenes.get_mut(si).and_then(|s| s.as_object_mut()) {
                        sc.insert("nodes".into(), json!([ni]));
                    }
                }
            }
        }
    }
    // stamp asset.extras.adomMolecule.upAxis = "z"
    let asset = obj.entry("asset").or_insert_with(|| json!({}));
    let extras = asset.as_object_mut().unwrap().entry("extras").or_insert_with(|| json!({}));
    let am = extras.as_object_mut().unwrap().entry("adomMolecule").or_insert_with(|| json!({}));
    if let Some(m) = am.as_object_mut() { m.insert("upAxis".into(), json!("z")); }

    rebuild_glb(glb, &j).unwrap_or(orig)
}

/// (scene_index, root node-index list) for every scene, read before mutation.
fn j_scenes_roots(obj: &serde_json::Map<String, Value>) -> Vec<(usize, Vec<Value>)> {
    obj.get("scenes").and_then(|s| s.as_array()).map(|scenes| {
        scenes.iter().enumerate().map(|(i, sc)| {
            let roots = sc.get("nodes").and_then(|n| n.as_array()).cloned().unwrap_or_default();
            (i, roots)
        }).collect()
    }).unwrap_or_default()
}

/// Re-emit a binary GLB with a replaced JSON chunk, preserving all following chunks
/// (BIN etc.) verbatim. JSON is padded with spaces, binary chunks with zeros, each to
/// a 4-byte boundary — per the glTF binary spec.
fn rebuild_glb(orig: &[u8], json: &Value) -> Option<Vec<u8>> {
    let old_json_len = u32::from_le_bytes([orig[12], orig[13], orig[14], orig[15]]) as usize;
    let after_json = 20usize.checked_add(old_json_len)?;
    let trailing = orig.get(after_json..).unwrap_or(&[]);
    let mut js = serde_json::to_vec(json).ok()?;
    while js.len() % 4 != 0 { js.push(b' '); }
    let total = 12 + 8 + js.len() + trailing.len();
    let mut out = Vec::with_capacity(total);
    out.extend_from_slice(b"glTF");
    out.extend_from_slice(&2u32.to_le_bytes());
    out.extend_from_slice(&(total as u32).to_le_bytes());
    out.extend_from_slice(&(js.len() as u32).to_le_bytes());
    out.extend_from_slice(&0x4E4F_534Au32.to_le_bytes()); // "JSON"
    out.extend_from_slice(&js);
    out.extend_from_slice(trailing);
    Some(out)
}

/// 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"))
        });
        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!("apm-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=\"apm-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).
fn board_deps(slug: &str) -> Value {
    let mut components: Vec<String> = vec![];
    let mut seen = std::collections::HashSet::new();
    for d in deps_of(slug) {
        let sub = deps_of(&d);
        if sub.is_empty() {
            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();
    // 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, "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()?;
    kicad_export(slug, "pcb", "glb-package", "?F=--include-silkscreen&F=--include-pads", &bytes)
}

/// 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 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!([])),
            // interactive viewer exports available for the CURRENT version (e.g.
            // ["layout","sch-usb",…]). Per-version availability rides on versions[].viewers.
            "viewers": m.get("viewers").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"))
}

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

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

    /// Assemble a minimal binary GLB from a glTF JSON value + BIN payload.
    fn build_glb(json: &Value, bin: &[u8]) -> Vec<u8> {
        let mut js = serde_json::to_vec(json).unwrap();
        while js.len() % 4 != 0 { js.push(b' '); }
        let mut bn = bin.to_vec();
        while bn.len() % 4 != 0 { bn.push(0); }
        let total = 12 + 8 + js.len() + 8 + bn.len();
        let mut out = Vec::new();
        out.extend_from_slice(b"glTF");
        out.extend_from_slice(&2u32.to_le_bytes());
        out.extend_from_slice(&(total as u32).to_le_bytes());
        out.extend_from_slice(&(js.len() as u32).to_le_bytes());
        out.extend_from_slice(&0x4E4F_534Au32.to_le_bytes());
        out.extend_from_slice(&js);
        out.extend_from_slice(&(bn.len() as u32).to_le_bytes());
        out.extend_from_slice(&0x004E_4942u32.to_le_bytes());
        out.extend_from_slice(&bn);
        out
    }

    fn json_of(glb: &[u8]) -> Value {
        let jlen = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
        serde_json::from_slice(&glb[20..20 + jlen]).unwrap()
    }

    // A Y-up scene: one root node (index 0) bearing a mesh.
    fn yup_glb(bin: &[u8]) -> Vec<u8> {
        let j = json!({
            "asset": {"version": "2.0"},
            "scenes": [{"nodes": [0]}],
            "scene": 0,
            "nodes": [{"mesh": 0, "name": "board"}],
            "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
        });
        build_glb(&j, bin)
    }

    #[test]
    fn stamps_and_rotates_raw_yup() {
        let bin = b"BINARYDATA-1234567890";
        let out = ensure_zup_stamp(&yup_glb(bin));
        // stamp present + readable by the same accessor APM uses
        assert_eq!(glb_up_axis(&out).as_deref(), Some("z"));
        let j = json_of(&out);
        let nodes = j["nodes"].as_array().unwrap();
        // exactly one adom_zup wrapper, rotating +90° about X, wrapping the old root
        let zup: Vec<_> = nodes.iter().filter(|n| n["name"] == "adom_zup").collect();
        assert_eq!(zup.len(), 1, "one wrapper node");
        let rot = zup[0]["rotation"].as_array().unwrap();
        assert!((rot[0].as_f64().unwrap() - 0.707_106_78).abs() < 1e-6);
        assert!((rot[3].as_f64().unwrap() - 0.707_106_78).abs() < 1e-6);
        assert_eq!(zup[0]["children"], json!([0]));
        // the scene now points at the wrapper, not the bare root
        let widx = nodes.iter().position(|n| n["name"] == "adom_zup").unwrap();
        assert_eq!(j["scenes"][0]["nodes"], json!([widx]));
        // BIN chunk survives verbatim
        assert!(out.windows(bin.len()).any(|w| w == bin), "bin preserved");
    }

    #[test]
    fn is_idempotent() {
        let once = ensure_zup_stamp(&yup_glb(b"payload-xyz"));
        let twice = ensure_zup_stamp(&once);
        assert_eq!(once, twice, "already-stamped input returned byte-identical");
    }

    #[test]
    fn rotated_but_unstamped_only_gets_stamp() {
        // Mimics the optimizer having rotated (adom_zup) but not stamped.
        let j = json!({
            "asset": {"version": "2.0"},
            "scenes": [{"nodes": [1]}],
            "scene": 0,
            "nodes": [
                {"mesh": 0, "name": "board"},
                {"name": "adom_zup", "rotation": [0.70710678, 0.0, 0.0, 0.70710678], "children": [0]}
            ],
            "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
        });
        let out = ensure_zup_stamp(&build_glb(&j, b"bin"));
        assert_eq!(glb_up_axis(&out).as_deref(), Some("z"));
        let j2 = json_of(&out);
        let zup = j2["nodes"].as_array().unwrap().iter().filter(|n| n["name"] == "adom_zup").count();
        assert_eq!(zup, 1, "no second rotation node added");
    }

    #[test]
    fn non_glb_passes_through() {
        let junk = b"not a glb at all".to_vec();
        assert_eq!(ensure_zup_stamp(&junk), junk);
    }

    fn obj(v: Value) -> serde_json::Map<String, Value> { v.as_object().unwrap().clone() }

    #[test]
    fn manifest_preserves_unknown_toplevel_keys() {
        // The exact regression from #218: a re-render must not drop `viewers`.
        let existing = obj(json!({
            "v": 2, "current": "1.3.0", "glb": true, "pcb": true, "sheets": ["Root"],
            "viewers": ["layout", "sch-usb"],
            "viewers_path": "viewers/{version}/",
            "versions": [{"version": "1.3.0", "hash": "aaa", "glb": true, "pcb": true, "sheets": ["Root"], "viewers": ["layout"]}],
        }));
        // an unchanged re-render (same hash) still must not clobber unknown keys
        let m = merge_render_manifest(&existing, "1.3.0", "aaa", false, true, true, &["Root".into()]);
        assert_eq!(m["viewers"], json!(["layout", "sch-usb"]));
        assert_eq!(m["viewers_path"], json!("viewers/{version}/"));
        assert_eq!(m["versions"][0]["viewers"], json!(["layout"]), "per-version key survives");
    }

    #[test]
    fn manifest_new_version_merges_not_replaces() {
        // A changed render for a version string that already carries extra keys must
        // update owned keys yet keep the unknown ones on that entry.
        let existing = obj(json!({
            "v": 2, "current": "1.3.0", "glb": true, "pcb": true, "sheets": ["Root"],
            "viewers": ["layout"],
            "versions": [{"version": "1.4.0", "hash": "old", "glb": true, "pcb": true, "sheets": ["Root"], "viewers": ["layout", "sch"]}],
        }));
        let m = merge_render_manifest(&existing, "1.4.0", "new", true, true, false, &["Root".into(), "Pwr".into()]);
        let v0 = &m["versions"][0];
        assert_eq!(v0["version"], json!("1.4.0"));
        assert_eq!(v0["hash"], json!("new"), "owned key updated");
        assert_eq!(v0["pcb"], json!(false), "owned key updated");
        assert_eq!(v0["sheets"], json!(["Root", "Pwr"]));
        assert_eq!(v0["viewers"], json!(["layout", "sch"]), "unknown per-version key preserved");
        assert_eq!(m["current"], json!("1.4.0"));
        assert_eq!(m["viewers"], json!(["layout"]), "top-level unknown key preserved");
        // exactly one entry for 1.4.0 (merged, not duplicated)
        let count = m["versions"].as_array().unwrap().iter()
            .filter(|v| v["version"] == json!("1.4.0")).count();
        assert_eq!(count, 1);
    }

    #[test]
    fn manifest_first_archive_from_empty() {
        let m = merge_render_manifest(&serde_json::Map::new(), "1.0.0", "h0", true, true, true, &["Root".into()]);
        assert_eq!(m["v"], json!(2));
        assert_eq!(m["current"], json!("1.0.0"));
        assert_eq!(m["versions"][0]["hash"], json!("h0"));
    }
}