123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332
'use strict';
/*
 * kicad-library-manager — organize your KiCad libraries fast.
 *
 * Pure-Node http server (no native deps). Serves a single-file SPA and a small REST API.
 * The user's libraries live on THEIR machine; we reach the filesystem through the
 * `adom-desktop` bridge (never the container FS), exactly like chip-fetcher-lite.
 *
 * Three views over one parsed model:
 *   - symbol    : grouped by symbol library; columns symbol / ref / value / datasheet / mpn / description (editable)
 *   - footprint : grouped by .pretty; columns footprint / 3d-model / #symbols-using (expandable)
 *   - model3d   : grouped by folder; the 3D model on the left, footprints that use it
 *
 * Plus: select + delete, an undo/redo TREE (snapshots), an always-kept initial backup with
 * a revert button, and a Sync button that writes the app's state to disk (toggle again to
 * stop & undo). Disk writes are guarded (.bak backups, never silently shrink a lib, deletions
 * go to a reversible trash dir).
 *
 * REST API:
 *   GET  /api/state                         -> sanitized view of the working model + history/sync flags
 *   POST /api/probe-request    {edas}        -> scan the user's machine via adom-desktop, parse, snapshot backup
 *   POST /api/symbol/:id/field {field,value} -> edit a symbol field (symbol mode only)
 *   POST /api/delete           {mode,ids}    -> delete selected entries (symbol/footprint/model)
 *   POST /api/undo  /api/redo                -> walk the undo tree
 *   POST /api/revert                         -> restore the working model to the initial backup
 *   POST /api/sync             {}            -> write working model to disk (or, if active, stop & undo)
 */
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');

const ROOT = __dirname;
const DATA_DIR = process.env.KLM_DATA_DIR || path.join(ROOT, 'data');
const DATA_FILE = path.join(DATA_DIR, 'data.json');
const PUBLIC_DIR = path.join(ROOT, 'public');
const STAGING_DIR = path.join(DATA_DIR, 'staging');
const PORT = parseInt(process.env.PORT || process.env.KLM_PORT || '7823', 10);
const HOME = os.homedir();
const WIKI = process.env.KLM_WIKI || 'https://wiki.adom.inc';
const JLCPCB_API = process.env.JLCPCB_API || process.env.KLM_JLCPCB_API || 'https://jlcpcb-wela51osctvp.adom.cloud';
const LCSC_CACHE = new Map();

// ----------------------------------------------------------------- persistence
function freshLibs() { return { symbolLibs: [], footprintLibs: [], models3d: [] }; }
function freshDB() {
  return {
    libs: freshLibs(),
    backup: null,                 // deep snapshot of libs at probe time (persisted)
    probe: { status: 'idle', probedAt: null, edas: ['kicad'], tablesFound: [], counts: {}, error: null },
    sync: { active: false, status: 'idle', lastSyncedAt: null, log: [], error: null, trashed: [], pendingTrash: [], pendingUnregister: [], unregisteredTables: [] },
  };
}
let DB = load();
// history is in-memory only (full-libs snapshots would bloat data.json); rebuilt on load.
let HIST = { nodes: {}, rootId: null, currentId: null };

function load() {
  try {
    const d = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
    return Object.assign(freshDB(), d);
  } catch { return freshDB(); }
}
let _saveT = null;
function save() {
  clearTimeout(_saveT);
  _saveT = setTimeout(() => {
    try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(DATA_FILE, JSON.stringify(DB)); }
    catch (e) { console.error('save failed', e.message); }
  }, 250);
}
const clone = o => JSON.parse(JSON.stringify(o));
const uid = () => crypto.randomBytes(6).toString('hex');

// ----------------------------------------------------------------- adom-desktop bridge
// Run a shell command on the USER's machine. Returns stdout (best-effort JSON-unwrapped).
function runDesktopShell(command, timeoutSeconds = 120) {
  return new Promise((resolve) => {
    execFile('adom-desktop', ['shell_execute', JSON.stringify({ command, timeoutSeconds })],
      { timeout: (timeoutSeconds + 20) * 1000, maxBuffer: 256 << 20, env: process.env }, (err, stdout) => {
        let out = '';
        try { out = JSON.parse(stdout || '{}').stdout || ''; } catch { out = stdout || ''; }
        resolve(out);
      });
  });
}
function adomDesktopJSON(verb, argsObj, timeout = 180000) {
  return new Promise((resolve) => {
    execFile('adom-desktop', [verb, JSON.stringify(argsObj)], { timeout, maxBuffer: 256 << 20, env: process.env }, (err, stdout) => {
      let j = null; try { j = JSON.parse(stdout || '{}'); } catch {}
      resolve({ err, j, stdout: stdout || '' });
    });
  });
}
const shq = s => "'" + String(s).replace(/'/g, "'\\''") + "'";
const dirOf = p => (p || '').replace(/[\\/][^\\/]*$/, '');
const baseOf = p => (p || '').split(/[\\/]/).filter(Boolean).pop() || '';
function expandPath(p) {
  if (!p) return p;
  return p.replace(/^~(?=[\\/]|$)/, HOME).replace(/\$\{HOME\}/g, HOME).replace(/\$HOME/g, HOME)
    .replace(/\$\{USERPROFILE\}/g, HOME);
}
// Is this lib-table URI / path a KiCad STOCK (default) library rather than a personal one?
function isStockUri(uri) {
  if (!uri) return false;
  return /\$\{KICAD\d*_(FOOTPRINT|SYMBOL|3DMODEL)_DIR\}|\$\{KISYS(MOD|3DMOD)\}/i.test(uri)
    || /[\\/]usr[\\/](local[\\/])?share[\\/]kicad/i.test(uri)
    || /Program Files[\\/]+KiCad/i.test(uri)
    || /KiCad\.app[\\/]+Contents[\\/]+SharedSupport/i.test(uri)
    || /[\\/]snap[\\/]kicad[\\/]/i.test(uri)
    || /flatpak[\s\S]*org\.kicad/i.test(uri);
}

// ----------------------------------------------------------------- KiCad S-expression parsing
function unesc(s) { return String(s == null ? '' : s).replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\\/g, '\\'); }
function escStr(s) { return String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }

// Split a .kicad_sym library into [header, [topLevelSymbolBlocks]].
// Top-level symbols are those at depth 1 (directly inside (kicad_symbol_lib ...)); nested
// "units" (Name_1_1) live inside their parent block and must NOT be treated as separate symbols.
function parseSymbolLib(text) {
  const symbols = [];
  let depth = 0, i = 0, firstSymAt = -1;
  while (i < text.length) {
    const ch = text[i];
    if (ch === '"') { i = skipString(text, i); continue; }
    if (ch === '(') {
      if (depth === 1 && text.startsWith('(symbol "', i)) {
        if (firstSymAt < 0) firstSymAt = i;
        const block = balanced(text, i);
        symbols.push(block);
        i += block.length; continue;
      }
      depth++; i++; continue;
    }
    if (ch === ')') { depth--; i++; continue; }
    i++;
  }
  const header = firstSymAt >= 0 ? text.slice(0, firstSymAt).replace(/\s+$/, '') : text.replace(/\)\s*$/, '').replace(/\s+$/, '');
  return { header, symbols };
}
function skipString(text, i) { // i points at opening quote; returns index after closing quote
  i++;
  while (i < text.length) { if (text[i] === '\\') { i += 2; continue; } if (text[i] === '"') { return i + 1; } i++; }
  return i;
}
function balanced(text, start) { // start points at '('; returns the balanced substring incl. parens
  let d = 0, i = start;
  for (; i < text.length; i++) {
    const c = text[i];
    if (c === '"') { i = skipString(text, i) - 1; continue; }
    if (c === '(') d++;
    else if (c === ')') { d--; if (d === 0) { i++; break; } }
  }
  return text.slice(start, i);
}
function symName(block) { const m = block.match(/^\(symbol\s+"((?:[^"\\]|\\.)*)"/); return m ? unesc(m[1]) : ''; }
function symProps(block) {
  const out = {}; const re = /\(property\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"/g; let m;
  while ((m = re.exec(block))) { const k = unesc(m[1]); if (!(k in out)) out[k] = unesc(m[2]); }
  return out;
}
// Property names that hold the JLCPCB / LCSC part number ONLY (no generic manufacturer PNs), best first.
const MPN_KEYS = ['LCSC', 'LCSC Part', 'LCSC Part #', 'LCSC#', 'LCSC Part Number',
  'JLCPCB', 'JLCPCB Part', 'JLCPCB Part #', 'JLCPCB Part Number', 'JLC', 'JLC Part'];
function mpnOf(props) {
  for (const k of MPN_KEYS) if (props[k] && props[k].trim()) return { field: k, value: props[k].trim() };
  // fallback: any property whose name mentions LCSC/JLC, or whose value is an LCSC code C12345
  for (const [k, v] of Object.entries(props)) { const t = (v || '').trim(); if (!t) continue; if (/lcsc|jlc/i.test(k) || /^C\d{3,}$/.test(t)) return { field: k, value: t }; }
  return { field: null, value: '' };
}
// Backfill per-field "changed" flags for symbols edited before field-tracking existed: diff each
// pending-edit symbol against its backup counterpart (matched by LCSC PN, else name).
const VIS_FIELDS = ['name', 'ref', 'footprint', 'datasheet', 'mpn', 'description'];
function backfillEditedFields() {
  if (!DB.backup) return;
  const bAll = DB.backup.symbolLibs.flatMap(l => l.symbols);
  for (const lib of DB.libs.symbolLibs) for (const s of lib.symbols) {
    if (!s.pendingEdit || s.editedFields || s.pendingAdd) continue;
    const b = (s.mpn && bAll.find(x => (x.mpn || '').toUpperCase() === s.mpn.toUpperCase())) || bAll.find(x => x.name === s.name);
    const ef = {}; if (b) for (const f of VIS_FIELDS) if ((s[f] || '') !== (b[f] || '')) ef[f] = true;
    s.editedFields = ef;
  }
}
// Re-derive the LCSC/JLC PN for every loaded symbol from its raw block (used after the rule changes).
function reDeriveMpn() {
  for (const lib of DB.libs.symbolLibs) for (const s of lib.symbols) {
    const m = mpnOf(symProps(s.raw)); s.mpn = m.value; s.mpnField = m.field || s.mpnField || 'LCSC';
  }
}
function symbolFromBlock(libNick, libPath, block) {
  const p = symProps(block);
  const fp = (p.Footprint || '').trim();
  const mpn = mpnOf(p);
  const name = symName(block);
  return {
    id: libNick + '::' + name,
    lib: libNick, libPath,
    name,
    ref: (p.Reference || '').trim(),
    value: (p.Value || '').trim(),
    footprint: fp,
    fpNick: fp.includes(':') ? fp.split(':')[0] : '',
    fpName: fp.includes(':') ? fp.split(':').slice(1).join(':') : fp,
    datasheet: (p.Datasheet || '').trim(),
    mpn: mpn.value,
    mpnField: mpn.field,
    description: (p.Description || '').trim(),
    raw: block,
  };
}

// Set a property's value in a raw symbol block; insert the property if absent.
function setSymProp(block, name, value) {
  const v = escStr(value);
  const re = new RegExp('(\\(property\\s+"' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"\\s+")(?:[^"\\\\]|\\\\.)*(")');
  if (re.test(block)) return block.replace(re, '$1' + v + '$2');
  // insert right after the (symbol "name" line
  return block.replace(/(\(symbol\s+"(?:[^"\\]|\\.)*"\s*\n)/, `$1    (property "${name}" "${v}" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n`);
}
function renameSymInBlock(block, oldN, newN) {
  const o = oldN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  // Rename sub-unit symbols ("Name_1_1") FIRST, then the exact top-level name. Doing the
  // top-level first would let the `_` pattern re-match it when newN starts with oldN (e.g. the
  // "_copy" case), corrupting the name to "Name_copy_copy".
  return block
    .replace(new RegExp('\\(symbol\\s+"' + o + '_', 'g'), `(symbol "${escStr(newN)}_`)
    .replace(new RegExp('\\(symbol\\s+"' + o + '"'), `(symbol "${escStr(newN)}"`);
}

// ----------------------------------------------------------------- lib-table parsing
function parseLibTable(text) {
  const libs = []; if (!text) return libs;
  const re = /\(lib\b([\s\S]*?)\)\s*(?=\(lib\b|\)\s*$|$)/g; let m;
  while ((m = re.exec(text))) {
    const body = m[1];
    const nm = body.match(/\(name\s+"?([^")\s]+)"?\)/);
    const uriQ = body.match(/\(uri\s+"([^"]*)"\)/) || body.match(/\(uri\s+([^)\s]+)\)/);
    if (nm && uriQ) libs.push({ name: nm[1], uri: uriQ[1] });
  }
  return libs;
}

// ----------------------------------------------------------------- probe
// One generous shell pass: read both global lib-tables, then enumerate & read the user's
// PERSONAL symbol libs (full text — we need it for editing) and footprint libs (just the
// 3D-model refs), plus standalone 3D-model files. Markers delimit each record.
function probeShell() {
  return [
    'set -e 2>/dev/null || true',
    'CFG=$(ls -d "$HOME/.config/kicad"/*/ 2>/dev/null | sort | tail -1)',
    '[ -z "$CFG" ] && CFG="$HOME/.config/kicad/"',
    'for T in "$CFG/sym-lib-table" "$CFG/fp-lib-table" "$HOME/Library/Preferences/kicad"/*/sym-lib-table "$HOME/Library/Preferences/kicad"/*/fp-lib-table; do [ -f "$T" ] && { echo "@@TABLE $T"; cat "$T"; echo; echo "@@END"; }; done',
    'SEARCH="$HOME/Documents $HOME/KiCad $HOME/kicad $HOME/Projects"',
    'find $SEARCH -iname "*.kicad_sym" 2>/dev/null | grep -vi backup | grep -vi "\\.bak" | head -300 | while read f; do echo "@@SYM $f"; cat "$f"; echo; echo "@@END"; done',
    'find $SEARCH -type d -iname "*.pretty" 2>/dev/null | grep -vi backup | head -300 | while read d; do echo "@@FPDIR $d"; for m in "$d"/*.kicad_mod; do [ -f "$m" ] || continue; echo "@@FP $(basename "$m")"; grep -h "(model" "$m" 2>/dev/null | head -6; done; echo "@@END"; done',
    'for r in "$HOME/Documents/KiCAD/3D" "$HOME/Documents/KiCad/3D" "$HOME/Documents/KiCAD/3dmodels" "$HOME/Documents/KiCad/3dmodels" "$HOME/KiCad/3D" "$HOME/Documents/KiCAD/packages3d"; do [ -d "$r" ] && find "$r" -type f \\( -iname "*.step" -o -iname "*.stp" -o -iname "*.wrl" -o -iname "*.glb" -o -iname "*.stl" \\) 2>/dev/null | head -1000 | sed "s|^|@@MODEL |"; done',
  ].join('\n');
}

function parseProbeOutput(out) {
  const lines = (out || '').split('\n');
  const tables = [], symFiles = [], fpDirs = [], modelFiles = [];
  let i = 0;
  while (i < lines.length) {
    const line = lines[i];
    if (line.startsWith('@@TABLE ')) {
      const pth = line.slice(8).trim(); const buf = [];
      i++; while (i < lines.length && lines[i] !== '@@END') buf.push(lines[i++]);
      tables.push({ path: pth, text: buf.join('\n') }); i++;
    } else if (line.startsWith('@@SYM ')) {
      const pth = line.slice(6).trim(); const buf = [];
      i++; while (i < lines.length && lines[i] !== '@@END') buf.push(lines[i++]);
      symFiles.push({ path: pth, text: buf.join('\n') }); i++;
    } else if (line.startsWith('@@FPDIR ')) {
      const dir = line.slice(8).trim(); const fps = [];
      i++; let cur = null;
      while (i < lines.length && lines[i] !== '@@END') {
        const l = lines[i];
        if (l.startsWith('@@FP ')) { cur = { file: l.slice(5).trim(), models: [] }; fps.push(cur); }
        else if (cur) { const mm = l.match(/\(model\s+"?([^"\)]+)"?/); if (mm) cur.models.push(mm[1].trim()); }
        i++;
      }
      fpDirs.push({ path: dir, footprints: fps }); i++;
    } else if (line.startsWith('@@MODEL ')) {
      modelFiles.push(line.slice(8).trim()); i++;
    } else i++;
  }
  return { tables, symFiles, fpDirs, modelFiles };
}

async function probe(edas) {
  const out = await runDesktopShell(probeShell(), 180);
  const { tables, symFiles, fpDirs, modelFiles } = parseProbeOutput(out);

  // path/basename -> nickname, from the lib-tables (best source for the nick KiCad actually uses)
  const symNickByPath = {}, symNickByBase = {}, fpNickByPath = {}, fpNickByBase = {};
  const defaultFpLibs = new Set(), defaultSymLibs = new Set();  // nicks that point at KiCad stock libs
  const tablesFound = [];
  for (const t of tables) {
    tablesFound.push(t.path);
    const isFp = /fp-lib-table/.test(t.path);
    for (const lib of parseLibTable(t.text)) {
      const p = expandPath(lib.uri);
      const stock = isStockUri(lib.uri);
      if (isFp) { fpNickByPath[p] = lib.name; fpNickByBase[baseOf(p).replace(/\.pretty$/i, '')] = lib.name; if (stock) defaultFpLibs.add(lib.name); }
      else { symNickByPath[p] = lib.name; symNickByBase[baseOf(p).replace(/\.kicad_sym$/i, '')] = lib.name; if (stock) defaultSymLibs.add(lib.name); }
    }
  }

  // ---- symbol libraries
  const symbolLibs = [];
  for (const f of symFiles) {
    const base = baseOf(f.path).replace(/\.kicad_sym$/i, '');
    const nick = symNickByPath[f.path] || symNickByBase[base] || base;
    const inTable = !!(symNickByPath[f.path] || symNickByBase[base]);
    const { header, symbols } = parseSymbolLib(f.text);
    symbolLibs.push({
      nick, path: f.path, header, inTable, isDefault: defaultSymLibs.has(nick) || isStockUri(f.path),
      symbols: symbols.map(b => symbolFromBlock(nick, f.path, b)),
    });
  }

  // ---- footprint libraries
  const footprintLibs = [];
  for (const d of fpDirs) {
    const base = baseOf(d.path).replace(/\.pretty$/i, '');
    const nick = fpNickByPath[d.path] || fpNickByBase[base] || base;
    const inTable = !!(fpNickByPath[d.path] || fpNickByBase[base]);
    footprintLibs.push({
      nick, path: d.path, inTable, isDefault: defaultFpLibs.has(nick) || isStockUri(d.path),
      footprints: d.footprints.map(fp => {
        const name = fp.file.replace(/\.kicad_mod$/i, '');
        const models = (fp.models || []).map(expandPath);
        return { id: nick + '::' + name, lib: nick, libPath: d.path, name, file: fp.file, model: models[0] || '', models };
      }),
    });
  }

  // ---- 3D models: union of standalone files found + models referenced by footprints
  const modelMap = new Map(); // key -> {name, path, folder}
  const addModel = (p) => {
    if (!p) return; const ep = expandPath(p);
    const key = baseOf(ep).toLowerCase();
    if (!modelMap.has(key)) modelMap.set(key, { id: key, name: baseOf(ep), path: ep, folder: baseOf(dirOf(ep)) || '(root)' });
  };
  for (const mp of modelFiles) addModel(mp);
  for (const lib of footprintLibs) for (const fp of lib.footprints) for (const m of fp.models) addModel(m);
  const models3d = [...modelMap.values()];

  const counts = {
    symbolLibs: symbolLibs.length, symbols: symbolLibs.reduce((n, l) => n + l.symbols.length, 0),
    footprintLibs: footprintLibs.length, footprints: footprintLibs.reduce((n, l) => n + l.footprints.length, 0),
    models3d: models3d.length,
  };
  return { libs: { symbolLibs, footprintLibs, models3d, defaultFpLibs: [...defaultFpLibs] }, tablesFound, counts };
}

// ----------------------------------------------------------------- usage maps (footprint<->symbol, model<->footprint)
function recomputeUsage() {
  const allSymbols = [];
  for (const lib of DB.libs.symbolLibs) for (const s of lib.symbols) allSymbols.push(s);
  // footprint -> symbols that reference it (match by footprint NAME, prefer matching nick)
  for (const lib of DB.libs.footprintLibs) {
    for (const fp of lib.footprints) {
      const users = allSymbols.filter(s => {
        if (!s.fpName) return false;
        if (s.fpName.toLowerCase() !== fp.name.toLowerCase()) return false;
        if (s.fpNick && lib.nick) return s.fpNick.toLowerCase() === lib.nick.toLowerCase();
        return true;
      });
      fp.usedBy = users.map(s => ({ id: s.id, name: s.name, lib: s.lib, ref: s.ref, value: s.value }));
      fp.useCount = users.length;
    }
  }
  // model -> footprints that reference it (match by basename)
  const fpByModel = {};
  for (const lib of DB.libs.footprintLibs) for (const fp of lib.footprints) for (const m of fp.models) {
    const key = baseOf(m).toLowerCase(); (fpByModel[key] = fpByModel[key] || []).push({ id: fp.id, name: fp.name, lib: fp.lib });
  }
  for (const md of DB.libs.models3d) {
    const u = fpByModel[md.name.toLowerCase()] || [];
    md.usedBy = u; md.useCount = u.length;
  }
}

// ----------------------------------------------------------------- undo/redo tree
function snapNow() { return { libs: clone(DB.libs), trash: clone(DB.sync.pendingTrash || []), unreg: clone(DB.sync.pendingUnregister || []) }; }
function restoreSnap(node) { DB.libs = clone(node.snap.libs); DB.sync.pendingTrash = clone(node.snap.trash || []); DB.sync.pendingUnregister = clone(node.snap.unreg || []); }
function historyInit(label) {
  const id = uid();
  HIST = { nodes: {}, rootId: id, currentId: id };
  HIST.nodes[id] = { id, parentId: null, children: [], label: label || 'initial', ts: Date.now(), snap: snapNow() };
}
function commit(label) {
  if (!HIST.currentId) historyInit('initial');
  const parent = HIST.nodes[HIST.currentId];
  const id = uid();
  HIST.nodes[id] = { id, parentId: parent.id, children: [], label, ts: Date.now(), snap: snapNow() };
  parent.children.push(id);
  HIST.currentId = id;
  pruneHistory();
}
function pruneHistory() {
  const max = 300;
  const ids = Object.keys(HIST.nodes);
  if (ids.length <= max) return;
  // drop oldest leaf nodes that are not on the path to current
  const onPath = new Set(); let n = HIST.currentId;
  while (n) { onPath.add(n); n = HIST.nodes[n].parentId; }
  const leaves = ids.filter(id => HIST.nodes[id].children.length === 0 && !onPath.has(id))
    .sort((a, b) => HIST.nodes[a].ts - HIST.nodes[b].ts);
  for (const id of leaves) {
    if (Object.keys(HIST.nodes).length <= max) break;
    const node = HIST.nodes[id]; const par = HIST.nodes[node.parentId];
    if (par) par.children = par.children.filter(c => c !== id);
    delete HIST.nodes[id];
  }
}
function historyInfo() {
  const cur = HIST.nodes[HIST.currentId];
  const par = cur && cur.parentId ? HIST.nodes[cur.parentId] : null;
  const childId = cur && cur.children.length ? cur.children[cur.children.length - 1] : null;
  const child = childId ? HIST.nodes[childId] : null;
  return {
    canUndo: !!par, canRedo: !!child,
    undoLabel: cur ? cur.label : null,
    redoLabel: child ? child.label : null,
    nodeCount: Object.keys(HIST.nodes).length,
    currentLabel: cur ? cur.label : null,
  };
}
function undo() {
  const cur = HIST.nodes[HIST.currentId]; if (!cur || !cur.parentId) return false;
  HIST.currentId = cur.parentId; restoreSnap(HIST.nodes[HIST.currentId]);
  recomputeUsage(); save(); return true;
}
function redo() {
  const cur = HIST.nodes[HIST.currentId]; if (!cur || !cur.children.length) return false;
  const childId = cur.children[cur.children.length - 1];
  HIST.currentId = childId; restoreSnap(HIST.nodes[childId]);
  recomputeUsage(); save(); return true;
}

// ----------------------------------------------------------------- mutations
const SYM_FIELD_TO_PROP = { ref: 'Reference', value: 'Value', footprint: 'Footprint', datasheet: 'Datasheet', description: 'Description' };
function findSymbol(id) {
  for (const lib of DB.libs.symbolLibs) { const s = lib.symbols.find(x => x.id === id); if (s) return { lib, s }; }
  return null;
}
function editSymbolField(id, field, value) {
  const hit = findSymbol(id); if (!hit) throw new Error('symbol not found');
  const { lib, s } = hit;
  value = String(value == null ? '' : value);
  if (field === 'name') {
    const oldN = s.name; const newN = value.trim(); if (!newN || newN === oldN) return s;
    if (lib.symbols.some(x => x !== s && x.name === newN)) throw new Error('a symbol named "' + newN + '" already exists in ' + lib.nick);
    s.raw = renameSymInBlock(s.raw, oldN, newN);
    s.name = newN; s.id = lib.nick + '::' + newN;
  } else if (field === 'mpn') {
    const prop = s.mpnField || 'LCSC';
    s.raw = setSymProp(s.raw, prop, value); s.mpn = value.trim(); s.mpnField = prop;
  } else {
    const prop = SYM_FIELD_TO_PROP[field]; if (!prop) throw new Error('unknown field: ' + field);
    s.raw = setSymProp(s.raw, prop, value);
    s[field] = value.trim();
    if (field === 'footprint') { s.fpNick = value.includes(':') ? value.split(':')[0] : ''; s.fpName = value.includes(':') ? value.split(':').slice(1).join(':') : value.trim(); }
  }
  if (!s.pendingAdd) { s.pendingEdit = true; s.editedFields = s.editedFields || {}; s.editedFields[field] = true; }  // track which fields changed
  recomputeUsage();
  commit('edit ' + field + ' · ' + s.name);
  save(); return s;
}
// Delete = FLAG for deletion (row turns red), applied to disk on the next Sync. Toggles, so a second
// delete un-flags. An un-synced just-added item (pendingAdd/added) is dropped outright (never on disk).
const isAdded = it => !!(it.pendingAdd || it.added);
function deleteEntries(mode, ids) {
  const set = new Set(ids); let n = 0;
  if (mode === 'symbol') {
    for (const lib of DB.libs.symbolLibs) {
      const keep = [];
      for (const s of lib.symbols) {
        if (set.has(s.id)) { n++; if (isAdded(s)) continue; s.pendingDelete = !s.pendingDelete; }
        keep.push(s);
      }
      lib.symbols = keep.filter(s => !(set.has(s.id) && isAdded(s)));
    }
  } else if (mode === 'footprint') {
    for (const lib of DB.libs.footprintLibs) {
      for (const fp of lib.footprints) if (set.has(fp.id)) { n++; if (!isAdded(fp)) fp.pendingDelete = !fp.pendingDelete; }
      lib.footprints = lib.footprints.filter(fp => !(set.has(fp.id) && isAdded(fp)));
    }
  } else if (mode === 'model3d') {
    for (const md of DB.libs.models3d) if (set.has(md.id)) { n++; if (!isAdded(md)) md.pendingDelete = !md.pendingDelete; }
    DB.libs.models3d = DB.libs.models3d.filter(md => !(set.has(md.id) && isAdded(md)));
  }
  recomputeUsage();
  commit('flag ' + n + ' ' + mode + (n === 1 ? '' : 's') + ' for delete');
  save(); return n;
}
// Flag a whole library (or 3D folder group) for deletion — toggles every item + the lib itself.
function deleteLibrary(mode, nick) {
  if (mode === 'symbol' || mode === 'footprint') {
    const lib = (mode === 'symbol' ? findSymLib : findFpLib)(nick); if (!lib) throw new Error(mode + ' library not found: ' + nick);
    lib.pendingDelete = !lib.pendingDelete;
    const items = mode === 'symbol' ? lib.symbols : lib.footprints;
    for (const it of items) it.pendingDelete = lib.pendingDelete;
  } else if (mode === 'model3d') {
    const inFolder = DB.libs.models3d.filter(md => (md.folder || '(root)') === nick);
    const allFlagged = inFolder.length && inFolder.every(md => md.pendingDelete);
    for (const md of inFolder) md.pendingDelete = !allFlagged;
  } else throw new Error('bad mode: ' + mode);
  recomputeUsage();
  commit('flag library ' + nick + ' for delete');
  save(); return 1;
}

// ----------------------------------------------------------------- add / duplicate
function findSymLib(nick) { return DB.libs.symbolLibs.find(l => l.nick === nick); }
function findFpLib(nick) { return DB.libs.footprintLibs.find(l => l.nick === nick); }
function uniqueName(taken, base) { let n = base, i = 2; const set = new Set(taken); while (set.has(n)) n = base + '_' + (i++); return n; }
function blankSymbolBlock(name) {
  return `(symbol "${escStr(name)}" (in_bom yes) (on_board yes)\n` +
    `    (property "Reference" "U" (at 0 0 0) (effects (font (size 1.27 1.27))))\n` +
    `    (property "Value" "${escStr(name)}" (at 0 0 0) (effects (font (size 1.27 1.27))))\n` +
    `    (property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n` +
    `    (property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n` +
    `    (property "Description" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n  )`;
}
function blankFootprintMod(name) {
  return `(footprint "${escStr(name)}" (version 20211014) (generator kicad-library-manager) (layer "F.Cu")\n` +
    `  (attr smd)\n` +
    `  (fp_text reference "REF**" (at 0 -2) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))\n` +
    `  (fp_text value "${escStr(name)}" (at 0 2) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))\n)`;
}
function addBlankSymbol(libNick, name) {
  const lib = findSymLib(libNick); if (!lib) throw new Error('symbol library not found: ' + libNick);
  const nm = uniqueName(lib.symbols.map(s => s.name), (name || 'NewSymbol').trim());
  const s = symbolFromBlock(lib.nick, lib.path, blankSymbolBlock(nm)); s.pendingAdd = true;
  lib.symbols.push(s); recomputeUsage(); commit('add symbol · ' + nm); save(); return s;
}
// Bulk-create symbols from a single symbol-block template (e.g. KiCad's Device:R),
// renaming + setting properties per spec. Used for the "fill a library" workflows.
function bulkAddSymbols(libNick, template, specs, clear) {
  const lib = findSymLib(libNick); if (!lib) throw new Error('symbol library not found: ' + libNick);
  const baseName = symName(template) || 'R';
  if (clear) lib.symbols = [];
  let added = 0;
  for (const sp of specs) {
    if (!sp || !sp.name) continue;
    if (lib.symbols.some(s => s.name === sp.name)) continue;
    let b = renameSymInBlock(template, baseName, sp.name);
    if (sp.reference) b = setSymProp(b, 'Reference', sp.reference);
    if (sp.value != null) b = setSymProp(b, 'Value', sp.value);
    if (sp.footprint != null) b = setSymProp(b, 'Footprint', sp.footprint);
    if (sp.datasheet != null) b = setSymProp(b, 'Datasheet', sp.datasheet || '~');
    if (sp.description != null) b = setSymProp(b, 'Description', sp.description);
    if (sp.mpn) b = setSymProp(b, sp.mpnField || 'LCSC', sp.mpn);
    const ns = symbolFromBlock(lib.nick, lib.path, b); ns.pendingAdd = true;
    lib.symbols.push(ns);
    added++;
  }
  recomputeUsage(); commit(`bulk add ${added} symbols → ${libNick}`); save();
  return added;
}
function duplicateSymbol(id, name) {
  const hit = findSymbol(id); if (!hit) throw new Error('symbol not found');
  const { lib, s } = hit;
  const nm = uniqueName(lib.symbols.map(x => x.name), (name && name.trim()) || s.name + '_copy');
  let raw = renameSymInBlock(s.raw, s.name, nm);
  const dup = symbolFromBlock(lib.nick, lib.path, raw); dup.pendingAdd = true;
  const idx = lib.symbols.findIndex(x => x.id === s.id);
  lib.symbols.splice(idx + 1, 0, dup);
  recomputeUsage(); commit('duplicate · ' + s.name + ' → ' + nm); save(); return dup;
}
function addBlankFootprint(libNick, name) {
  const lib = findFpLib(libNick); if (!lib) throw new Error('footprint library not found: ' + libNick);
  const nm = uniqueName(lib.footprints.map(f => f.name), (name || 'NewFootprint').trim());
  const fp = { id: lib.nick + '::' + nm, lib: lib.nick, libPath: lib.path, name: nm, file: nm + '.kicad_mod', model: '', models: [], rawMod: blankFootprintMod(nm), added: true };
  lib.footprints.push(fp); recomputeUsage(); commit('add footprint · ' + nm); save(); return fp;
}

// ----------------------------------------------------------------- wiki-first add (chip-fetcher style)
function adompkgSearch(q) {
  return new Promise((resolve) => {
    execFile('adompkg', ['search', q], { timeout: 45000, maxBuffer: 4 << 20, env: process.env }, (err, stdout) => {
      if (err && !stdout) return resolve([]);
      const rows = []; let cur = null;
      const ENTRY = /^(\S+)\s{2,}([A-Za-z][\w-]*)\s{2,}(.*)$/;
      for (const line of (stdout || '').split('\n')) {
        const m = ENTRY.exec(line);
        if (m) { cur = { slug: m[1], type: m[2], title: m[3].trim() }; rows.push(cur); }
      }
      resolve(rows);
    });
  });
}
async function wikiJSON(p) {
  try { const r = await fetch(WIKI + p, { signal: AbortSignal.timeout(20000) }); if (!r.ok) return null; return await r.json(); } catch { return null; }
}
async function wikiBlob(slug, filename) {
  try {
    const r = await fetch(`${WIKI}/blob/component/${slug}/${encodeURIComponent(filename)}`, { signal: AbortSignal.timeout(45000) });
    if (!r.ok) return null; return Buffer.from(await r.arrayBuffer());
  } catch { return null; }
}
const norm = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
// Fetch an MPN's CAD from the Adom wiki and insert it into the chosen libraries (app model;
// written to disk on Sync). Returns a summary of what was added.
async function wikiAdd(mpn, symLibNick, fpLibNick) {
  const q = mpn.trim(); if (!q) throw new Error('MPN required');
  const want = norm(q);
  const results = await adompkgSearch(q.replace(/-/g, ' '));
  // prefer a result whose slug or title head normalizes to the MPN, else a component result
  let hit = results.find(r => norm(r.slug) === want) ||
            results.find(r => norm((r.title || '').split(/[—\-\s]/)[0]) === want) ||
            results.find(r => r.type === 'component') || results[0];
  if (!hit) throw new Error('no wiki page found for ' + q);
  const slug = hit.slug;
  const meta = await wikiJSON(`/api/v1/pages/${slug}`);
  const filesRes = await wikiJSON(`/api/v1/pages/${slug}/files`);
  const files = (filesRes && (filesRes.files || filesRes)) || [];
  const names = files.map(f => (typeof f === 'string' ? f : f.name)).filter(Boolean);
  const added = { slug, symbol: null, footprint: null, model3d: null, wikiUrl: `${WIKI}/component/${slug}` };

  // ---- symbol
  const symFile = names.find(n => /\.kicad_sym$/i.test(n));
  const fpFile = names.find(n => /\.kicad_mod$/i.test(n));
  const modelFile = names.find(n => /\.step$/i.test(n)) || names.find(n => /\.(stp|wrl|glb|stl)$/i.test(n));
  const symLib = symLibNick ? findSymLib(symLibNick) : DB.libs.symbolLibs[0];
  const fpLib = fpLibNick ? findFpLib(fpLibNick) : DB.libs.footprintLibs[0];

  let fpNameForLink = '';
  if (fpFile && fpLib) {
    const buf = await wikiBlob(slug, fpFile); if (buf) {
      const nm = uniqueName(fpLib.footprints.map(f => f.name), fpFile.replace(/\.kicad_mod$/i, ''));
      const text = buf.toString('utf8');
      const mm = text.match(/\(model\s+"?([^"\)]+)"?/); const model = mm ? expandPath(mm[1].trim()) : '';
      const fp = { id: fpLib.nick + '::' + nm, lib: fpLib.nick, libPath: fpLib.path, name: nm, file: nm + '.kicad_mod', model, models: model ? [model] : [], rawMod: text, added: true };
      fpLib.footprints.push(fp); added.footprint = { lib: fpLib.nick, name: nm }; fpNameForLink = fpLib.nick + ':' + nm;
    }
  }
  if (symFile && symLib) {
    const buf = await wikiBlob(slug, symFile); if (buf) {
      const parsed = parseSymbolLib(buf.toString('utf8'));
      const block = parsed.symbols[0];
      if (block) {
        let b = block;
        const nm0 = symName(b);
        const nm = uniqueName(symLib.symbols.map(s => s.name), nm0 || q);
        if (nm !== nm0) b = renameSymInBlock(b, nm0, nm);
        if (fpNameForLink) b = setSymProp(b, 'Footprint', fpNameForLink);
        if (meta && meta.metadata && meta.metadata.datasheet_path) b = setSymProp(b, 'Datasheet', `${WIKI}/${meta.metadata.datasheet_path}`.replace(/([^:])\/\//g, '$1/'));
        const sym = symbolFromBlock(symLib.nick, symLib.path, b); sym.pendingAdd = true;
        symLib.symbols.push(sym); added.symbol = { lib: symLib.nick, name: nm };
      }
    }
  }
  // ---- 3D model: stage the bytes now; Sync sends them to the 3D folder
  if (modelFile) {
    const buf = await wikiBlob(slug, modelFile);
    if (buf) {
      fs.mkdirSync(STAGING_DIR, { recursive: true });
      const local = path.join(STAGING_DIR, slug + '_' + modelFile.replace(/[^\w.\-]/g, '_'));
      fs.writeFileSync(local, buf);
      const folder3d = kiRoot() + '/3D';
      const target = folder3d + '/' + modelFile;
      DB.libs.models3d.push({ id: modelFile.toLowerCase(), name: modelFile, path: target, folder: '3D', added: true, _local: local });
      added.model3d = { name: modelFile };
    }
  }
  recomputeUsage();
  commit('wiki add · ' + q);
  save();
  return added;
}

// ----------------------------------------------------------------- disk sync
const TRASH_DIRNAME = '.klm-trash';
function kiRoot() {
  const sl = DB.libs.symbolLibs[0] && DB.libs.symbolLibs[0].path;
  const fl = DB.libs.footprintLibs[0] && DB.libs.footprintLibs[0].path;
  const base = sl ? dirOf(sl) : fl ? dirOf(fl) : path.join(HOME, 'Documents');
  return dirOf(base) || base;
}
function buildSymLibText(lib) {
  const header = (lib.header && lib.header.trim()) ? lib.header.trim()
    : '(kicad_symbol_lib (version 20211014) (generator kicad-library-manager)';
  // symbols flagged for deletion are written out (i.e. removed) on this sync
  return header + '\n' + lib.symbols.filter(s => !s.pendingDelete).map(s => '  ' + s.raw.trim()).join('\n') + '\n)\n';
}
// Write a single symbol library to disk, guarded. Returns a log line.
async function writeSymLib(lib) {
  const text = buildSymLibText(lib);
  // GUARD 1: never write a lib we couldn't read back (avoid clobbering on a flaky bridge).
  const cur = await adomDesktopJSON('read_file', { path: lib.path, encoding: 'utf8' });
  const exists = cur.j && cur.j.success !== false && typeof cur.j.content === 'string';
  // GUARD 2: never shrink unexpectedly — only allow fewer symbols than disk if the user deleted them
  // (we always go through deleteEntries, so a smaller count is intentional). We still keep a .bak.
  // Land the new text via a temp file + mv, with a .bak backup.
  const tmp = path.join(DATA_DIR, 'out'); fs.mkdirSync(tmp, { recursive: true });
  const local = path.join(tmp, baseOf(lib.path)); fs.writeFileSync(local, text);
  const sent = await sendFiles([local], dirOf(lib.path), baseOf(lib.path), { backup: true });
  return `${sent.ok ? 'OK' : 'FAIL'} symbol ${lib.nick} (${lib.symbols.length} symbols)${sent.ok ? '' : ' — ' + sent.error}`;
}
// send a local file to a destination dir on the user's machine; send_files ignores `dest` on
// Linux (lands in ~/Downloads), so land then mv into place. Optionally .bak the target first.
async function sendFiles(localPaths, destDir, finalName, opts = {}) {
  const r = await adomDesktopJSON('send_files', { filePaths: localPaths });
  const landed = (r.j && r.j.destinationPaths) || [];
  if (!landed.length) return { ok: false, error: (r.j && r.j.error) || 'send_files returned no paths' };
  const target = destDir + '/' + (finalName || baseOf(landed[0]));
  const bak = opts.backup ? `[ -f ${shq(target)} ] && cp -f ${shq(target)} ${shq(target + '.klm.bak')};` : '';
  const out = await runDesktopShell(`mkdir -p ${shq(destDir)}; ${bak} mv -f ${shq(landed[0])} ${shq(target)}; echo MOVED`, 60);
  return { ok: /MOVED/.test(out), target };
}
async function moveToTrash(items) {
  if (!items.length) return [];
  const trashRoot = kiRoot() + '/' + TRASH_DIRNAME;
  const cmds = [`mkdir -p ${shq(trashRoot)}`]; const done = [];
  for (const it of items) {
    const dest = trashRoot + '/' + baseOf(it.path);
    cmds.push(`[ -e ${shq(it.path)} ] && mv -f ${shq(it.path)} ${shq(dest)} && echo "TRASHED ${it.path}"`);  // -e: handles files AND .pretty dirs
    done.push({ ...it, trashed: dest });
  }
  await runDesktopShell(cmds.join('\n'), 90);
  return done;
}
// Remove deleted libraries' entries from the KiCad lib-tables (sym-lib-table / fp-lib-table).
// KiCad writes one `(lib (name "X") …)` per line, so a fixed-string line filter is safe; we
// keep a .klm.bak of each table so "stop & undo all" can restore it.
async function unregisterLibs(items) {
  const out = { removed: [], tables: [] };
  if (!items || !items.length) return out;
  const tables = (DB.probe && DB.probe.tablesFound) || [];
  const symT = tables.find(t => /sym-lib-table/.test(t));
  const fpT = tables.find(t => /fp-lib-table/.test(t));
  const groups = { sym: [], fp: [] };
  for (const it of items) if (groups[it.table]) groups[it.table].push(it.nick);
  for (const [k, tp] of [['sym', symT], ['fp', fpT]]) {
    const nicks = [...new Set(groups[k])]; if (!nicks.length || !tp) continue;
    const pats = nicks.map(n => `-e ${shq('(name "' + n + '")')}`).join(' ');
    const cmd = `T=${shq(tp)}; [ -f "$T" ] || exit 0; cp -f "$T" "$T.klm.bak"; grep -Fv ${pats} "$T" > "$T.klm.tmp" && mv "$T.klm.tmp" "$T" && echo OK`;
    const res = await runDesktopShell(cmd, 60);
    if (/OK/.test(res)) { out.removed.push(...nicks); out.tables.push(tp); }
  }
  return out;
}
// Remove lib-table entries whose path no longer exists (e.g. a library file the user deleted), so
// KiCad stops erroring on launch. Leaves KiCad stock/project entries (${KICAD*}, ${KIPRJMOD}) alone —
// those resolve via KiCad's own env. Runs on every sync. Backs up each table to .klm.bak.
let DESKTOP_HOME = null;
async function desktopHome() {
  if (DESKTOP_HOME) return DESKTOP_HOME;
  const o = await runDesktopShell('printf %s "$HOME"', 15);
  DESKTOP_HOME = (o || '').trim() || HOME; return DESKTOP_HOME;
}
function expandDesktop(p, home) {
  return (p || '').replace(/^~(?=[\\/]|$)/, home).replace(/\$\{HOME\}/g, home).replace(/\$HOME\b/g, home).replace(/\$\{USERPROFILE\}/g, home);
}
async function pruneDeadLibTableEntries() {
  const tables = (DB.probe && DB.probe.tablesFound) || [];
  const result = { removed: [], tables: [] };
  if (!tables.length) return result;
  const home = await desktopHome();
  for (const tp of tables) {
    const text = await runDesktopShell(`[ -f ${shq(tp)} ] && cat ${shq(tp)} || echo __KLM_NOFILE__`, 30);
    if (!text || text.includes('__KLM_NOFILE__')) continue;
    const checks = [];   // concrete-path entries we can verify (skip ${KICAD*}/${KIPRJMOD}/${KISYS*})
    for (const lib of parseLibTable(text)) {
      if (/\$\{KICAD|\$\{KIPRJMOD\}|\$\{KISYS/i.test(lib.uri)) continue;
      const pth = expandDesktop(lib.uri, home);
      if (/\$\{/.test(pth)) continue;   // unknown custom var — leave it alone
      checks.push({ name: lib.name, path: pth });
    }
    if (!checks.length) continue;
    const probe = checks.map(c => `[ -e ${shq(c.path)} ] || echo ${shq('MISS::' + c.name)}`).join('; ');
    const out = await runDesktopShell(probe, 40);
    const dead = [...new Set(out.split('\n').map(l => l.trim()).filter(l => l.startsWith('MISS::')).map(l => l.slice(6)))];
    if (!dead.length) continue;
    const pats = dead.map(n => `-e ${shq('(name "' + n + '")')}`).join(' ');
    const cmd = `T=${shq(tp)}; cp -f "$T" "$T.klm.bak"; grep -Fv ${pats} "$T" > "$T.klm.tmp" && mv "$T.klm.tmp" "$T" && echo OK`;
    const res = await runDesktopShell(cmd, 40);
    if (/OK/.test(res)) { for (const n of dead) result.removed.push({ table: baseOf(tp), name: n }); result.tables.push(tp); }
  }
  return result;
}
async function restoreTrash() {
  // move everything in the trash back to its sibling .pretty / model dir (best-effort: by basename mapping).
  const trashRoot = kiRoot() + '/' + TRASH_DIRNAME;
  const restored = [];
  for (const it of (DB.sync.trashed || [])) {
    await runDesktopShell(`[ -e ${shq(it.trashed)} ] && mkdir -p ${shq(dirOf(it.path))} && mv -f ${shq(it.trashed)} ${shq(it.path)} && echo "RESTORED ${it.path}"`, 60);
    restored.push(it.path);
  }
  await runDesktopShell(`rmdir ${shq(trashRoot)} 2>/dev/null || true`, 30);
  return restored;
}

// After a sync writes to disk: drop the items/libs that were flagged for deletion and clear the
// pending-add (green) flags on everything that was just written.
function pruneAfterSync() {
  DB.libs.symbolLibs = DB.libs.symbolLibs.filter(l => !l.pendingDelete);
  for (const l of DB.libs.symbolLibs) { l.symbols = l.symbols.filter(s => !s.pendingDelete); for (const s of l.symbols) { delete s.pendingAdd; delete s.pendingEdit; delete s.editedFields; } }
  DB.libs.footprintLibs = DB.libs.footprintLibs.filter(l => !l.pendingDelete);
  for (const l of DB.libs.footprintLibs) { l.footprints = l.footprints.filter(f => !f.pendingDelete); for (const f of l.footprints) f.added = false; }
  DB.libs.models3d = DB.libs.models3d.filter(m => !m.pendingDelete); for (const m of DB.libs.models3d) m.added = false;
  recomputeUsage();
}
async function runSync() {
  if (DB.sync.status === 'running') return DB.sync;   // already in progress
  DB.sync = { ...DB.sync, active: true, status: 'running', log: [], error: null }; save();
  const log = [];
  try {
    // 1) write every symbol library (edits, additions, and removing pendingDelete symbols). Skip libs
    //    flagged for deletion entirely — their file is trashed below.
    for (const lib of DB.libs.symbolLibs) {
      if (!lib.path || lib.pendingDelete) continue;
      try { log.push(await writeSymLib(lib)); } catch (e) { log.push('FAIL symbol ' + lib.nick + ' — ' + e.message); }
    }
    // 2) write any newly-added footprints (.kicad_mod) into their .pretty (skip libs being deleted)
    let newFp = 0;
    for (const lib of DB.libs.footprintLibs) {
      if (lib.pendingDelete) continue;
      log.push(...await writeAddedFootprints(lib)); newFp += lib.footprints.filter(f => !f.added && f.rawMod).length;
    }
    // 3) send any staged 3D models (from wiki-add) into the 3D folder
    let new3d = 0;
    for (const md of DB.libs.models3d) {
      if (!md.added || !md._local || md.pendingDelete) continue;
      try { const sent = await sendFiles([md._local], dirOf(md.path), baseOf(md.path), { backup: true }); if (sent.ok) { md.added = false; new3d++; } else log.push('FAIL 3D ' + md.name + ' — ' + sent.error); }
      catch (e) { log.push('FAIL 3D ' + md.name + ' — ' + e.message); }
    }
    if (new3d) log.push(`Wrote ${new3d} new 3D model(s)`);
    // 4) apply deletions: move flagged libs/footprints/models to the reversible trash dir
    const trash = [];
    for (const lib of DB.libs.symbolLibs) if (lib.pendingDelete && lib.path) trash.push({ path: lib.path });
    for (const lib of DB.libs.footprintLibs) {
      if (lib.pendingDelete && lib.path) trash.push({ path: lib.path });
      else for (const fp of lib.footprints) if (fp.pendingDelete) trash.push({ path: lib.path + '/' + fp.file });
    }
    for (const md of DB.libs.models3d) if (md.pendingDelete && md.path) trash.push({ path: md.path });
    const trashed = await moveToTrash(trash);
    if (trashed.length) log.push(`Moved ${trashed.length} deleted file(s) to ${TRASH_DIRNAME}/`);
    // 5) unregister whole deleted libraries from the KiCad lib-tables
    const unregItems = [];
    for (const lib of DB.libs.symbolLibs) if (lib.pendingDelete) unregItems.push({ nick: lib.nick, table: 'sym' });
    for (const lib of DB.libs.footprintLibs) if (lib.pendingDelete) unregItems.push({ nick: lib.nick, table: 'fp' });
    const unreg = await unregisterLibs(unregItems);
    if (unreg.removed.length) log.push(`Unregistered ${unreg.removed.length} library(ies) from the lib-table(s)`);
    // 5b) clean any lib-table entries whose path no longer exists (prevents KiCad launch errors)
    const dead = await pruneDeadLibTableEntries();
    if (dead.removed.length) log.push(`Removed ${dead.removed.length} dead lib-table entry(ies): ${dead.removed.map(d => d.name).join(', ')}`);
    // 6) prune the model: drop the now-deleted items, clear the pending-add flags
    pruneAfterSync();
    // one-shot: reset to idle so the button is immediately ready to sync again
    DB.sync = { active: false, status: 'synced', lastSyncedAt: new Date().toISOString(), log, error: null, trashed, pendingTrash: [], pendingUnregister: [], unregisteredTables: unreg.tables };
    save();
  } catch (e) {
    DB.sync = { ...DB.sync, active: true, status: 'error', error: String(e && e.message || e), log }; save();
  }
  return DB.sync;
}
// Stop syncing: undo all on-disk changes — restore each symbol lib from the initial backup and
// move trashed files back. The app's working model is left as-is (use Revert to also reset it).
async function stopSync() {
  DB.sync.status = 'reverting'; save();
  const log = [];
  const backup = DB.backup;
  if (backup) {
    for (const lib of backup.symbolLibs) {
      if (!lib.path) continue;
      try {
        const text = buildSymLibText(lib);
        const tmp = path.join(DATA_DIR, 'out'); fs.mkdirSync(tmp, { recursive: true });
        const local = path.join(tmp, baseOf(lib.path)); fs.writeFileSync(local, text);
        const sent = await sendFiles([local], dirOf(lib.path), baseOf(lib.path), { backup: false });
        log.push(`${sent.ok ? 'RESTORED' : 'FAIL'} symbol ${lib.nick}`);
      } catch (e) { log.push('FAIL restore ' + lib.nick + ' — ' + e.message); }
    }
  }
  const restored = await restoreTrash();
  if (restored.length) log.push(`Restored ${restored.length} file(s) from ${TRASH_DIRNAME}/`);
  // restore any lib-tables we edited (re-register the libraries we'd unregistered)
  for (const tp of (DB.sync.unregisteredTables || [])) {
    await runDesktopShell(`[ -f ${shq(tp + '.klm.bak')} ] && mv -f ${shq(tp + '.klm.bak')} ${shq(tp)} && echo RT`, 30);
    log.push('Restored lib-table ' + baseOf(tp));
  }
  DB.sync = { active: false, status: 'idle', lastSyncedAt: DB.sync.lastSyncedAt, log, error: null, trashed: [], pendingTrash: DB.sync.pendingTrash || [], pendingUnregister: DB.sync.pendingUnregister || [], unregisteredTables: [] };
  save();
  return DB.sync;
}

// Write the newly-added footprints of one footprint lib to disk. Returns log lines.
async function writeAddedFootprints(lib) {
  const log = []; let n = 0;
  for (const fp of lib.footprints) {
    if (!fp.added || !fp.rawMod) continue;
    try {
      const tmp = path.join(DATA_DIR, 'out'); fs.mkdirSync(tmp, { recursive: true });
      const local = path.join(tmp, fp.file); fs.writeFileSync(local, fp.rawMod);
      const sent = await sendFiles([local], lib.path, fp.file, { backup: true });
      if (sent.ok) { fp.added = false; n++; } else log.push('FAIL footprint ' + fp.name + ' — ' + sent.error);
    } catch (e) { log.push('FAIL footprint ' + fp.name + ' — ' + e.message); }
  }
  if (n) log.push(`wrote ${n} new footprint(s)`);
  return log;
}
// Sync exactly ONE library (symbol lib / footprint .pretty / 3D folder) to disk. A targeted,
// one-shot write — it does NOT flip the global sync.active toggle (that's "Sync all").
async function syncOneLibrary(mode, nick) {
  const log = []; const addTrash = async items => { const tr = await moveToTrash(items); DB.sync.trashed = (DB.sync.trashed || []).concat(tr); return tr; };
  if (mode === 'symbol') {
    const lib = findSymLib(nick); if (!lib) throw new Error('symbol library not found: ' + nick);
    if (lib.pendingDelete) {
      if (lib.path) await addTrash([{ path: lib.path }]);
      await unregisterLibs([{ nick: lib.nick, table: 'sym' }]);
      DB.libs.symbolLibs = DB.libs.symbolLibs.filter(l => l !== lib);
      log.push('deleted symbol library ' + nick);
    } else {
      log.push(await writeSymLib(lib));
      lib.symbols = lib.symbols.filter(s => !s.pendingDelete); for (const s of lib.symbols) { delete s.pendingAdd; delete s.pendingEdit; delete s.editedFields; }
    }
  } else if (mode === 'footprint') {
    const lib = findFpLib(nick); if (!lib) throw new Error('footprint library not found: ' + nick);
    if (lib.pendingDelete) {
      if (lib.path) await addTrash([{ path: lib.path }]);
      await unregisterLibs([{ nick: lib.nick, table: 'fp' }]);
      DB.libs.footprintLibs = DB.libs.footprintLibs.filter(l => l !== lib);
      log.push('deleted footprint library ' + nick);
    } else {
      log.push(...await writeAddedFootprints(lib));
      const del = lib.footprints.filter(f => f.pendingDelete).map(f => ({ path: lib.path + '/' + f.file }));
      if (del.length) { const tr = await addTrash(del); log.push(`trashed ${tr.length} footprint(s)`); }
      lib.footprints = lib.footprints.filter(f => !f.pendingDelete); for (const f of lib.footprints) f.added = false;
    }
  } else if (mode === 'model3d') {
    let n = 0; const del = [];
    for (const md of DB.libs.models3d) {
      if ((md.folder || '(root)') !== nick) continue;
      if (md.added && md._local) { const sent = await sendFiles([md._local], dirOf(md.path), baseOf(md.path), { backup: true }); if (sent.ok) { md.added = false; n++; } }
      if (md.pendingDelete && md.path) del.push({ path: md.path });
    }
    if (del.length) await addTrash(del);
    DB.libs.models3d = DB.libs.models3d.filter(md => !((md.folder || '(root)') === nick && md.pendingDelete));
    log.push(`wrote ${n} model(s), removed ${del.length}`);
  } else throw new Error('bad mode: ' + mode);
  // every push also cleans dead lib-table entries (paths that no longer exist)
  const dead = await pruneDeadLibTableEntries();
  if (dead.removed.length) log.push(`Removed ${dead.removed.length} dead lib-table entry(ies): ${dead.removed.map(d => d.name).join(', ')}`);
  recomputeUsage();
  DB.sync.lastSyncedAt = new Date().toISOString(); DB.sync.log = log; save();
  return log;
}

// ----------------------------------------------------------------- preview rendering (kicad-cli on the desktop)
const PREVIEW_CACHE = new Map();
function previewLocalDir() { const d = path.join(DATA_DIR, 'preview'); fs.mkdirSync(d, { recursive: true }); return d; }
async function landFile(localPath) {
  const r = await adomDesktopJSON('send_files', { filePaths: [localPath] });
  const landed = (r.j && r.j.destinationPaths) || [];
  return landed[0] || null;
}
// KiCad symbol SVGs are drawn for a LIGHT background (black text on a light-yellow body, dark-red
// outlines, an opaque page-fill rect). Remap the whole theme palette to one clean dark-friendly
// scheme so every symbol looks consistent: light-gray body/graphics, teal pins, muted field text,
// and transparent fills (so our gridded canvas shows through). Unknown colours pass through.
const SYM_STROKE_MAP = { '000000': 'c9d1d9', '840000': 'c9d1d9', 'a90000': '8b949e', '006464': '2bd9d0', 'ff8c8c': 'c9d1d9' };
const SYM_FILL_MAP = { '000000': 'none', 'ffffc2': 'none', 'ffffff': 'none', '840000': 'c9d1d9', 'a90000': '8b949e', '006464': '2bd9d0' };
function themeSymbolSvg(svg) {
  svg = svg.replace(/stroke:#([0-9a-fA-F]{6})/g, (m, h) => { const v = SYM_STROKE_MAP[h.toLowerCase()]; return 'stroke:' + (v ? '#' + v : '#' + h); });
  svg = svg.replace(/fill:#([0-9a-fA-F]{6})/g, (m, h) => { const v = SYM_FILL_MAP[h.toLowerCase()]; return 'fill:' + (v === 'none' ? 'none' : v ? '#' + v : '#' + h); });
  return svg;
}
async function renderSymbolSvg(name, libText) {
  const key = 'sym:' + crypto.createHash('sha1').update(name + '\n' + libText).digest('hex');
  if (PREVIEW_CACHE.has(key)) return PREVIEW_CACHE.get(key);
  const safe = (name.replace(/[^\w.\-]/g, '_') || 'sym');
  const local = path.join(previewLocalDir(), safe + '.kicad_sym'); fs.writeFileSync(local, libText);
  const landed = await landFile(local); if (!landed) throw new Error('failed to send symbol lib to desktop');
  const cmd = `set -e; D="$HOME/.klm-preview"; mkdir -p "$D/lib" "$D/symout"; rm -f "$D/symout/"*.svg; mv -f ${shq(landed)} "$D/lib/${safe}.kicad_sym"; kicad-cli sym export svg --symbol ${shq(name)} -o "$D/symout" "$D/lib/${safe}.kicad_sym" >/dev/null 2>&1 || true; cat "$D/symout/"*.svg 2>/dev/null`;
  const svg = await runDesktopShell(cmd, 60);
  const out = /<svg/i.test(svg) ? themeSymbolSvg(svg) : '';
  PREVIEW_CACHE.set(key, out); return out;
}
async function renderFootprintSvg(prettyPath, fpName, rawMod) {
  const key = 'fp:' + crypto.createHash('sha1').update((prettyPath || '') + '|' + fpName + '|' + (rawMod || '')).digest('hex');
  if (PREVIEW_CACHE.has(key)) return PREVIEW_CACHE.get(key);
  const safe = (fpName.replace(/[^\w.\-]/g, '_') || 'fp');
  // Plot bottom→top: courtyard (lowest) → fab → silkscreen → paste/mask → copper pads (on top).
  // kicad-cli draws layers in the order given, so this puts courtyard under silk and pads above the gray lines.
  const LAYERS = 'F.CrtYd,F.Fab,F.SilkS,F.Paste,F.Mask,F.Cu';
  let cmd;
  if (rawMod) {
    const local = path.join(previewLocalDir(), safe + '.kicad_mod'); fs.writeFileSync(local, rawMod);
    const landed = await landFile(local); if (!landed) throw new Error('send failed');
    cmd = `set -e; D="$HOME/.klm-preview"; mkdir -p "$D/fp.pretty" "$D/fpout"; rm -f "$D/fpout/"*.svg "$D/fp.pretty/"*.kicad_mod; mv -f ${shq(landed)} "$D/fp.pretty/${safe}.kicad_mod"; kicad-cli fp export svg --layers ${LAYERS} --footprint ${shq(fpName)} -o "$D/fpout" "$D/fp.pretty" >/dev/null 2>&1 || true; cat "$D/fpout/"*.svg 2>/dev/null`;
  } else {
    if (!prettyPath) return '';
    cmd = `set -e; D="$HOME/.klm-preview"; mkdir -p "$D/fpout"; rm -f "$D/fpout/"*.svg; kicad-cli fp export svg --layers ${LAYERS} --footprint ${shq(fpName)} -o "$D/fpout" ${shq(prettyPath)} >/dev/null 2>&1 || true; cat "$D/fpout/"*.svg 2>/dev/null`;
  }
  const svg = await runDesktopShell(cmd, 60);
  const out = /<svg/i.test(svg) ? liftFootprintText(svg) : '';
  PREVIEW_CACHE.set(key, out); return out;
}
// kicad-cli draws pad-number / reference / value text (<g class="stroked-text">) as part of its layer,
// so with pads plotted on top the numbers get hidden. Move every text group above everything else,
// preserving the stroke colour it inherited from its layer group, so pad numbers sit ON the pads.
function liftFootprintText(svg) {
  const lifted = []; let out = ''; let i = 0; const styleStack = [];
  while (i < svg.length) {
    if (svg.startsWith('<g', i) && /^[\s>]/.test(svg[i + 2] || '')) {
      const end = svg.indexOf('>', i) + 1; const tag = svg.slice(i, end);
      if (/class="stroked-text"/.test(tag)) {
        let depth = 0; const re = /<g\b[^>]*>|<\/g>/g; re.lastIndex = i; let m, j = i;
        while ((m = re.exec(svg))) { if (m[0] === '</g>') { depth--; if (depth === 0) { j = re.lastIndex; break; } } else depth++; }
        const inner = svg.slice(i, j);
        const parentStyle = styleStack[styleStack.length - 1] || 'fill:none;stroke:#AFAFAF;stroke-width:0.12;stroke-linecap:round;stroke-linejoin:round';
        lifted.push(`<g style="${parentStyle}">${inner}</g>`);
        i = j; continue;
      }
      const sm = tag.match(/style="([^"]*)"/); styleStack.push(sm ? sm[1] : ''); out += tag; i = end; continue;
    }
    if (svg.startsWith('</g>', i)) { styleStack.pop(); out += '</g>'; i += 4; continue; }
    out += svg[i]; i++;
  }
  if (!lifted.length) return out;
  const idx = out.lastIndexOf('</g>');
  return idx < 0 ? out : out.slice(0, idx) + lifted.join('\n') + out.slice(idx);
}
// resolve a footprint nick to its rendered SVG: loaded lib (incl. unsynced added) or a KiCad stock lib
async function renderFootprintForNick(nick, fpName) {
  const lib = findFpLib(nick);
  if (lib && lib.path) {
    const fp = lib.footprints.find(f => f.name === fpName);
    if (fp && fp.added && fp.rawMod) return renderFootprintSvg(null, fpName, fp.rawMod);
    return renderFootprintSvg(lib.path, fpName, null);
  }
  if (nick) return renderFootprintSvg('/usr/share/kicad/footprints/' + nick + '.pretty', fpName, null);
  return '';
}
function symLibTextFor(s) { return `(kicad_symbol_lib (version 20211014) (generator kicad-library-manager)\n  ${s.raw.trim()}\n)\n`; }

// ---- 3D model preview: resolve a STEP path, pull it from the desktop, convert to GLB via step2glb, cache.
function expandModelPath(p) {
  return expandPath((p || '').replace(/\$\{KICAD\d*_3DMODEL_DIR\}/gi, '/usr/share/kicad/3dmodels').replace(/\$\{KIPRJMOD\}/gi, ''));
}
async function footprintStepPath(lib, fp) {
  let text;
  if (fp.added && fp.rawMod) text = fp.rawMod;
  else text = await runDesktopShell(`cat ${shq(lib.path + '/' + fp.file)} 2>/dev/null`, 40);
  const m = (text || '').match(/\(model\s+"?([^"\)]+)"?/); if (!m) return null;
  let mp = expandModelPath(m[1].trim());
  if (/\.wrl$/i.test(mp)) mp = mp.replace(/\.wrl$/i, '.step');   // step2glb needs STEP, not VRML
  return mp;
}
function glbDir() { const d = path.join(DATA_DIR, 'glb'); fs.mkdirSync(d, { recursive: true }); return d; }
async function glbForStepPath(stepPath) {
  const key = crypto.createHash('sha1').update('step:' + stepPath).digest('hex');
  const glbLocal = path.join(glbDir(), key + '.glb');
  if (fs.existsSync(glbLocal)) return glbLocal;
  const stepText = await runDesktopShell(`[ -f ${shq(stepPath)} ] && cat ${shq(stepPath)} || echo __KLM_NOFILE__`, 120);
  if (!stepText || stepText.includes('__KLM_NOFILE__') || stepText.length < 20) return null;
  const stepLocal = path.join(glbDir(), key + '.step'); fs.writeFileSync(stepLocal, stepText);
  await new Promise((resolve, reject) => execFile('step2glb', ['convert', stepLocal, '-o', glbLocal],
    { timeout: 180000, maxBuffer: 64 << 20, env: process.env }, (e, so, se) => e ? reject(new Error('step2glb: ' + (se || e.message))) : resolve()));
  return fs.existsSync(glbLocal) ? glbLocal : null;
}
async function pullGlb(glbPath) {   // GLB is binary → base64 transfer
  const key = crypto.createHash('sha1').update('glb:' + glbPath).digest('hex');
  const local = path.join(glbDir(), key + '.glb');
  if (fs.existsSync(local)) return local;
  const b64 = await runDesktopShell(`[ -f ${shq(glbPath)} ] && base64 -w0 ${shq(glbPath)} || echo __KLM_NOFILE__`, 120);
  if (!b64 || b64.includes('__KLM_NOFILE__')) return null;
  fs.writeFileSync(local, Buffer.from(b64.trim(), 'base64'));
  return local;
}
async function resolveGlb({ fpId, modelId }) {
  if (fpId) {
    let fp = null, lib = null;
    for (const L of DB.libs.footprintLibs) { const f = L.footprints.find(x => x.id === fpId); if (f) { fp = f; lib = L; break; } }
    if (!fp) return null;
    const sp = await footprintStepPath(lib, fp); return sp ? glbForStepPath(sp) : null;
  }
  if (modelId) {
    const md = DB.libs.models3d.find(m => m.id === modelId); if (!md) return null;
    const pth = md._local || md.path || '';
    if (/\.glb$/i.test(pth)) return md._local ? md._local : pullGlb(pth);
    let sp = /\.wrl$/i.test(pth) ? pth.replace(/\.wrl$/i, '.step') : pth;
    return /\.(step|stp)$/i.test(sp) ? glbForStepPath(sp) : null;
  }
  return null;
}
async function symbolOnDisk(libPath, name) {
  const out = await runDesktopShell(`grep -F ${shq('(symbol "' + name + '"')} ${shq(libPath)} >/dev/null 2>&1 && echo YES || echo NO`, 30);
  return /YES/.test(out);
}

// ----------------------------------------------------------------- HTTP helpers
function sendJSON(res, code, obj) {
  const b = Buffer.from(JSON.stringify(obj));
  res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': b.length });
  res.end(b);
}
function readBody(req, limit = 64 << 20) {
  return new Promise((resolve, reject) => {
    const chunks = []; let n = 0;
    req.on('data', d => { n += d.length; if (n > limit) { reject(new Error('too large')); req.destroy(); } else chunks.push(d); });
    req.on('end', () => resolve(Buffer.concat(chunks)));
    req.on('error', reject);
  });
}
async function readJSON(req) { const b = await readBody(req); return b.length ? JSON.parse(b.toString('utf8')) : {}; }
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png' };
function serveStatic(res, urlPath) {
  let rel = urlPath === '/' ? '/index.html' : urlPath;
  const fp = path.join(PUBLIC_DIR, path.normalize(rel).replace(/^(\.\.[/\\])+/, ''));
  if (!fp.startsWith(PUBLIC_DIR) || !fs.existsSync(fp) || !fs.statSync(fp).isFile()) { res.writeHead(404); return res.end('not found'); }
  res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
  res.end(fs.readFileSync(fp));
}

// Sanitized state for the SPA (omit the heavy backup snapshot; include history/sync flags).
function stateView() {
  return {
    libs: DB.libs,
    probe: DB.probe,
    sync: { active: DB.sync.active, status: DB.sync.status, lastSyncedAt: DB.sync.lastSyncedAt, log: DB.sync.log, error: DB.sync.error,
            pendingTrash: (DB.sync.pendingTrash || []).length },
    history: historyInfo(),
    hasBackup: !!DB.backup,
  };
}

// ----------------------------------------------------------------- routes
const server = http.createServer(async (req, res) => {
  const u = new URL(req.url, 'http://localhost');
  const p = u.pathname;
  try {
    if (!p.startsWith('/api/')) return serveStatic(res, p);

    if (p === '/api/state' && req.method === 'GET') return sendJSON(res, 200, stateView());

    if (p === '/api/probe-request' && req.method === 'POST') {
      const { edas } = await readJSON(req);
      const sel = (edas && edas.length) ? edas : ['kicad'];
      DB.probe = { ...DB.probe, status: 'probing', edas: sel, error: null }; save();
      probe(sel).then(({ libs, tablesFound, counts }) => {
        DB.libs = libs;
        DB.backup = clone(libs);               // always keep the initial state for revert
        DB.sync = { active: false, status: 'idle', lastSyncedAt: null, log: [], error: null, trashed: [], pendingTrash: [], pendingUnregister: [], unregisteredTables: [] };
        recomputeUsage();
        historyInit('probe');
        DB.probe = { status: 'done', probedAt: new Date().toISOString(), edas: sel, tablesFound, counts, error: null };
        save();
      }).catch(e => { DB.probe = { ...DB.probe, status: 'error', error: String(e && e.message || e) }; save(); });
      return sendJSON(res, 200, { status: 'probing' });
    }

    let m;
    if ((m = p.match(/^\/api\/symbol\/(.+)\/field$/)) && req.method === 'POST') {
      const id = decodeURIComponent(m[1]);
      const { field, value } = await readJSON(req);
      try { const s = editSymbolField(id, field, value); return sendJSON(res, 200, { ok: true, symbol: s, history: historyInfo() }); }
      catch (e) { return sendJSON(res, 400, { error: e.message }); }
    }

    if (p === '/api/add-symbol' && req.method === 'POST') {
      const { lib, name } = await readJSON(req);
      try { const s = addBlankSymbol(lib, name); return sendJSON(res, 200, { ok: true, symbol: s, history: historyInfo() }); }
      catch (e) { return sendJSON(res, 400, { error: e.message }); }
    }
    if (p === '/api/add-footprint' && req.method === 'POST') {
      const { lib, name } = await readJSON(req);
      try { const f = addBlankFootprint(lib, name); return sendJSON(res, 200, { ok: true, footprint: f, history: historyInfo() }); }
      catch (e) { return sendJSON(res, 400, { error: e.message }); }
    }
    if (p === '/api/bulk-add-symbols' && req.method === 'POST') {
      const { lib, template, symbols, clear } = await readJSON(req);
      if (!template || !Array.isArray(symbols)) return sendJSON(res, 400, { error: 'template and symbols[] required' });
      try { const added = bulkAddSymbols(lib, template, symbols, !!clear); return sendJSON(res, 200, { ok: true, added, history: historyInfo() }); }
      catch (e) { return sendJSON(res, 400, { error: e.message }); }
    }
    if (p === '/api/duplicate-symbol' && req.method === 'POST') {
      const { id, name } = await readJSON(req);
      try { const s = duplicateSymbol(id, name); return sendJSON(res, 200, { ok: true, symbol: s, history: historyInfo() }); }
      catch (e) { return sendJSON(res, 400, { error: e.message }); }
    }
    if (p === '/api/wiki-add' && req.method === 'POST') {
      const { mpn, symLib, fpLib } = await readJSON(req);
      try { const added = await wikiAdd(mpn || '', symLib, fpLib); return sendJSON(res, 200, { ok: true, added, history: historyInfo() }); }
      catch (e) { return sendJSON(res, 400, { error: e.message }); }
    }

    if (p === '/api/delete' && req.method === 'POST') {
      const { mode, ids } = await readJSON(req);
      if (!mode || !Array.isArray(ids) || !ids.length) return sendJSON(res, 400, { error: 'mode and ids required' });
      const removed = deleteEntries(mode, ids);
      return sendJSON(res, 200, { ok: true, removed, history: historyInfo() });
    }
    if (p === '/api/delete-library' && req.method === 'POST') {
      const { mode, nick } = await readJSON(req);
      if (!mode || !nick) return sendJSON(res, 400, { error: 'mode and nick required' });
      try { const removed = deleteLibrary(mode, nick); return sendJSON(res, 200, { ok: true, removed, history: historyInfo() }); }
      catch (e) { return sendJSON(res, 400, { error: e.message }); }
    }

    if (p === '/api/undo' && req.method === 'POST') { const ok = undo(); return sendJSON(res, 200, { ok, history: historyInfo() }); }
    if (p === '/api/redo' && req.method === 'POST') { const ok = redo(); return sendJSON(res, 200, { ok, history: historyInfo() }); }

    if (p === '/api/revert' && req.method === 'POST') {
      if (!DB.backup) return sendJSON(res, 400, { error: 'no backup yet — probe first' });
      DB.libs = clone(DB.backup); recomputeUsage();
      DB.sync.pendingTrash = [];
      commit('revert to backup');
      return sendJSON(res, 200, { ok: true, history: historyInfo() });
    }

    if (p === '/api/sync' && req.method === 'POST') {
      runSync().catch(e => { DB.sync = { ...DB.sync, status: 'error', error: String(e && e.message || e) }; save(); });
      return sendJSON(res, 200, { ok: true, sync: DB.sync });
    }
    // ---- preview: render a symbol + its linked footprint (or a footprint + a using symbol) to SVG
    if (p === '/api/preview/pair' && req.method === 'GET') {
      const symId = u.searchParams.get('symbolId'), fpId = u.searchParams.get('footprintId');
      const result = { left: null, right: null };
      try {
        if (symId) {
          const hit = findSymbol(symId); if (!hit) return sendJSON(res, 404, { error: 'symbol not found' });
          const s = hit.s;
          result.left = { kind: 'symbol', name: s.name, id: s.id, svg: await renderSymbolSvg(s.name, symLibTextFor(s)) };
          if (s.fpName) {
            const svg = await renderFootprintForNick(s.fpNick || '', s.fpName);
            result.right = { kind: 'footprint', name: s.footprint || s.fpName, svg, found: !!svg };
          } else result.right = { kind: 'footprint', name: '', svg: '', found: false, reason: 'no linked footprint' };
        } else if (fpId) {
          let fp = null, lib = null;
          for (const L of DB.libs.footprintLibs) { const f = L.footprints.find(x => x.id === fpId); if (f) { fp = f; lib = L; break; } }
          if (!fp) return sendJSON(res, 404, { error: 'footprint not found' });
          const svg = fp.added ? await renderFootprintSvg(null, fp.name, fp.rawMod) : await renderFootprintSvg(lib.path, fp.name, null);
          result.right = { kind: 'footprint', name: lib.nick + ':' + fp.name, svg, found: !!svg };
          const u0 = (fp.usedBy || [])[0];
          if (u0) { const h = findSymbol(u0.id); if (h) result.left = { kind: 'symbol', name: h.s.name, id: h.s.id, svg: await renderSymbolSvg(h.s.name, symLibTextFor(h.s)) }; }
        } else return sendJSON(res, 400, { error: 'symbolId or footprintId required' });
        return sendJSON(res, 200, result);
      } catch (e) { return sendJSON(res, 500, { error: e.message }); }
    }
    // ---- live JLCPCB stock + price for an LCSC part number (hover popover)
    if (p === '/api/lcsc' && req.method === 'GET') {
      const part = (u.searchParams.get('part') || '').trim();
      if (!/^C\d{3,}$/i.test(part)) return sendJSON(res, 400, { error: 'not an LCSC part number' });
      if (LCSC_CACHE.has(part.toUpperCase())) return sendJSON(res, 200, LCSC_CACHE.get(part.toUpperCase()));
      try {
        const r = await fetch(`${JLCPCB_API}/search?keyword=${encodeURIComponent(part)}&limit=6`, { signal: AbortSignal.timeout(15000) });
        const j = r.ok ? await r.json() : {};
        const comps = j.components || j.results || [];
        const c = comps.find(x => (x.lcsc || '').toUpperCase() === part.toUpperCase()) || comps[0];
        let out;
        if (!c) out = { part: part.toUpperCase(), found: false };
        else out = {
          part: c.lcsc, found: true, stock: c.stock, basic: !!c.is_basic, preferred: !!c.is_preferred,
          mfr: c.mfr || null, package: c.package || null, description: c.description || null,
          unitPrice: c.unit_price_usd != null ? c.unit_price_usd : ((c.price_tiers || [])[0] || {}).price_usd,
          tiers: (c.price_tiers || []).slice(0, 5).map(t => ({ q: t.qty_from, p: t.price_usd })),
          url: c.jlcpcb_parts_url || c.jlcpcb_url || null,
        };
        LCSC_CACHE.set(part.toUpperCase(), out);
        return sendJSON(res, 200, out);
      } catch (e) { return sendJSON(res, 200, { part: part.toUpperCase(), found: false, error: e.message }); }
    }
    // ---- preview: a single footprint's SVG
    if (p === '/api/preview/footprint' && req.method === 'GET') {
      const id = u.searchParams.get('id');
      let fp = null, lib = null;
      for (const L of DB.libs.footprintLibs) { const f = L.footprints.find(x => x.id === id); if (f) { fp = f; lib = L; break; } }
      if (!fp) return sendJSON(res, 404, { error: 'footprint not found' });
      try {
        const svg = fp.added ? await renderFootprintSvg(null, fp.name, fp.rawMod) : await renderFootprintSvg(lib.path, fp.name, null);
        return sendJSON(res, 200, { name: lib.nick + ':' + fp.name, svg });
      } catch (e) { return sendJSON(res, 500, { error: e.message }); }
    }
    // ---- preview: a 3D model as GLB (footprint's linked model, or a standalone model). Path ends .glb so Babylon detects it.
    if (p === '/api/model.glb' && req.method === 'GET') {
      try {
        const glb = await resolveGlb({ fpId: u.searchParams.get('footprintId'), modelId: u.searchParams.get('modelId') });
        if (!glb) return sendJSON(res, 404, { error: 'no 3D model available' });
        const buf = fs.readFileSync(glb);
        res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Content-Length': buf.length, 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' });
        return res.end(buf);
      } catch (e) { return sendJSON(res, 500, { error: e.message }); }
    }
    // ---- open a symbol in the KiCad Symbol Editor (offer to push it to disk first if it isn't there)
    if (p === '/api/open-in-kicad' && req.method === 'POST') {
      const { symbolId, push } = await readJSON(req);
      const hit = findSymbol(symbolId); if (!hit) return sendJSON(res, 404, { error: 'symbol not found' });
      const { lib, s } = hit;
      try {
        const onDisk = await symbolOnDisk(lib.path, s.name);
        if (!onDisk && !push) return sendJSON(res, 200, { needsPush: true, lib: lib.nick, symbol: s.name });
        if (!onDisk && push) await writeSymLib(lib);
        await adomDesktopJSON('kicad_open_symbol_editor', { libraryName: lib.nick, symbolName: s.name });
        return sendJSON(res, 200, { ok: true, opened: true, pushed: !onDisk && !!push });
      } catch (e) { return sendJSON(res, 500, { error: e.message }); }
    }
    // ---- clean dead lib-table entries (paths that no longer exist) on demand
    if (p === '/api/prune-tables' && req.method === 'POST') {
      try { const r = await pruneDeadLibTableEntries(); return sendJSON(res, 200, { ok: true, removed: r.removed }); }
      catch (e) { return sendJSON(res, 500, { error: e.message }); }
    }
    if (p === '/api/sync-library' && req.method === 'POST') {
      const { mode, nick } = await readJSON(req);
      if (!mode || !nick) return sendJSON(res, 400, { error: 'mode and nick required' });
      try { const log = await syncOneLibrary(mode, nick); return sendJSON(res, 200, { ok: true, log }); }
      catch (e) { return sendJSON(res, 500, { error: e.message }); }
    }

    return sendJSON(res, 404, { error: 'no route' });
  } catch (e) {
    return sendJSON(res, 500, { error: String(e && e.message || e) });
  }
});

// rebuild usage + history root on boot if we have persisted libs
if (DB.libs && (DB.libs.symbolLibs.length || DB.libs.footprintLibs.length || DB.libs.models3d.length)) {
  reDeriveMpn(); recomputeUsage(); backfillEditedFields(); historyInit(DB.probe && DB.probe.probedAt ? 'restored' : 'initial');
}
server.listen(PORT, () => {
  console.log(`kicad-library-manager listening on http://localhost:${PORT}`);
  console.log(`  data: ${DATA_FILE}`);
});