123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
//! adom-symbol — standalone Rust CLI for creating + previewing KiCad symbols.
//! No Node, no gallia. Generation is native Rust (`sym_gen`/`discrete_gen`);
//! SVG rendering goes through service-kicad (`render`).

mod provenance;
mod discrete_gen;
mod deliver;
mod ds2sf;
mod parse;
mod render;
mod server;
mod sym_gen;
mod theme;

use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use std::fs;
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "adom-symbol", version, about = "Create and preview KiCad/Fusion 360 schematic symbols (standalone Rust, no gallia)")]
struct Cli {
    #[command(subcommand)]
    command: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Create a symbol from structured pin data (JSON on stdin or --file).
    Create {
        #[arg(long)]
        file: Option<PathBuf>,
        /// Output directory (default: ./<symbolName>/)
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Render an existing .kicad_sym to a themed viewer HTML.
    Preview {
        /// Path to a .kicad_sym file
        #[arg(long)]
        file: PathBuf,
        /// Symbol name (defaults to the first symbol in the file)
        #[arg(long)]
        name: Option<String>,
        /// Where to write the viewer HTML (default: alongside the .kicad_sym)
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Run the live app server (AI-drivable: /state, /eval, /console).
    Serve {
        #[arg(long, default_value_t = 8781)]
        port: u16,
        /// Optional .kicad_sym to load into the view on startup
        #[arg(long)]
        file: Option<PathBuf>,
        #[arg(long)]
        name: Option<String>,
    },
    /// Render any `.kicad_sym` to a themed thumbnail SVG (for source variants).
    Render {
        #[arg(long)]
        file: PathBuf,
        #[arg(long)]
        out: PathBuf,
        /// Optional iso 3D chip PNG to composite into the symbol body centre —
        /// this is the "default symbol with the 3D chip in it" the dashboard
        /// card shows. Usually `<dir>/<mpn>-3d-iso-icon.png` (transparent) or
        /// `<mpn>-3d-iso.png`.
        #[arg(long)]
        iso: Option<PathBuf>,
        /// 3D-chip size in the body: large (default, the historical size) | medium | small.
        #[arg(long, default_value = "large")]
        chip_size: String,
    },
    /// Render a ds2sf `<mpn>-symbol.extracted.json` to a themed thumbnail SVG
    /// (auto-grouped via our generator). Used by chip-fetcher for the ds2sf
    /// source variant. Also writes the generated .kicad_sym if --sym is given.
    RenderDs2sf {
        /// Path to the ds2sf extracted JSON
        #[arg(long)]
        file: PathBuf,
        /// Output SVG path
        #[arg(long)]
        out: PathBuf,
        /// Pin layout: left-right | all-sides
        #[arg(long, default_value = "left-right")]
        layout: String,
        /// Also write the generated .kicad_sym here
        #[arg(long)]
        sym: Option<PathBuf>,
    },
    /// Drive the RUNNING live app's UI from the CLI (the ai-driven-apps contract:
    /// every user action has a command that drives the real UI like a click).
    /// Examples: `ui set 3d bold` · `ui set name chip` · `ui studio open` ·
    /// `ui zoom fit` · `ui send kicad` · `ui hover 6` · `ui toast "hi"` ·
    /// `ui state` · `ui list`. Requires `serve` running + a browser tab open.
    Ui {
        /// Action: set | studio | zoom | send | hover | toast | click | state | list
        action: String,
        /// Arguments to the action (e.g. `set 3d bold` → key=3d value=bold)
        args: Vec<String>,
        #[arg(long, default_value_t = 8781)]
        port: u16,
    },
    /// Export a self-contained, INTERACTIVE HTML package (pan/zoom + a pin info
    /// panel with datasheet descriptions) for embedding in an iframe — e.g. a
    /// wiki component page. No server, no external assets: one portable .html.
    Embed {
        /// Path to a .kicad_sym file
        #[arg(long)]
        file: PathBuf,
        /// Symbol name (defaults to the first symbol in the file)
        #[arg(long)]
        name: Option<String>,
        /// Output .html path (default: <name>-embed.html next to the input)
        #[arg(long)]
        out: Option<PathBuf>,
    },
}

// ── create input (matches the Node /sym/create body) ──
#[derive(serde::Deserialize)]
struct CreateInput {
    #[serde(rename = "symbolName")]
    symbol_name: String,
    #[serde(default)]
    manufacturer: String,
    #[serde(default)]
    package: String,
    #[serde(default)]
    description: String,
    #[serde(default, rename = "datasheetUrl")]
    datasheet_url: String,
    #[serde(default = "default_category")]
    category: String,
    #[serde(default)]
    pins: Vec<JsonPin>,
    #[serde(default, rename = "referencePrefix")]
    reference_prefix: Option<String>,
    #[serde(default, rename = "discreteBaseline")]
    discrete_baseline: Option<DiscreteBaseline>,
    #[serde(default, rename = "pinOverrides")]
    pin_overrides: Vec<JsonPinOverride>,
    #[serde(default)]
    preferences: Preferences,
    /// Short human value for discretes (e.g. "33Ω"). Empty → symbol name.
    #[serde(default)]
    value: String,
    /// Path to a `<mpn>-3d-outline-*.svg` wireframe to bake as a small offset
    /// 3D-chip icon beside a discrete symbol.
    #[serde(default, rename = "outlineSvg")]
    outline_svg: Option<String>,
}

fn default_category() -> String {
    "ic".to_string()
}

#[derive(serde::Deserialize)]
struct JsonPin {
    name: String,
    number: String,
    #[serde(rename = "type")]
    ptype: String,
    #[serde(default)]
    group: Option<String>,
    #[serde(default)]
    side: Option<String>,
}

#[derive(serde::Deserialize)]
struct DiscreteBaseline {
    library: String,
    symbol: String,
}

#[derive(serde::Deserialize, Default)]
struct JsonPinOverride {
    #[serde(rename = "oldNumber")]
    old_number: Option<String>,
    #[serde(rename = "newNumber")]
    new_number: Option<String>,
    #[serde(rename = "oldName")]
    old_name: Option<String>,
    #[serde(rename = "newName")]
    new_name: Option<String>,
}

#[derive(serde::Deserialize, Default)]
struct Preferences {
    #[serde(default, rename = "pinLayout")]
    pin_layout: Option<String>,
    #[serde(default, rename = "chipNameCentered")]
    chip_name_centered: bool,
    #[serde(default, rename = "distributorPn")]
    distributor_pn: Option<serde_json::Map<String, serde_json::Value>>,
}

fn main() {
    if let Err(e) = run() {
        eprintln!("ERROR: {e:#}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    match Cli::parse().command {
        Cmd::Create { file, out } => cmd_create(file, out),
        Cmd::Render { file, out, iso, chip_size } => {
            let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
            let name = first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string());
            let frac = render::chip_size_frac(&chip_size);
            let mut svg = render::render_symbol_svg(&content, &name)?;
            // The DEFAULT symbol layout shows the 3D chip as a WHITE vector OUTLINE
            // with the chip name lasered on, composited into the body — matching the
            // live adom-symbol app's default (three_d=bold + laserName). When no
            // explicit overlay is passed, auto-discover that asset next to the
            // symbol file. We pick a NATIVELY-white outline style (#e6edf3) so it
            // reads on the dark symbol canvas in any SVG consumer — the app gets
            // white by CSS-inverting the black "bold" SVG, but a saved standalone
            // SVG can't rely on that filter, so we use the white style directly.
            let overlay = iso.clone().or_else(|| default_white_outline(&file));
            let mut composited = false;
            let mut outline_note = String::new();
            if let Some(p) = overlay.as_ref() {
                match render::composite_iso_into_symbol(&svg, &content, &name, p, frac) {
                    Ok(s) => { svg = s; composited = true; }
                    Err(e) => eprintln!("WARN: 3D-chip composite skipped: {e}"),
                }
                // Emit the OUTLINE CONTRACT next to <mpn>-symbol.svg so adom-lbr
                // (and other consumers) can transcribe the exact placement we drew
                // — a lite drop-in overlay .mm.svg + a self-describing .json. Only
                // for vector outlines (a raster --iso has no strokes to extract).
                if p.extension().and_then(|e| e.to_str()) == Some("svg") {
                    let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or(&name).to_string();
                    let dir = out.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
                    let mm_name = format!("{stem}-outline.mm.svg");
                    let json_name = format!("{stem}-outline.json");
                    match fs::read_to_string(p).map_err(anyhow::Error::from).and_then(|ov| {
                        render::build_outline_contract(&svg, &content, &ov, p, &mm_name, frac)
                    }) {
                        Ok((mm_svg, json, segs)) => {
                            fs::write(dir.join(&mm_name), &mm_svg)
                                .with_context(|| format!("writing {mm_name}"))?;
                            fs::write(dir.join(&json_name), &json)
                                .with_context(|| format!("writing {json_name}"))?;
                            outline_note = format!(" (+outline contract: {mm_name} [{segs} segs] + {json_name})");
                        }
                        Err(e) => eprintln!("WARN: outline contract skipped: {e}"),
                    }
                }
            }
            fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
            println!("OK: rendered {} → {}{}{}", file.display(), out.display(),
                     if composited { " (+white 3D-chip outline + lasered name)" } else { "" },
                     outline_note);
            Ok(())
        }
        Cmd::RenderDs2sf { file, out, layout, sym } => cmd_render_ds2sf(file, out, layout, sym),
        Cmd::Ui { action, args, port } => cmd_ui(action, args, port),
        Cmd::Embed { file, name, out } => cmd_embed(file, name, out),
        Cmd::Preview { file, name, out } => cmd_preview(file, name, out),
        Cmd::Serve { port, file, name: _ } => {
            if let Some(f) = file {
                // Full chip load (sources + 3D assets + library root for the
                // switcher) when the file lives in a chip-fetcher library layout.
                server::load_chip_from_file(&f)?;
            }
            server::run(port)
        }
    }
}

/// Parse a `<mpn>-3d-outline-*.svg` (polyline wireframe in a 0..N viewBox) into
/// KiCad-mm polylines scaled to ~30% (a ~3 mm box), offset to the LEFT of the
/// symbol body, y-flipped — the small 3D-chip icon baked beside a discrete.
fn parse_outline_svg(path: &str) -> Result<Vec<Vec<(f64, f64)>>> {
    const OUT_BOX: f64 = 3.0;
    const OUT_CX: f64 = -3.7;
    const OUT_CY: f64 = 0.0;
    let svg = fs::read_to_string(path).with_context(|| format!("reading {path}"))?;
    let re = regex::Regex::new(r#"points="([^"]+)""#).unwrap();
    let mut polys: Vec<Vec<(f64, f64)>> = Vec::new();
    for c in re.captures_iter(&svg) {
        let mut pts = Vec::new();
        for pair in c[1].split_whitespace() {
            let mut it = pair.split(',');
            if let (Some(a), Some(b)) = (it.next(), it.next()) {
                if let (Ok(x), Ok(y)) = (a.parse::<f64>(), b.parse::<f64>()) {
                    pts.push((x, y));
                }
            }
        }
        if pts.len() >= 2 {
            polys.push(pts);
        }
    }
    if polys.is_empty() {
        return Ok(vec![]);
    }
    let (mut minx, mut maxx, mut miny, mut maxy) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
    for pl in &polys {
        for &(x, y) in pl {
            minx = minx.min(x);
            maxx = maxx.max(x);
            miny = miny.min(y);
            maxy = maxy.max(y);
        }
    }
    let (w, h) = (maxx - minx, maxy - miny);
    let s = OUT_BOX / w.max(h).max(1e-6);
    let (cx, cy) = ((minx + maxx) / 2.0, (miny + maxy) / 2.0);
    Ok(polys
        .into_iter()
        .map(|pl| {
            pl.into_iter()
                .map(|(x, y)| (OUT_CX + (x - cx) * s, OUT_CY - (y - cy) * s))
                .collect()
        })
        .collect())
}

fn cmd_create(file: Option<PathBuf>, out: Option<PathBuf>) -> Result<()> {
    let body = match file {
        Some(p) => fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?,
        None => {
            use std::io::Read;
            let mut s = String::new();
            std::io::stdin().read_to_string(&mut s)?;
            s
        }
    };
    let input: CreateInput = serde_json::from_str(&body).context("parsing create JSON")?;
    if input.symbol_name.is_empty() {
        return Err(anyhow!("symbolName is required"));
    }

    let kicad_sym = if input.category == "discrete" {
        let baseline = input
            .discrete_baseline
            .as_ref()
            .ok_or_else(|| anyhow!("discreteBaseline {{library,symbol}} required for category=discrete"))?;
        let dinput = discrete_gen::DiscreteInput {
            symbol_name: input.symbol_name.clone(),
            manufacturer: input.manufacturer.clone(),
            package: input.package.clone(),
            description: input.description.clone(),
            datasheet_url: input.datasheet_url.clone(),
            reference_prefix: input.reference_prefix.clone(),
            baseline_library: baseline.library.clone(),
            baseline_symbol: baseline.symbol.clone(),
            pin_overrides: input
                .pin_overrides
                .iter()
                .map(|o| discrete_gen::PinOverride {
                    old_number: o.old_number.clone(),
                    new_number: o.new_number.clone(),
                    old_name: o.old_name.clone(),
                    new_name: o.new_name.clone(),
                })
                .collect(),
            value: input.value.clone(),
            outline_polylines: match &input.outline_svg {
                Some(p) => parse_outline_svg(p).unwrap_or_default(),
                None => vec![],
            },
        };
        discrete_gen::generate_discrete_sym(&dinput, &fetch_lib_from_service)?
    } else {
        if input.pins.is_empty() {
            return Err(anyhow!("pins array is required for category=ic"));
        }
        let icinput = sym_gen::IcInput {
            symbol_name: input.symbol_name.clone(),
            manufacturer: input.manufacturer.clone(),
            package: input.package.clone(),
            description: input.description.clone(),
            datasheet_url: input.datasheet_url.clone(),
            reference_prefix: input.reference_prefix.clone().unwrap_or_else(|| "U".to_string()),
            pins: input
                .pins
                .iter()
                .map(|p| sym_gen::Pin {
                    name: p.name.clone(),
                    number: p.number.clone(),
                    ptype: p.ptype.clone(),
                    group: p.group.clone(),
                    side: p.side.clone(),
                })
                .collect(),
            pin_layout: input.preferences.pin_layout.clone().unwrap_or_else(|| "left-right".to_string()),
            chip_name_centered: input.preferences.chip_name_centered,
            ref_pos: "top".to_string(),
            value_pos: "out-bot".to_string(),
            distributor_pn: input
                .preferences
                .distributor_pn
                .as_ref()
                .map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect())
                .unwrap_or_default(),
        };
        sym_gen::generate_ic_sym(&icinput)
    };

    let folder = out.unwrap_or_else(|| PathBuf::from(&input.symbol_name));
    fs::create_dir_all(&folder)?;
    let sym_path = folder.join(format!("{}.kicad_sym", input.symbol_name));
    fs::write(&sym_path, &kicad_sym)?;

    let (svg, html_path) = render_and_view(&kicad_sym, &input.symbol_name, &folder, Some(&input))?;
    let svg_path = folder.join(format!("{}.svg", input.symbol_name));
    fs::write(&svg_path, &svg)?;

    println!("created {}", sym_path.display());
    println!("svg     {}", svg_path.display());
    println!("viewer  {}", html_path.display());
    Ok(())
}

fn cmd_render_ds2sf(file: PathBuf, out: PathBuf, layout: String, sym: Option<PathBuf>) -> Result<()> {
    let json = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
    let parsed = ds2sf::parse(&json)?;
    let opts = ds2sf::LayoutOpts { pin_layout: layout, grouped: true, ref_pos: "out-top".to_string(), value_pos: "out-bot".to_string() };
    let (input, _pin_desc, _group_desc) = ds2sf::build_ic_input(&parsed, &opts);
    if input.pins.is_empty() {
        return Err(anyhow!("ds2sf extraction has no pins"));
    }
    let kicad_sym = sym_gen::generate_ic_sym(&input);
    if let Some(sp) = sym {
        fs::write(&sp, &kicad_sym).with_context(|| format!("writing {}", sp.display()))?;
    }
    let svg = render::render_symbol_svg(&kicad_sym, &input.symbol_name)?;
    fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
    println!("OK: rendered ds2sf symbol → {} ({} pins)", out.display(), input.pins.len());
    Ok(())
}

fn cmd_preview(file: PathBuf, name: Option<String>, out: Option<PathBuf>) -> Result<()> {
    let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
    let symbol_name = name.unwrap_or_else(|| first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string()));
    let folder = out.unwrap_or_else(|| file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")));
    let (_svg, html_path) = render_and_view(&content, &symbol_name, &folder, None)?;
    println!("viewer {}", html_path.display());
    Ok(())
}

fn render_and_view(kicad_sym: &str, symbol_name: &str, folder: &std::path::Path, input: Option<&CreateInput>) -> Result<(String, PathBuf)> {
    let svg = render::render_symbol_svg(kicad_sym, symbol_name)?;
    let (mfr, pkg, desc) = match input {
        Some(i) => (i.manufacturer.clone(), i.package.clone(), i.description.clone()),
        None => (String::new(), String::new(), String::new()),
    };
    let html = build_viewer_html(symbol_name, &svg, &mfr, &pkg, &desc);
    let html_path = folder.join(format!("{symbol_name}-viewer.html"));
    fs::write(&html_path, &html)?;
    Ok((svg, html_path))
}

/// Drive the running app's `window.ui` API over the /eval channel.
fn cmd_ui(action: String, args: Vec<String>, port: u16) -> Result<()> {
    let arglist: Vec<String> = args.iter().map(|a| serde_json::to_string(a).unwrap_or_else(|_| format!("{a:?}"))).collect();
    let code = format!("(window.ui && window.ui.{action}) ? window.ui.{action}({}) : 'window.ui not ready — is a browser tab open?'", arglist.join(","));
    let base = format!("http://127.0.0.1:{port}");
    let client = reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(10)).build()?;
    let resp = client
        .post(format!("{base}/eval"))
        .json(&serde_json::json!({ "code": code }))
        .send()
        .with_context(|| format!("POST {base}/eval — is the app running? (adom-symbol serve --port {port})"))?;
    let id = resp.json::<serde_json::Value>()?.get("id").and_then(|v| v.as_str()).map(String::from).ok_or_else(|| anyhow!("no eval id from server"))?;
    for _ in 0..60 {
        std::thread::sleep(std::time::Duration::from_millis(120));
        let v: serde_json::Value = client.get(format!("{base}/eval/{id}")).send()?.json().unwrap_or(serde_json::Value::Null);
        // server wraps as { done, result: { error, result } } (the front-end's payload)
        if v.get("done").and_then(|d| d.as_bool()) == Some(true) {
            let inner = v.get("result").cloned().unwrap_or(serde_json::Value::Null);
            if let Some(err) = inner.get("error") {
                if !err.is_null() { return Err(anyhow!("UI error: {}", err.as_str().unwrap_or(""))); }
            }
            match inner.get("result") {
                Some(r) if r.is_string() => println!("{}", r.as_str().unwrap_or("")),
                Some(r) if !r.is_null() => println!("{}", serde_json::to_string_pretty(r).unwrap_or_default()),
                _ => println!("ok"),
            }
            return Ok(());
        }
    }
    Err(anyhow!("timed out — open a browser tab on http://127.0.0.1:{port}/ so the UI can run the command"))
}

fn cmd_embed(file: PathBuf, name: Option<String>, out: Option<PathBuf>) -> Result<()> {
    let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
    let symbol_name = name.unwrap_or_else(|| first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string()));
    let mut svg = render::render_symbol_svg(&content, &symbol_name)?;
    // Bake the 3D-chip outline into the symbol body — the signature look the live
    // app and `render` show — when the sibling white outline asset exists.
    let outline = default_white_outline(&file);
    if let Some(p) = outline.as_ref() {
        let frac = render::chip_size_frac("large");
        match render::composite_iso_into_symbol(&svg, &content, &symbol_name, p, frac) {
            Ok(s) => svg = s,
            Err(e) => eprintln!("WARN: 3D-chip composite skipped: {e}"),
        }
    }
    let meta = parse::parse_kicad_sym(&content, &symbol_name);
    let html = build_embed_html(&symbol_name, &svg, &meta);
    let out = out.unwrap_or_else(|| {
        let folder = file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
        folder.join(format!("{symbol_name}-embed.html"))
    });
    fs::write(&out, &html).with_context(|| format!("writing {}", out.display()))?;
    println!("OK: interactive embed ({} pins) → {}", meta.pins.len(), out.display());
    Ok(())
}

/// Build a self-contained, INTERACTIVE HTML viewer (pan/zoom + a filterable pin
/// panel with datasheet descriptions) ready to drop into an `<iframe>`.
pub(crate) fn build_embed_html(name: &str, svg: &str, meta: &parse::SymbolMeta) -> String {
    let pins: Vec<serde_json::Value> = meta
        .pins
        .iter()
        .map(|p| {
            serde_json::json!({
                "number": p.number, "name": p.name, "type": p.r#type,
                "color": p.color, "desc": p.desc,
                // geometry so the embed can place pin-hover hit zones exactly
                // where the live SymView does (KiCad symbol-local mm, Y-up).
                "x": p.x, "y": p.y, "angle": p.angle, "length": p.length,
            })
        })
        .collect();
    let pins_json = serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into());
    // The symbol->SVG-unit offset that aligns pin geometry to the rendered glyphs
    // (same helper the live server hands the app as `state.offset`).
    let offset_json = match parse::compute_offset(svg, &meta.pins) {
        Some((x, y)) => format!("{{\"x\":{x},\"y\":{y}}}"),
        None => "null".into(),
    };
    // Canonical-header subject-meta: "· N pins · <package>" (JetBrains-Mono row).
    // Package = the footprint's library-qualified tail (e.g. "…:QFN-56" -> "QFN-56").
    let pkg = meta.footprint.rsplit(':').next().unwrap_or("").trim();
    let mut subj_meta = format!("<span class=\"sep\">·</span><span>{} pins</span>", meta.pins.len());
    if !pkg.is_empty() {
        subj_meta.push_str(&format!("<span class=\"sep\">·</span><span>{}</span>", esc(pkg)));
    }
    // App-info popover "about" line — the app subtitle, not the part's datasheet blurb.
    let about = "Interactive KiCad schematic-symbol viewer: pan/zoom, per-pin hover, and a filterable pin list.";
    let mfr = meta.manufacturer.trim();
    let mfr_row = if mfr.is_empty() {
        String::new()
    } else {
        format!("<div class=\"popover-row\"><span class=\"k\">mfr</span><span class=\"v\">{}</span></div>", esc(mfr))
    };
    // Bottom info bar items (MFR · PKG · PINS · DESC) — mirrors the live app footer.
    let mut foot = String::new();
    for (k, v) in [("MFR", mfr), ("PKG", pkg)] {
        if !v.is_empty() {
            foot.push_str(&format!("<span class=\"it\"><span class=\"k\">{k}</span><span class=\"v\">{}</span></span>", esc(v)));
        }
    }
    foot.push_str(&format!("<span class=\"it\"><span class=\"k\">PINS</span><span class=\"v\">{}</span></span>", meta.pins.len()));
    let desc = meta.description.trim();
    if !desc.is_empty() {
        let d: String = if desc.chars().count() > 120 {
            format!("{}…", desc.chars().take(120).collect::<String>())
        } else {
            desc.to_string()
        };
        foot.push_str(&format!("<span class=\"it descit\"><span class=\"k\">DESC</span><span class=\"v\">{}</span></span>", esc(&d)));
    }
    include_str!("embed.html")
        .replace("/*__VIEWER_CORE__*/", include_str!("viewer_core.js"))
        .replace("__NAME__", &esc(name))
        .replace("__SVG__", svg)
        .replace("__PINS__", &pins_json)
        .replace("__OFFSET__", &offset_json)
        .replace("__META__", &subj_meta)
        .replace("__ABOUT__", about)
        .replace("__MFR_ROW__", &mfr_row)
        .replace("__FOOTER__", &foot)
        .replace("__PINS_N__", &meta.pins.len().to_string())
        .replace("__VERSION__", env!("CARGO_PKG_VERSION"))
}

fn fetch_lib_from_service(library: &str) -> Result<String> {
    let url = format!(
        "{}/symbols/{}.kicad_sym",
        std::env::var("KICAD_SERVICE_API").unwrap_or_else(|_| "https://kicad-rk5ue5pcfemi.adom.cloud".to_string()).trim_end_matches('/'),
        library
    );
    let resp = reqwest::blocking::get(&url).with_context(|| format!("GET {url}"))?;
    if !resp.status().is_success() {
        return Err(anyhow!("service-kicad {} for library {library}", resp.status()));
    }
    Ok(resp.text()?)
}

pub(crate) fn first_symbol_name(content: &str) -> Option<String> {
    let i = content.find("(symbol \"")? + "(symbol \"".len();
    let j = content[i..].find('"')? + i;
    Some(content[i..j].to_string())
}

/// The default 3D-chip overlay for a symbol render: the lasered-name vector
/// outline chip-thumbnailer drops next to `<mpn>.kicad_sym`
/// (`<mpn>-3d-outline-<style>-named.svg`). Canonical = **heavy** (white `#e6edf3`,
/// thick) — confirmed by John 2026-06-28. We then fall back through the other
/// SINGLE-VISIBLE-LAYER styles (white thickness ladder, then the teal accent),
/// and deliberately AVOID the multi-layer styles (`blueprint`/`xray` add hidden
/// dashed edges; `neon` is a 2-layer glow) — they read as clutter when embedded
/// as schematic `<wire>`s. Returns None if no outline asset exists yet (plain).
fn default_white_outline(file: &std::path::Path) -> Option<PathBuf> {
    let mpn = file.file_stem()?.to_str()?;
    let dir = file.parent().unwrap_or_else(|| std::path::Path::new("."));
    for style in ["heavy", "dark", "hairline", "teal"] {
        let p = dir.join(format!("{mpn}-3d-outline-{style}-named.svg"));
        if p.is_file() {
            return Some(p);
        }
    }
    None
}

fn build_viewer_html(name: &str, svg: &str, mfr: &str, pkg: &str, desc: &str) -> String {
    let meta: String = [mfr, pkg].iter().filter(|s| !s.is_empty()).cloned().collect::<Vec<_>>().join(" · ");
    let desc_html = if desc.is_empty() { String::new() } else { format!("<div class=\"desc\">{}</div>", esc(desc)) };
    let meta_html = if meta.is_empty() { String::new() } else { format!("<div class=\"meta\">{}</div>", esc(&meta)) };
    format!(
        r#"<!doctype html><html><head><meta charset="utf-8"><title>{title} — adom-symbol</title>
<style>
  :root {{ --bg:#0d1117; --panel:#11161f; --border:#222b38; --text:#e6edf3; --muted:#8b949e; --accent:#00e6dc; }}
  * {{ box-sizing:border-box; }}
  body {{ margin:0; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; min-height:100vh; display:flex; flex-direction:column; }}
  header {{ display:flex; align-items:center; gap:10px; padding:14px 22px; border-bottom:1px solid var(--border); }}
  header .dot {{ width:10px; height:10px; border-radius:50%; background:var(--accent); box-shadow:0 0 12px var(--accent); }}
  header .brand {{ font-weight:700; letter-spacing:.2px; }}
  header .brand span {{ color:var(--muted); font-weight:400; }}
  .wrap {{ flex:1; display:flex; align-items:center; justify-content:center; padding:32px; }}
  .card {{ background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:22px 26px; box-shadow:0 24px 80px rgba(0,0,0,.5); max-width:min(92vw,720px); }}
  .name {{ font-size:22px; font-weight:700; }}
  .meta {{ color:var(--muted); font-size:13px; margin-top:2px; }}
  .desc {{ color:#aeb6c2; font-size:13px; margin-top:6px; line-height:1.4; }}
  .svgbox {{ margin-top:16px; border:1px solid var(--border); border-radius:10px; background:#0d1117; overflow:hidden; }}
  .svgbox svg {{ display:block; width:100%; height:auto; max-height:64vh; }}
  footer {{ text-align:center; color:var(--muted); font-size:11px; padding:10px; }}
</style></head>
<body>
  <header><span class="dot"></span><span class="brand">adom-symbol <span>· KiCad symbol viewer</span></span></header>
  <div class="wrap">
    <div class="card">
      <div class="name">{title}</div>
      {meta_html}
      {desc_html}
      <div class="svgbox">{svg}</div>
    </div>
  </div>
  <footer>rendered via service-kicad — standalone, no local KiCad</footer>
</body></html>"#,
        title = esc(name),
        meta_html = meta_html,
        desc_html = desc_html,
        svg = svg,
    )
}

fn esc(s: &str) -> String {
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}