123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929
//! `adom-lbr manage` — the adom-lbr Manager. A small web app (tiny_http →
//! Hydrogen webview) that lists wiki components, and on click opens a viewer:
//! symbol + footprint + 3D, plus the footprint LAYER STACKUP showing what layer
//! each graphic becomes in KiCad and Altium.
//!
//! 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};

const WIKI: &str = "https://wiki.adom.inc";
// TODO: detect the signed-in wiki user rather than hard-coding this owner.
const MY_OWNER: &str = "barrett-land";

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

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-lbr 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.");
    for mut req in server.incoming_requests() {
        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, "/") => html(INDEX_HTML),
        (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("/api/board/") => {
            let slug = &p["/api/board/".len()..];
            json_resp(&board_deps(slug).to_string())
        }
        (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)
// ---------------------------------------------------------------------------

fn curl(url: &str) -> Option<Vec<u8>> {
    let out = Command::new("curl")
        .args(["-s", "-L", "--max-time", "12", url])
        .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}"))
}

/// 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();
    if let Some(body) = curl(&format!("{WIKI}/api/v1/pages?type=component&limit=300")) {
        if let Ok(v) = serde_json::from_slice::<Value>(&body) {
            if let Some(pages) = v.get("pages").and_then(|p| p.as_array()) {
                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();
                    items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner}));
                }
            }
        }
    }
    if items.is_empty() {
        for s in ["cl21a476mqynnne", "cq03saf4702t5e", "lqm18pnr47mfhd", "machinepinlargestandard", "machinecontactmedium"] {
            items.push(json!({"slug": s, "title": up(s), "brief": ""}));
        }
    }
    json!({ "status": "ok", "components": items, "me": MY_OWNER,
        "hints": ["click a component to open its viewer"] })
}

/// Fetch a component and build its adom-lbr part FRESH on every call (nothing is
/// 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.
fn board_deps(slug: &str) -> Value {
    let bare = |k: &str| k.rsplit('/').next().unwrap_or(k).trim().to_string();
    let mut deps: Vec<String> = vec![];
    if let Some(txt) = wiki_text(slug, "package.json") {
        if let Ok(v) = serde_json::from_str::<Value>(&txt) {
            deps = 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![],
            };
        }
    }
    let items: Vec<Value> = deps.into_iter().map(|s| json!({"slug": s})).collect();
    json!({ "status": "ok", "board": slug, "deps": items,
        "hints": ["deps come from the board's package.json dependencies (same as the wiki dependency graph)"] })
}

/// 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 KICAD_SYM_DIR: &str = "/home/barrett-land/Documents/KiCAD/Symbols";
const KICAD_FP_DIR: &str = "/home/barrett-land/Documents/KiCAD/Footprints";
const KICAD_PROJ_DIR: &str = "/home/barrett-land/Documents/KiCAD/Projects";
const SCRATCH: &str = "/tmp/adom-lbr-apply";

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

/// 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!("{KICAD_PROJ_DIR}/{board}") } 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 mut names = vec![];
    for f in &files {
        let name = f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).unwrap_or("");
        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();
                if std::fs::write(format!("{work}/{base}"), bytes).is_ok() {
                    names.push(base);
                }
            }
        }
    }
    if names.is_empty() {
        return (dest, got);
    }
    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");
                    ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));
                    ad_send(&pretty, &pdir);
                }
                got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();
                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);
    (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)
}

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.
fn ad_shell_out(cmd: &str) -> Option<String> {
    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())
}

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

/// 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());
    }
    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 = format!("{KICAD_SYM_DIR}/{symlib}.kicad_sym");
        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;
            }
        }
        let local = format!("{SCRATCH}/{symlib}.kicad_sym");
        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], KICAD_SYM_DIR) {
            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 = format!("{KICAD_FP_DIR}/{fplib}.pretty");
        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,"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");