12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130
//! adom-symbol live app server — the AI-drivable surface (app-creator §7/§7b/§8).
//! tiny_http server: GET / (UI), GET /state (poller), POST /load (push a symbol),
//! POST /eval + GET /eval/pending + POST /eval/:id/result + GET /eval/:id (eval
//! channel), POST/GET /console (console forwarding), POST /shutdown, GET
//! /favicon.svg (monochrome). No gallia, no Node.

use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::io::Read;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};

#[derive(Default, Clone)]
pub struct AppState {
    pub symbol_name: String,
    pub svg: String,
    pub manufacturer: String,
    pub package: String,
    pub description: String,
    pub datasheet: String,
    pub pin_count: usize,
    pub pins: Vec<crate::parse::Pin>,
    pub groups: Vec<crate::parse::Group>,
    pub offset: Option<(f64, f64)>,
    pub hide_pin_names: bool,
    pub body_rect: Option<crate::parse::BodyRect>,
    // ── studio: source + layout options ──
    pub sources: Vec<String>, // available, e.g. ["auto","manufacturer","ds2sf"]
    pub source: String,       // current
    pub pin_layout: String,   // "left-right" | "all-sides"
    pub grouped: bool,
    pub ref_pos: String,
    pub value_pos: String,    // chip-name placement (same set as ref_pos; "none" = hidden)
    pub laser_name: bool,     // "laser" the chip name onto the 3D chip's top surface
    pub three_d: String,      // "off" | "iso" | "thin" | "bold" | "blueprint" | "teal" | "dark"
    pub three_d_assets: std::collections::HashMap<String, String>, // mode → data: URL
    pub source_labels: std::collections::HashMap<String, String>,  // method → display label
    pub kicad_sym: String,    // raw .kicad_sym of the current symbol (for Send-to delivery)
    pub saved_layout: bool,   // this chip has per-symbol prefs saved (sticky must NOT override)
    pub chip_size: String,    // 3D-chip size: large|medium|small (per-symbol when saved)
    // Effective Reference/Value designator positions (KiCad symbol-local mm, Y-up)
    // as actually rendered — the app uses these to place the draggable field handles.
    pub ref_field: Option<(f64, f64)>,
    pub value_field: Option<(f64, f64)>,
    // The exact rendered text of each designator (e.g. "U?", "AD7124-8") so the
    // app can match the precise on-screen <text> for the drag box (not a pin name).
    pub ref_text: String,
    pub value_text: String,
}

/// Map a source slug to a friendly label. Delegates to the SHARED provenance
/// rule (see the chip-provenance-labeling skill): label by where it was actually
/// downloaded, name the maker site, never call an aggregator "Manufacturer".
/// Label a source for the picker. `discovery_label` is the human name of where
/// the whole library bundle was downloaded (e.g. "Component Search Engine") —
/// the format variants (altium/fusion) came from that SAME bundle, so they're
/// named "<discovery> - <Format> Library", matching chip-fetcher's carat.
fn source_label(slug: &str, manufacturer: &str, discovery_label: &str) -> String {
    match slug {
        "auto" => "Auto · grouped".to_string(),
        "ds2sf" => format!("ds2sf {}", crate::provenance::source_label("manufacturer", manufacturer)),
        // The format variants of the SAME downloaded bundle — name the format, not "KiCad".
        "manufacturer" => format!("{discovery_label} - KiCad Library"),
        "altium" => format!("{discovery_label} - Altium Library"),
        "fusion" | "fusion360" => format!("{discovery_label} - Fusion 360 Library"),
        // A KiCad-format variant discovered from a different vendor → name that source.
        other => format!("{} - KiCad Library", crate::provenance::source_label(other, manufacturer)),
    }
}

/// What the studio is currently showing — drives /relayout regeneration.
#[derive(Default, Clone)]
struct Model {
    dir: String,
    mpn: String,
    has_ds2sf: bool,
    has_mfr: bool,
    variants: Vec<String>, // vendor variant methods: <mpn>.<method>.kicad_sym
    dir_for_3d: String,    // chip folder (to load iso PNG + outline SVGs)
    mpn_axis: String,      // chosen_up_axis (for label only)
    source: String,
    pin_layout: String,
    grouped: bool,
    ref_pos: String,
    value_pos: String,
    laser_name: bool,
    three_d: String,       // "off" | "iso" | outline style
    chip_size: String,     // 3D-chip size: large|medium|small
    saved: bool,           // per-symbol prefs were loaded from info.json (sticky must not override)
    // Custom Reference/Value designator positions (KiCad symbol-local mm, Y-up).
    // Some = the user dragged it / auto-placed it → overrides the discrete refPos.
    // Cleared when a discrete refPos/valuePos preset is picked.
    ref_x: Option<f64>,
    ref_y: Option<f64>,
    value_x: Option<f64>,
    value_y: Option<f64>,
}
static MODEL: Mutex<Option<Model>> = Mutex::new(None);
// The chip-fetcher library root (folder of <MPN>/ dirs) — powers the left
// chip-library switcher. Set when a chip is loaded from a library folder.
static LIBRARY_ROOT: Mutex<Option<String>> = Mutex::new(None);

/// Render `kicad_sym` (by its real `render_name`) and parse it into a fully
/// interactive AppState. `display_name` is the label shown (e.g. the MPN);
/// the metadata args override parsed values when non-empty.
pub fn build_symbol_state(
    kicad_sym: &str,
    render_name: &str,
    display_name: &str,
    manufacturer: &str,
    package: &str,
    description: &str,
    datasheet: &str,
) -> Result<AppState> {
    let svg = crate::render::render_symbol_svg(kicad_sym, render_name)?;
    let meta = crate::parse::parse_kicad_sym(kicad_sym, render_name);
    let offset = crate::parse::compute_offset(&svg, &meta.pins);
    let pick = |over: &str, parsed: &str| if over.is_empty() { parsed.to_string() } else { over.to_string() };
    Ok(AppState {
        symbol_name: if display_name.is_empty() { render_name.to_string() } else { display_name.to_string() },
        svg,
        manufacturer: pick(manufacturer, &meta.manufacturer),
        package: pick(package, &meta.footprint),
        description: pick(description, &meta.description),
        datasheet: pick(datasheet, &meta.datasheet),
        pin_count: meta.pins.len(),
        pins: meta.pins,
        groups: meta.groups,
        offset,
        hide_pin_names: meta.hide_pin_names,
        body_rect: meta.body_rect,
        kicad_sym: kicad_sym.to_string(),
        ref_field: crate::render::field_at(kicad_sym, render_name, "Reference"),
        value_field: crate::render::field_at(kicad_sym, render_name, "Value"),
        ref_text: crate::render::field_val(kicad_sym, render_name, "Reference").map(|p| format!("{p}?")).unwrap_or_default(),
        value_text: render_name.to_string(),
        ..Default::default()
    })
}

static STATE: Mutex<Option<AppState>> = Mutex::new(None);
static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new());
static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None);
static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);
static EVAL_ID: AtomicU64 = AtomicU64::new(1);

const APP_HTML: &str = include_str!("app.html");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="4" width="10" height="16" rx="1.5"/><path d="M7 8H3M7 12H3M7 16H3M21 8h-4M21 12h-4M21 16h-4"/></svg>"##;

static REV: AtomicU64 = AtomicU64::new(0);
// True while a chip-thumbnailer regen (3D outlines + iso) is running for the
// loaded chip — surfaced in /state so the app spins the ⟳ Regen button.
static REGEN_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

pub fn set_state(s: AppState) {
    REV.fetch_add(1, Ordering::SeqCst);
    *STATE.lock().unwrap() = Some(s);
}

fn state_json() -> String {
    // Pre-read the raw field overrides (MODEL) BEFORE locking STATE, so we never
    // nest the two locks (avoids any lock-ordering hazard with save/regen paths).
    let (ref_ovr, val_ovr): (Option<[f64; 2]>, Option<[f64; 2]>) = MODEL
        .lock()
        .unwrap()
        .as_ref()
        .map(|m| (
            match (m.ref_x, m.ref_y) { (Some(x), Some(y)) => Some([x, y]), _ => None },
            match (m.value_x, m.value_y) { (Some(x), Some(y)) => Some([x, y]), _ => None },
        ))
        .unwrap_or((None, None));
    match &*STATE.lock().unwrap() {
        // Lightweight: NO svg, NO 3D asset data (those are fetched on demand via
        // /svg and /3d/:mode). `rev` bumps whenever the symbol is regenerated so
        // the client knows when to re-fetch the heavy SVG. Keeps the 1s poll tiny.
        Some(s) => {
            // Hide the internal "<style>-named" keys from the mode list (they're
            // selected implicitly by the laser-name toggle, not user-facing styles).
            let mut modes: Vec<&String> = s.three_d_assets.keys().filter(|k| !k.ends_with("-named")).collect();
            modes.sort();
            // When the chip name is laser-marked on the chip, serve the
            // name-marked variant of the current 3D style (iso emboss render, or
            // the flat-top outline) when one exists.
            let named_key = format!("{}-named", s.three_d);
            let cur_asset = if s.laser_name && s.three_d_assets.contains_key(&named_key) {
                s.three_d_assets.get(&named_key)
            } else {
                s.three_d_assets.get(&s.three_d)
            }
            .cloned()
            .unwrap_or_default();
            json!({
                "rev": REV.load(Ordering::SeqCst),
                "regen": REGEN_RUNNING.load(Ordering::SeqCst),
                "symbolName": s.symbol_name, "manufacturer": s.manufacturer,
                "package": s.package, "description": s.description, "datasheet": s.datasheet,
                "pinCount": s.pin_count, "pins": s.pins, "groups": s.groups,
                "offset": s.offset.map(|(x, y)| json!({ "x": x, "y": y })),
                "hidePinNames": s.hide_pin_names,
                "bodyRect": s.body_rect,
                "sources": s.sources, "source": s.source, "pinLayout": s.pin_layout,
                "grouped": s.grouped, "refPos": s.ref_pos, "valuePos": s.value_pos, "laserName": s.laser_name, "threeD": s.three_d,
                "savedLayout": s.saved_layout, "chipSize": s.chip_size,
                // Effective designator positions (KiCad mm, Y-up) for the draggable handles.
                "refField": s.ref_field.map(|(x, y)| json!({ "x": x, "y": y })),
                "valueField": s.value_field.map(|(x, y)| json!({ "x": x, "y": y })),
                "refText": s.ref_text, "valueText": s.value_text,
                // Raw custom overrides (null = following the discrete preset) — lets
                // the client's Undo restore the exact preset-vs-dragged state.
                "refOverride": ref_ovr,
                "valueOverride": val_ovr,
                "threeDModes": modes,
                // only the CURRENT mode's asset (sync, reliable) — not all 6.
                "threeDAsset": cur_asset,
                "sourceLabels": s.source_labels,
            })
            .to_string()
        }
        None => "{}".to_string(),
    }
}

fn read_file(dir: &str, name: &str) -> Option<String> {
    std::fs::read_to_string(std::path::Path::new(dir).join(name)).ok()
}

/// Load the centered-3D assets for the studio: the shaded iso PNG (at the user's
/// chosen axis) keyed "iso", plus every vector outline `<mpn>-3d-outline-<style>.svg`
/// keyed by its style. Values are data: URLs the viewer overlays in the body centre.
fn load_3d_assets(dir: &str, mpn: &str, axis: &str) -> std::collections::HashMap<String, String> {
    let mut out = std::collections::HashMap::new();
    let p = std::path::Path::new(dir);
    let iso_file = match axis {
        "y" | "yup" => format!("{mpn}-3d-iso-yup.png"),
        "x" | "xup" => format!("{mpn}-3d-iso-xup.png"),
        "xdown" | "-x" => format!("{mpn}-3d-iso-xdown.png"),
        "ydown" | "-y" => format!("{mpn}-3d-iso-ydown.png"),
        "zdown" | "-z" => format!("{mpn}-3d-iso-zdown.png"),
        _ => format!("{mpn}-3d-iso.png"),
    };
    // Prefer the transparent-background iso icon; fall back to the shaded ones.
    if let Ok(b) = std::fs::read(p.join(format!("{mpn}-3d-iso-icon.png")))
        .or_else(|_| std::fs::read(p.join(&iso_file)))
        .or_else(|_| std::fs::read(p.join(format!("{mpn}-3d-iso.png"))))
    {
        out.insert("iso".to_string(), format!("data:image/png;base64,{}", b64(&b)));
    }
    // Name-embossed iso (OCCT-marked STEP rendered, then made transparent): the
    // part number is real raised geometry on the chip's top face. Used by the
    // "laser the name on the chip" mode so the mark is perspective-correct and
    // matches what ships to KiCad. Optional — only present once generated.
    if let Ok(b) = std::fs::read(p.join(format!("{mpn}-3d-iso-named-icon.png")))
        .or_else(|_| std::fs::read(p.join(format!("{mpn}-3d-iso-named.png"))))
    {
        out.insert("iso-named".to_string(), format!("data:image/png;base64,{}", b64(&b)));
    }
    // Vector outlines: <mpn>-3d-outline-<style>.svg → data:image/svg+xml. The
    // name-marked variants are <style>-named.svg (the part number drawn flat on
    // the top face — top-edge only, no extrusion clutter); they map to the
    // "<style>-named" key, served when the laser-name mode is on.
    let prefix = format!("{mpn}-3d-outline-");
    if let Ok(rd) = std::fs::read_dir(p) {
        for e in rd.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            if let Some(rest) = name.strip_prefix(&prefix) {
                if let Some(style) = rest.strip_suffix(".svg") {
                    if let Ok(s) = std::fs::read_to_string(e.path()) {
                        // style may be "thin" or "thin-named" — both become keys verbatim.
                        out.insert(style.to_string(), format!("data:image/svg+xml;base64,{}", b64(s.as_bytes())));
                    }
                }
            }
        }
    }
    out
}

/// Discover the symbol sources in a chip-fetcher library folder and render the
/// default (auto-grouped from ds2sf if present, else the manufacturer symbol).
fn load_from_dir(dir: &str, mpn: &str, init_source: &str) {
    // Remember the library root (this chip's parent folder) for the switcher.
    if let Some(parent) = std::path::Path::new(dir).parent() {
        *LIBRARY_ROOT.lock().unwrap() = Some(parent.to_string_lossy().to_string());
    }
    let has_ds2sf = read_file(dir, &format!("{mpn}-symbol.extracted.json")).is_some();
    let has_mfr = read_file(dir, &format!("{mpn}.kicad_sym")).is_some();
    // Discover per-source vendor variants: <mpn>.<method>.kicad_sym
    let mut variants = Vec::new();
    if let Ok(rd) = std::fs::read_dir(dir) {
        let suffix = ".kicad_sym";
        let prefix = format!("{mpn}.");
        for e in rd.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            if name == format!("{mpn}.kicad_sym") || !name.ends_with(suffix) || !name.starts_with(&prefix) {
                continue;
            }
            let method = &name[prefix.len()..name.len() - suffix.len()];
            // `vendor`/`prev` are auto-save BACKUPS, not provenance sources — don't
            // list them in the Source carat.
            if !method.is_empty() && !method.contains('.') && method != "vendor" && method != "prev" {
                variants.push(method.to_string());
            }
        }
    }
    let axis = read_file(dir, "info.json")
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| v.get("chosen_up_axis").and_then(|a| a.as_str()).map(String::from))
        .unwrap_or_default();
    // Honor an explicit initial source from the carat (ds2sf vs a vendor method);
    // otherwise default to auto-grouped (ds2sf) when available.
    let default_source = match init_source {
        "ds2sf" if has_ds2sf => "ds2sf".to_string(),
        "auto" if has_ds2sf => "auto".to_string(),
        s if !s.is_empty() && s != "ds2sf" && has_mfr => "manufacturer".to_string(),
        _ if has_ds2sf => "auto".to_string(),
        _ => "manufacturer".to_string(),
    };
    // Per-symbol saved prefs (info.json → "adom_symbol") WIN over the sticky
    // defaults. If present, this chip was deliberately set by the user, so we
    // restore exactly those and flag `saved` so the client won't re-apply sticky.
    let saved = read_saved_prefs(dir, mpn);
    let g = |k: &str, d: &str| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_str()).map(String::from).unwrap_or_else(|| d.to_string());
    let gb = |k: &str, d: bool| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_bool()).unwrap_or(d);
    let gn = |k: &str| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_f64());
    *MODEL.lock().unwrap() = Some(Model {
        dir: dir.to_string(),
        mpn: mpn.to_string(),
        has_ds2sf,
        has_mfr,
        variants,
        dir_for_3d: dir.to_string(),
        mpn_axis: axis,
        source: g("source", &default_source),
        pin_layout: g("pinLayout", "left-right"),
        grouped: gb("grouped", true),
        ref_pos: g("refPos", "out-top"),
        value_pos: g("valuePos", "none"),   // name lives ON the chip by default
        laser_name: gb("laserName", true),
        three_d: g("threeD", "bold"),
        chip_size: g("chipSize", "large"),
        saved: saved.is_some(),
        ref_x: gn("refX"),
        ref_y: gn("refY"),
        value_x: gn("valueX"),
        value_y: gn("valueY"),
    });
    regenerate();
}

/// Read this chip's per-symbol prefs block (`info.json` → `adom_symbol`), if any.
fn read_saved_prefs(dir: &str, _mpn: &str) -> Option<Value> {
    let v: Value = serde_json::from_str(&read_file(dir, "info.json")?).ok()?;
    v.get("adom_symbol").filter(|x| x.is_object()).cloned()
}

/// Persist this chip's per-symbol prefs into `info.json` → `adom_symbol`, merging
/// with the rest of info.json. This is what makes a deliberately-set symbol stick.
fn write_saved_prefs(dir: &str, prefs: Value) {
    let path = std::path::Path::new(dir).join("info.json");
    let mut root: Value = std::fs::read_to_string(&path).ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_else(|| json!({}));
    if !root.is_object() { root = json!({}); }
    root["adom_symbol"] = prefs;
    if let Ok(s) = serde_json::to_string_pretty(&root) {
        let _ = std::fs::write(&path, s);
    }
}

fn library_root() -> Option<String> {
    LIBRARY_ROOT.lock().unwrap().clone()
}

/// Every chip in the library root (a `<MPN>/<MPN>.kicad_sym` folder), sorted, with
/// the currently-loaded one flagged. Powers the left chip-library switcher.
fn list_chips() -> Vec<(String, bool)> {
    let root = match library_root() {
        Some(r) => r,
        None => return vec![],
    };
    let cur = MODEL.lock().unwrap().as_ref().map(|m| m.mpn.clone()).unwrap_or_default();
    let mut out = Vec::new();
    if let Ok(rd) = std::fs::read_dir(&root) {
        for e in rd.flatten() {
            if !e.path().is_dir() {
                continue;
            }
            let name = e.file_name().to_string_lossy().to_string();
            if e.path().join(format!("{name}.kicad_sym")).is_file() {
                let is_cur = name == cur;
                out.push((name, is_cur));
            }
        }
    }
    out.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
    out
}

/// Build the standalone interactive embed HTML for the CURRENTLY-shown symbol —
/// the exact layout on screen (source/pins/3D/labels), composited with the 3D
/// outline, so "open interactive viewer" always reflects what the studio shows.
fn build_embed_for(ks: &str, dir: &str, mpn: &str) -> Option<String> {
    if ks.trim().is_empty() { return None; }
    let render_name = crate::first_symbol_name(ks)?;
    let mut svg = crate::render::render_symbol_svg(ks, &render_name).ok()?;
    if let Some(p) = crate::render::default_white_outline_in(std::path::Path::new(dir), mpn) {
        let frac = crate::render::chip_size_frac("large");
        if let Ok(s) = crate::render::composite_iso_into_symbol(&svg, ks, &render_name, &p, frac) {
            svg = s;
        }
    }
    let meta = crate::parse::parse_kicad_sym(ks, &render_name);
    Some(crate::build_embed_html(&render_name, &svg, &meta))
}

fn build_current_embed() -> Option<String> {
    let (dir, mpn) = { let md = MODEL.lock().unwrap(); let m = md.as_ref()?; (m.dir.clone(), m.mpn.clone()) };
    let ks = { let st = STATE.lock().unwrap(); st.as_ref()?.kicad_sym.clone() };
    build_embed_for(&ks, &dir, &mpn)
}

/// Persist the current symbol back to its library folder so chip-fetcher and
/// adom-lbr see the latest: write the canonical `<mpn>.kicad_sym` (backing up the
/// original vendor file once to `<mpn>.vendor.kicad_sym`) and re-render
/// `<mpn>-symbol.svg` + the outline contract at the chosen chip size. Always
/// re-renders the thumbnail (so a size change persists) even when the symbol
/// itself is unchanged. Returns true if there was a chip to save.
fn save_current(chip_size: &str) -> bool {
    let (dir, mpn) = match MODEL.lock().unwrap().as_ref() {
        Some(m) => (m.dir.clone(), m.mpn.clone()),
        None => return false,
    };
    if dir.is_empty() || mpn.is_empty() {
        return false;
    }
    let ks = match STATE.lock().unwrap().as_ref() {
        Some(s) => s.kicad_sym.clone(),
        None => return false,
    };
    if ks.trim().is_empty() {
        return false;
    }
    let dirp = std::path::Path::new(&dir);
    let canon = dirp.join(format!("{mpn}.kicad_sym"));
    let existing = std::fs::read_to_string(&canon).unwrap_or_default();
    if existing.trim() != ks.trim() {
        if !existing.is_empty() {
            // Backups live in a hidden `.bak/` subdir so they never get picked up
            // as `<mpn>.<method>.kicad_sym` source variants (which cluttered the
            // Source carat). `.vendor` = the ORIGINAL, kept once; `.prev` = the
            // immediately-previous canonical, a one-step undo so a later edit can't
            // unrecoverably clobber an earlier good one.
            let bak = dirp.join(".bak");
            let _ = std::fs::create_dir_all(&bak);
            let vendor = bak.join(format!("{mpn}.vendor.kicad_sym"));
            if !vendor.exists() {
                let _ = std::fs::write(&vendor, &existing);
            }
            let _ = std::fs::write(bak.join(format!("{mpn}.prev.kicad_sym")), &existing);
        }
        let _ = std::fs::write(&canon, &ks);
    }
    let render_name = crate::first_symbol_name(&ks).unwrap_or_else(|| mpn.clone());
    let frac = crate::render::chip_size_frac(chip_size);
    let _ = crate::render::render_into_library(&ks, &render_name, &mpn, dirp, frac);
    // Persist the per-symbol prefs so this deliberate look STICKS — on the next
    // load/nav-back the sticky default won't override it (saved wins).
    if let Some(m) = MODEL.lock().unwrap().as_mut() {
        m.chip_size = chip_size.to_string();
        m.saved = true;
        write_saved_prefs(&dir, json!({
            "source": m.source, "pinLayout": m.pin_layout, "grouped": m.grouped,
            "refPos": m.ref_pos, "valuePos": m.value_pos, "laserName": m.laser_name,
            "threeD": m.three_d, "chipSize": chip_size,
            // Custom (dragged / auto-placed) designator positions, KiCad mm Y-up.
            "refX": m.ref_x, "refY": m.ref_y, "valueX": m.value_x, "valueY": m.value_y,
        }));
    }
    true
}

/// Remove this chip's per-symbol prefs (info.json → `adom_symbol`) so it goes back
/// to FOLLOWING the sticky default. Backs the kicad_sym change is NOT undone here;
/// this only un-pins the layout/display prefs. Returns true if it had any.
fn unpin_current() -> bool {
    let (dir, mpn) = match MODEL.lock().unwrap().as_ref() { Some(m) => (m.dir.clone(), m.mpn.clone()), None => return false };
    if dir.is_empty() { return false; }
    let dirp = std::path::Path::new(&dir);
    let mut had = false;
    // 1) Drop this chip's per-symbol prefs block from info.json.
    let path = dirp.join("info.json");
    if let Ok(s) = std::fs::read_to_string(&path) {
        if let Ok(mut root) = serde_json::from_str::<Value>(&s) {
            had = root.get("adom_symbol").is_some();
            if let Some(obj) = root.as_object_mut() { obj.remove("adom_symbol"); }
            if let Ok(out) = serde_json::to_string_pretty(&root) { let _ = std::fs::write(&path, out); }
        }
    }
    // 2) Restore the pristine vendor .kicad_sym if a backup exists — so a label
    //    that was dragged/auto-placed and SAVED (baked into the canonical file) is
    //    fully reverted, not just un-pinned. The .vendor backup is written once on
    //    the first save (save_current), so it's the true original.
    let vendor = dirp.join(".bak").join(format!("{mpn}.vendor.kicad_sym"));
    if vendor.exists() {
        if let Ok(orig) = std::fs::read_to_string(&vendor) {
            if std::fs::write(dirp.join(format!("{mpn}.kicad_sym")), orig).is_ok() { had = true; }
        }
    }
    // 3) Clear in-memory: no saved prefs, no custom field overrides.
    if let Some(m) = MODEL.lock().unwrap().as_mut() {
        m.saved = false;
        m.ref_x = None; m.ref_y = None; m.value_x = None; m.value_y = None;
    }
    had
}

/// Write the given prefs into chips' info.json (bulk "Apply to all"). When `only`
/// is Some, restrict to exactly those MPNs (the filtered/shown set); when None,
/// every chip in the library root. Only touches info.json (no re-render) — the new
/// look applies as each chip loads. Returns how many chips were written.
fn apply_prefs_to_all(prefs: &Value, only: Option<&[String]>) -> usize {
    let root = match library_root() { Some(r) => r, None => return 0 };
    let mut n = 0;
    if let Ok(rd) = std::fs::read_dir(&root) {
        for e in rd.flatten() {
            if !e.path().is_dir() { continue; }
            let name = e.file_name().to_string_lossy().to_string();
            if let Some(list) = only { if !list.iter().any(|m| m == &name) { continue; } }
            if e.path().join(format!("{name}.kicad_sym")).is_file() {
                write_saved_prefs(&e.path().to_string_lossy(), prefs.clone());
                n += 1;
            }
        }
    }
    n
}

/// Regenerate a chip's 3D assets (outlines + iso, forced) via chip-thumbnailer,
/// then refresh the in-memory 3D assets so the live app shows the fresh outline,
/// and bump REV so the poller re-applies them. chip-thumbnailer resolves the chip
/// via the library root it reads from the SAME `CHIP_FETCHER_LIB` this process
/// inherited. Runs on a background thread (it's multi-second per chip).
fn run_regen(dir: &str, mpn: &str, axis: &str) {
    let _ = std::process::Command::new("adom-chip-thumbnailer")
        .args(["rerender", mpn, "--kind", "outline"])
        .output();   // LIGHT: just the composited vector outline, not the iso family
    let assets = load_3d_assets(dir, mpn, axis);
    // The user may have switched chips while this ran — only apply the refreshed
    // assets if we're STILL on this chip, else we'd corrupt the new chip's view.
    let still_current = MODEL.lock().unwrap().as_ref().map(|m| m.mpn == mpn).unwrap_or(false);
    if still_current {
        if let Some(st) = STATE.lock().unwrap().as_mut() {
            st.three_d_assets = assets;
        }
        REV.fetch_add(1, Ordering::SeqCst);
    }
}

/// Load a chip given a `.kicad_sym` path. If it sits in a chip-fetcher library
/// layout (`<root>/<mpn>/<mpn>.kicad_sym`) we load the FULL model (sources + 3D
/// assets) and register the library root for the switcher; otherwise we fall back
/// to a plain render of just that file.
pub fn load_chip_from_file(file: &std::path::Path) -> Result<()> {
    let mpn = file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
    let dir = file.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
    if !mpn.is_empty()
        && !dir.is_empty()
        && std::path::Path::new(&dir).join(format!("{mpn}.kicad_sym")).is_file()
    {
        load_from_dir(&dir, &mpn, "auto");
        Ok(())
    } else {
        load_symbol_into_state(file, None)
    }
}

/// Regenerate the AppState from the current Model (source + options).
/// Bake the model's custom Reference/Value positions into a kicad_sym, if any.
/// No-op when neither override is set (the discrete refPos/valuePos placement,
/// or the vendor file's own positions, stands). Source-agnostic.
fn apply_field_overrides(ks: String, m: &Model) -> String {
    let mut out = ks;
    if let (Some(x), Some(y)) = (m.ref_x, m.ref_y) {
        out = crate::render::set_field_pos(&out, "Reference", x, y);
    }
    if let (Some(x), Some(y)) = (m.value_x, m.value_y) {
        out = crate::render::set_field_pos(&out, "Value", x, y);
    }
    out
}

fn regenerate() {
    let m = match MODEL.lock().unwrap().clone() {
        Some(m) => m,
        None => return,
    };
    let mut sources = Vec::new();
    if m.has_ds2sf {
        sources.push("auto".to_string());
        sources.push("ds2sf".to_string());
    }
    if m.has_mfr {
        sources.push("manufacturer".to_string());
    }
    for v in &m.variants {
        sources.push(v.clone());
    }
    // Never list the same source twice (a `.manufacturer.` variant is a tagged
    // copy of the canonical CSE symbol — same thing, one entry).
    let mut seen = std::collections::HashSet::new();
    sources.retain(|s| seen.insert(s.clone()));

    // Friendly labels per source. The canonical "manufacturer" entry is shown by
    // its REAL origin (e.g. "SnapEDA") from info.json.source_of_record / provenance,
    // so the user sees where it came from, not a generic word.
    let manufacturer = read_file(&m.dir, "info.json")
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| v.get("manufacturer").and_then(|x| x.as_str()).map(String::from))
        .unwrap_or_default();
    // Where the whole library bundle was downloaded (discovery_source) — NOT who
    // authored it (content_origin). A TI model pulled from CSE reads "Component
    // Search Engine", not "Manufacturer". The altium/fusion format variants came
    // from this SAME bundle, so they share this discovery source.
    let discovery = read_file(&m.dir, &format!("{}-file-provenance.json", m.mpn))
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| v.get(format!("{}.kicad_sym", m.mpn)).and_then(|e| e.get("discovery_source").or_else(|| e.get("content_origin"))).and_then(|s| s.as_str()).map(String::from))
        .or_else(|| {
            read_file(&m.dir, "info.json")
                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
                .and_then(|v| v.get("source_of_record").and_then(|s| s.get("kicad_sym")).and_then(|s| s.as_str()).map(String::from))
        })
        .unwrap_or_else(|| "cse".to_string());
    let discovery_label = crate::provenance::source_label(&discovery, &manufacturer);

    let mut source_labels: std::collections::HashMap<String, String> = std::collections::HashMap::new();
    for s in &sources {
        source_labels.insert(s.clone(), source_label(s, &manufacturer, &discovery_label));
    }

    // A vendor variant source = a discovered <mpn>.<method>.kicad_sym.
    let is_variant = m.variants.iter().any(|v| v == &m.source);

    let result: Result<AppState> = (|| {
        let mut st = if is_variant {
            let ks = read_file(&m.dir, &format!("{}.{}.kicad_sym", m.mpn, m.source))
                .ok_or_else(|| anyhow::anyhow!("no {}.{}.kicad_sym", m.mpn, m.source))?;
            let rn = crate::first_symbol_name(&ks).unwrap_or_else(|| m.mpn.clone());
            build_symbol_state(&apply_field_overrides(ks, &m), &rn, &m.mpn, "", "", "", "")?
        } else if m.source == "manufacturer" || (!m.has_ds2sf) {
            let ks = read_file(&m.dir, &format!("{}.kicad_sym", m.mpn))
                .ok_or_else(|| anyhow::anyhow!("no {}.kicad_sym", m.mpn))?;
            let rn = crate::first_symbol_name(&ks).unwrap_or_else(|| m.mpn.clone());
            build_symbol_state(&apply_field_overrides(ks, &m), &rn, &m.mpn, "", "", "", "")?
        } else {
            // auto / ds2sf → generate via our generator from ds2sf pins+groups.
            let js = read_file(&m.dir, &format!("{}-symbol.extracted.json", m.mpn))
                .ok_or_else(|| anyhow::anyhow!("no ds2sf extraction"))?;
            let sym = crate::ds2sf::parse(&js)?;
            let opts = crate::ds2sf::LayoutOpts {
                pin_layout: m.pin_layout.clone(),
                grouped: m.grouped,
                ref_pos: m.ref_pos.clone(),
                value_pos: m.value_pos.clone(),
            };
            let (input, pin_desc, group_desc) = crate::ds2sf::build_ic_input(&sym, &opts);
            let ks = apply_field_overrides(crate::sym_gen::generate_ic_sym(&input), &m);
            let mut st = build_symbol_state(&ks, &input.symbol_name, &m.mpn, &sym.manufacturer, &sym.package, &sym.description, "")?;
            // merge ds2sf descriptions onto the parsed pins + groups (for tooltips)
            for p in st.pins.iter_mut() {
                if let Some(d) = pin_desc.get(&p.name).or_else(|| pin_desc.get(&p.number)) {
                    p.desc = d.clone();
                }
            }
            for g in st.groups.iter_mut() {
                if let Some(d) = group_desc.get(&g.name) {
                    g.desc = d.clone();
                }
            }
            st
        };
        st.sources = sources.clone();
        st.source = m.source.clone();
        st.pin_layout = m.pin_layout.clone();
        st.grouped = m.grouped;
        st.ref_pos = m.ref_pos.clone();
        st.value_pos = m.value_pos.clone();
        st.laser_name = m.laser_name;
        st.three_d = m.three_d.clone();
        st.three_d_assets = load_3d_assets(&m.dir_for_3d, &m.mpn, &m.mpn_axis);
        st.source_labels = source_labels.clone();
        st.saved_layout = m.saved;
        st.chip_size = m.chip_size.clone();
        Ok(st)
    })();

    if let Ok(st) = result {
        set_state(st);
    }
}

/// Minimal base64 (std-only) for the iso-render data URL.
fn b64(data: &[u8]) -> String {
    const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
    for c in data.chunks(3) {
        let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
        let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
        out.push(T[(n >> 18 & 63) as usize] as char);
        out.push(T[(n >> 12 & 63) as usize] as char);
        out.push(if c.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
        out.push(if c.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
    }
    out
}

pub fn run(port: u16) -> Result<()> {
    *EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());
    *CONSOLE.lock().unwrap() = Some(Vec::new());
    let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;
    eprintln!("[adom-symbol] serving on http://127.0.0.1:{port}");
    register_port(port, "adom-symbol", "KiCad symbol viewer live app (AI-drivable)");

    for mut req in server.incoming_requests() {
        let url = req.url().split('?').next().unwrap_or("/").to_string();
        let method = req.method().clone();
        let mut body = String::new();
        if matches!(method, Method::Post) {
            let _ = req.as_reader().read_to_string(&mut body);
        }
        let resp = route(&method, &url, &body);
        let _ = req.respond(resp);
        if url == "/shutdown" && matches!(method, Method::Post) {
            break;
        }
    }
    unregister_port(port);
    Ok(())
}

/// Best-effort HD port registration (app-creator §6a / adom-port-hints).
/// Raw TCP so we add no HTTP-client dependency; never blocks serving, never
/// fails the app if HD is absent (outside Hydrogen Desktop).
fn register_port(port: u16, name: &str, reason: &str) {
    let body = format!(
        "{{\"port\":{port},\"action\":\"register\",\"name\":\"{name}\",\"reason\":\"{reason}\",\"expose\":\"internal\"}}"
    );
    std::thread::spawn(move || hd_post(&body));
}
fn unregister_port(port: u16) {
    hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}"));
}
fn hd_post(body: &str) {
    use std::io::Write;
    use std::net::{TcpStream, ToSocketAddrs};
    use std::time::Duration;
    let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) {
        Some(a) => a,
        None => return,
    };
    if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {
        let _ = s.set_write_timeout(Some(Duration::from_millis(800)));
        let req = format!(
            "POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
            body.len(),
            body
        );
        let _ = s.write_all(req.as_bytes());
    }
}

fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    match (method, url) {
        (Method::Get, "/") | (Method::Get, "/index.html") =>
            html(&APP_HTML.replace("/*__VIEWER_CORE__*/", include_str!("viewer_core.js"))),
        (Method::Get, "/favicon.svg") => svg(FAVICON),
        (Method::Get, "/state") => json_resp(&state_json()),
        (Method::Get, "/svg") => {
            let (svg, rev) = match &*STATE.lock().unwrap() {
                Some(s) => (s.svg.clone(), REV.load(Ordering::SeqCst)),
                None => (String::new(), 0),
            };
            json_resp(&json!({ "svg": svg, "rev": rev }).to_string())
        }
        // Standalone interactive viewer for the CURRENT symbol — opened in a new
        // tab by the app's "Open interactive viewer" button. Always live: it
        // reflects the layout on screen right now.
        (Method::Get, "/embed") => match build_current_embed() {
            Some(h) => html(&h),
            None => json_err(404, "no symbol loaded"),
        },
        _ if url.starts_with("/3d/") => {
            let mode = &url["/3d/".len()..];
            let asset = STATE.lock().unwrap().as_ref().and_then(|s| s.three_d_assets.get(mode).cloned()).unwrap_or_default();
            json_resp(&json!({ "asset": asset }).to_string())
        }
        (Method::Post, "/load") => {
            if let Ok(v) = serde_json::from_str::<Value>(body) {
                // Preferred: push raw .kicad_sym; the server renders it via
                // service-kicad (mirrors adom-step's /api/load). This is how
                // chip-fetcher drives the app — push a file, it renders.
                let g = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
                // Folder mode (preferred): discover sources + render auto-grouped default.
                if !g("dir").is_empty() && !g("mpn").is_empty() {
                    load_from_dir(&g("dir"), &g("mpn"), &g("source"));
                    return json_resp("{\"ok\":true}");
                }
                if let Some(ks) = v.get("kicadSym").and_then(|x| x.as_str()) {
                    // Render by the file's ACTUAL symbol name; display the
                    // caller's label (e.g. the MPN). Parses pins + offset.
                    let render_name = crate::first_symbol_name(ks).unwrap_or_else(|| "symbol".to_string());
                    let display = { let d = g("symbolName"); if d.is_empty() { render_name.clone() } else { d } };
                    match build_symbol_state(ks, &render_name, &display, &g("manufacturer"), &g("package"), &g("description"), &g("datasheet")) {
                        Ok(st) => set_state(st),
                        Err(e) => return json_err(422, &e.to_string()),
                    }
                } else {
                    // Pre-rendered: caller already has the SVG (no interactive data).
                    set_state(AppState {
                        symbol_name: g("symbolName"),
                        svg: g("svg"),
                        manufacturer: g("manufacturer"),
                        package: g("package"),
                        description: g("description"),
                        pin_count: v.get("pinCount").and_then(|x| x.as_u64()).unwrap_or(0) as usize,
                        ..Default::default()
                    });
                }
            }
            json_resp("{\"ok\":true}")
        }
        (Method::Get, "/api/projects") => {
            // Mirror chip-fetcher's projects (same on-disk store) so the chip
            // switcher can offer the same global + per-project filtered lists.
            let home = std::env::var("HOME").unwrap_or_default();
            let path = std::path::Path::new(&home).join(".config/adom-chip-fetcher/projects.json");
            let body = std::fs::read_to_string(&path).unwrap_or_else(|_| "{\"projects\":{}}".to_string());
            json_resp(&body)
        }
        (Method::Get, "/api/chips") => {
            let arr: Vec<Value> = list_chips().into_iter().map(|(m, c)| json!({"mpn": m, "current": c})).collect();
            let cur = MODEL.lock().unwrap().as_ref().map(|m| m.mpn.clone()).unwrap_or_default();
            json_resp(&json!({"chips": arr, "current": cur}).to_string())
        }
        (Method::Post, "/load-chip") => {
            let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);
            let mpn = v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string();
            // Deliberately NO auto-save here. The client flushes any unsaved edit
            // (POST /save) on the chip you're LEAVING before it calls /load-chip, so
            // a deliberate look is still persisted. Calling save_current() on every
            // switch re-wrote info.json (+ the kicad_sym, + re-rendered the SVG) for
            // every chip you merely BROWSED past — silently pinning the whole library
            // to whatever the sticky look happened to be. A chip you only viewed must
            // stay unpinned and keep following the sticky default. (Root-caused 2026-06-29.)
            match library_root() {
                Some(root) if !mpn.is_empty() => {
                    let dir = std::path::Path::new(&root).join(&mpn);
                    if dir.join(format!("{mpn}.kicad_sym")).is_file() {
                        load_from_dir(&dir.to_string_lossy(), &mpn, "auto");
                        json_resp("{\"ok\":true}")
                    } else {
                        json_err(404, "chip not found in library")
                    }
                }
                _ => json_err(404, "no library root / mpn"),
            }
        }
        (Method::Post, "/save") => {
            let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);
            let chip_size = v.get("chipSize").and_then(|x| x.as_str()).unwrap_or("large");
            let saved = save_current(chip_size);
            // Keep the standalone interactive embed in sync with every Save — but
            // OFF the request thread (it re-renders via service-kicad, up to 60s).
            // Capture the just-saved symbol so a quick chip-switch can't race it.
            if saved {
                let dm = { let md = MODEL.lock().unwrap(); md.as_ref().map(|m| (m.dir.clone(), m.mpn.clone())) };
                let ks = { let st = STATE.lock().unwrap(); st.as_ref().map(|s| s.kicad_sym.clone()) };
                let cap = match (dm, ks) { (Some((dir, mpn)), Some(ks)) => Some((ks, dir, mpn)), _ => None };
                if let Some((ks, dir, mpn)) = cap {
                    std::thread::spawn(move || {
                        match build_embed_for(&ks, &dir, &mpn) {
                            Some(h) => {
                                let p = std::path::Path::new(&dir).join(format!("{mpn}-embed.html"));
                                if let Err(e) = std::fs::write(&p, h) {
                                    eprintln!("WARN: embed auto-write failed for {mpn}: {e}");
                                }
                            }
                            None => eprintln!("WARN: embed regen skipped for {mpn} (render failed)"),
                        }
                    });
                }
            }
            json_resp(&json!({"ok": true, "saved": saved}).to_string())
        }
        (Method::Post, "/apply-all") => {
            // Bulk: push the CURRENT layout/display prefs onto every chip in the
            // library (the deliberate "make everything bold" path — overrides even
            // per-symbol pins, which is what the user wants for a bulk change).
            let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);
            let chip_size = v.get("chipSize").and_then(|x| x.as_str()).unwrap_or("large");
            let prefs = MODEL.lock().unwrap().as_ref().map(|m| json!({
                "source": m.source, "pinLayout": m.pin_layout, "grouped": m.grouped,
                "refPos": m.ref_pos, "valuePos": m.value_pos, "laserName": m.laser_name,
                "threeD": m.three_d, "chipSize": chip_size,
            }));
            // Optional explicit scope (the filtered/shown chips). Absent = whole library.
            let only: Option<Vec<String>> = v.get("mpns").and_then(|x| x.as_array())
                .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect());
            let n = match prefs { Some(p) => apply_prefs_to_all(&p, only.as_deref()), None => 0 };
            // The current chip is now "saved" too.
            if let Some(m) = MODEL.lock().unwrap().as_mut() { m.saved = true; m.chip_size = chip_size.to_string(); }
            json_resp(&json!({"ok": true, "count": n}).to_string())
        }
        (Method::Post, "/unpin") => {
            // Drop this chip's per-symbol prefs + restore the vendor original, then
            // re-render so the reverted symbol shows immediately.
            let had = unpin_current();
            regenerate();
            json_resp(&json!({"ok": true, "had": had}).to_string())
        }
        (Method::Post, "/regen") => {
            // Kick chip-thumbnailer to regenerate this chip's 3D outlines + iso in
            // a background thread; /state.regen drives the app's spinner.
            let started = {
                match MODEL.lock().unwrap().as_ref() {
                    Some(m) if !m.dir.is_empty() && !m.mpn.is_empty() => {
                        if !REGEN_RUNNING.swap(true, Ordering::SeqCst) {
                            let (dir, mpn, axis) = (m.dir.clone(), m.mpn.clone(), m.mpn_axis.clone());
                            std::thread::spawn(move || {
                                run_regen(&dir, &mpn, &axis);
                                REGEN_RUNNING.store(false, Ordering::SeqCst);
                            });
                        }
                        true
                    }
                    _ => false,
                }
            };
            json_resp(&format!("{{\"ok\":{started}}}"))
        }
        (Method::Post, "/deliver") => {
            let target = serde_json::from_str::<Value>(body)
                .ok()
                .and_then(|v| v.get("target").and_then(|t| t.as_str()).map(String::from))
                .unwrap_or_default();
            let (name, ks) = match &*STATE.lock().unwrap() {
                Some(s) => (s.symbol_name.clone(), s.kicad_sym.clone()),
                None => (String::new(), String::new()),
            };
            json_resp(&crate::deliver::deliver(&name, &ks, &target).to_string())
        }
        (Method::Post, "/relayout") => {
            let mut non_3d_change = false;
            let mut laser_only = false;
            let mut new_3d: Option<String> = None;
            if let Ok(v) = serde_json::from_str::<Value>(body) {
                if let Some(m) = MODEL.lock().unwrap().as_mut() {
                    let g = |k: &str| v.get(k).and_then(|x| x.as_str()).map(String::from);
                    let gn = |k: &str| v.get(k).and_then(|x| x.as_f64());
                    if let Some(s) = g("source") { m.source = s; non_3d_change = true; }
                    if let Some(s) = g("pinLayout") { m.pin_layout = s; non_3d_change = true; }
                    // A discrete refPos/valuePos preset CLEARS any custom (dragged)
                    // position for that field — the preset wins, as the user just chose it.
                    if let Some(s) = g("refPos") { m.ref_pos = s; m.ref_x = None; m.ref_y = None; non_3d_change = true; }
                    if let Some(s) = g("valuePos") { m.value_pos = s; m.value_x = None; m.value_y = None; non_3d_change = true; }
                    // Custom (manual-drag) positions, KiCad symbol-local mm, Y-up.
                    if let (Some(x), Some(y)) = (gn("refX"), gn("refY")) { m.ref_x = Some(x); m.ref_y = Some(y); non_3d_change = true; }
                    if let (Some(x), Some(y)) = (gn("valueX"), gn("valueY")) { m.value_x = Some(x); m.value_y = Some(y); non_3d_change = true; }
                    if let Some(b) = v.get("grouped").and_then(|x| x.as_bool()) { m.grouped = b; non_3d_change = true; }
                    if let Some(b) = v.get("laserName").and_then(|x| x.as_bool()) { m.laser_name = b; laser_only = true; }
                    if let Some(s) = g("threeD") { m.three_d = s.clone(); new_3d = Some(s); }
                }
            }
            if non_3d_change {
                regenerate(); // re-renders the symbol (+ refreshes 3D mode + laser flag)
            } else if laser_only {
                // Laser-name toggle only: client overlay change — update state +
                // bump rev so the poller re-fetches, no expensive re-render.
                let laser = MODEL.lock().unwrap().as_ref().map(|m| m.laser_name).unwrap_or(false);
                if let Some(st) = STATE.lock().unwrap().as_mut() { st.laser_name = laser; }
                REV.fetch_add(1, Ordering::SeqCst);
            } else if let Some(td) = new_3d {
                // 3D-only toggle: update in place, no expensive re-render / no rev bump.
                if let Some(st) = STATE.lock().unwrap().as_mut() {
                    st.three_d = td;
                }
            }
            json_resp("{\"ok\":true}")
        }
        (Method::Post, "/autofields") => {
            // Auto-place the Reference + Value designators CLEAR of the body and
            // every pin: Reference centred just above the top extent, Value just
            // below the bottom extent. Baked source-agnostically via the override.
            let placed = {
                let st = STATE.lock().unwrap();
                st.as_ref().and_then(|s| {
                    // Seed the extents from the body rect when present; otherwise
                    // (polyline-only / discrete symbols with no rect) fall back to
                    // the pin envelope so auto-place NEVER dead-ends.
                    let mut max_y = f64::NEG_INFINITY;
                    let mut min_y = f64::INFINITY;
                    let mut cx = 0.0;
                    if let Some(b) = s.body_rect.as_ref() {
                        max_y = b.y1.max(b.y2); min_y = b.y1.min(b.y2);
                        cx = (b.x1 + b.x2) / 2.0;
                    }
                    if !s.pins.is_empty() {
                        let (mut sx, mut have_x) = (0.0, false);
                        for p in &s.pins {
                            if p.y > max_y { max_y = p.y; }
                            if p.y < min_y { min_y = p.y; }
                            sx += p.x; have_x = true;
                        }
                        if s.body_rect.is_none() && have_x { cx = sx / s.pins.len() as f64; }
                    }
                    if max_y.is_finite() && min_y.is_finite() {
                        const M: f64 = 2.54; // clearance above/below the outermost feature
                        Some((cx, max_y + M, cx, min_y - M))
                    } else { None }
                })
            };
            match placed {
                Some((rx, ry, vx, vy)) => {
                    if let Some(m) = MODEL.lock().unwrap().as_mut() {
                        m.ref_x = Some(rx); m.ref_y = Some(ry);
                        m.value_x = Some(vx); m.value_y = Some(vy);
                    }
                    regenerate();
                    json_resp(&json!({"ok": true, "ref": [rx, ry], "value": [vx, vy]}).to_string())
                }
                None => json_err(400, "no body geometry to auto-place from"),
            }
        }
        (Method::Post, "/eval") => {
            let code = serde_json::from_str::<Value>(body)
                .ok()
                .and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from))
                .unwrap_or_default();
            let id = EVAL_ID.fetch_add(1, Ordering::SeqCst);
            EVAL_PENDING.lock().unwrap().push_back((id, code));
            json_resp(&json!({ "id": id.to_string() }).to_string())
        }
        (Method::Get, "/eval/pending") => {
            let next = EVAL_PENDING.lock().unwrap().pop_front();
            match next {
                Some((id, code)) => json_resp(&json!({ "id": id.to_string(), "code": code }).to_string()),
                None => json_resp("{}"),
            }
        }
        (Method::Get, "/console") => {
            let log = CONSOLE.lock().unwrap().clone().unwrap_or_default();
            json_resp(&json!({ "lines": log }).to_string())
        }
        (Method::Post, "/console") => {
            if let Ok(v) = serde_json::from_str::<Value>(body) {
                let line = format!(
                    "[{}] {}",
                    v.get("level").and_then(|l| l.as_str()).unwrap_or("log"),
                    v.get("msg").and_then(|m| m.as_str()).unwrap_or("")
                );
                if let Some(buf) = CONSOLE.lock().unwrap().as_mut() {
                    buf.push(line);
                    let len = buf.len();
                    if len > 500 {
                        buf.drain(0..len - 500);
                    }
                }
            }
            json_resp("{\"ok\":true}")
        }
        (Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),
        // /eval/:id  and  /eval/:id/result
        _ if url.starts_with("/eval/") => {
            let rest = &url["/eval/".len()..];
            if let Some(id_str) = rest.strip_suffix("/result") {
                if matches!(method, Method::Post) {
                    if let (Ok(id), Ok(v)) = (id_str.parse::<u64>(), serde_json::from_str::<Value>(body)) {
                        if let Some(map) = EVAL_RESULTS.lock().unwrap().as_mut() {
                            map.insert(id, v);
                        }
                    }
                    return json_resp("{\"ok\":true}");
                }
            }
            // GET /eval/:id → poll result
            if let Ok(id) = rest.parse::<u64>() {
                let map = EVAL_RESULTS.lock().unwrap();
                if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {
                    return json_resp(&json!({ "done": true, "result": r }).to_string());
                }
                return json_resp("{\"done\":false}");
            }
            not_found()
        }
        _ => not_found(),
    }
}

fn header(ct: &str) -> Header {
    Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()
}
fn html(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(s).with_header(header("text/html; charset=utf-8"))
}
fn svg(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(s).with_header(header("image/svg+xml"))
}
fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(s).with_header(header("application/json"))
}
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(header("application/json"))
}
fn json_err(code: u16, msg: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let body = json!({ "ok": false, "error": msg }).to_string();
    Response::from_string(body).with_status_code(code).with_header(header("application/json"))
}

/// Load a .kicad_sym from disk, render it via service-kicad, set app state.
pub fn load_symbol_into_state(file: &std::path::Path, name: Option<&str>) -> Result<()> {
    let content = std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
    let symbol_name = name
        .map(String::from)
        .or_else(|| crate::first_symbol_name(&content))
        .unwrap_or_else(|| "symbol".to_string());
    let st = build_symbol_state(&content, &symbol_name, &symbol_name, "", "", "", "")?;
    set_state(st);
    Ok(())
}