123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
//! adom-footprint — standalone Rust CLI + live app for KiCad PCB footprints.
//! No Node, no gallia. Generation is native Rust (`fp_gen`); SVG rendering goes
//! through service-kicad (`render`); `serve` runs the AI-drivable live app.

mod provenance;
mod deliver;
mod fp_gen;
mod paste;
mod fp_render;
mod render;
mod server;
mod theme;

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

#[derive(Parser)]
#[command(name = "adom-footprint", version, about = "Create and preview KiCad PCB footprints (standalone Rust)")]
struct Cli {
    #[command(subcommand)]
    command: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Create a footprint from structured pad data (JSON on stdin or --file).
    Create {
        #[arg(long)]
        file: Option<PathBuf>,
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Render an existing .kicad_mod to a themed viewer HTML.
    Preview {
        #[arg(long)]
        file: PathBuf,
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Render any `.kicad_mod` to a themed thumbnail SVG (for source variants).
    Render {
        #[arg(long)]
        file: PathBuf,
        #[arg(long)]
        out: PathBuf,
    },
    /// Render a ds2sf `<mpn>-footprint.extracted.json` to a themed thumbnail SVG.
    /// Used by chip-fetcher for the ds2sf footprint source variant.
    RenderDs2sf {
        #[arg(long)]
        file: PathBuf,
        #[arg(long)]
        out: PathBuf,
    },
    /// Compute the canonical RefDes/Value `textPlacement` for a footprint and
    /// (with --write) inject it into a `<mpn>-footprint.extracted.json` so every
    /// exporter validates against ONE explicit placement instead of re-guessing.
    /// Accepts a ds2sf extracted.json or an adom-footprint create JSON.
    Placement {
        #[arg(long)]
        file: PathBuf,
        /// Write the `textPlacement` block back into the JSON file (in place).
        #[arg(long)]
        write: bool,
    },
    /// Export a self-contained, INTERACTIVE HTML package (pan/zoom + a Layers
    /// panel with the InstaPCB paste-dot overlay) for embedding in an iframe —
    /// e.g. a wiki component page. No server, no external assets.
    Embed {
        #[arg(long)]
        file: PathBuf,
        /// Output .html path (default: <footprintName>-embed.html next to the input)
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Drive the RUNNING live app's UI from the CLI (ai-driven-apps contract).
    /// Examples: `ui layer silk off` · `ui layer paste-dots on` · `ui zoom fit` ·
    /// `ui send kicad` · `ui toast "hi"` · `ui state` · `ui list`.
    Ui {
        /// Action: layer | zoom | send | toast | state | list
        action: String,
        /// Arguments (e.g. `layer silk off`)
        args: Vec<String>,
        #[arg(long, default_value_t = 8783)]
        port: u16,
    },
    /// Run the live app server (AI-drivable: /state, /eval, /console).
    Serve {
        #[arg(long, default_value_t = 8782)]
        port: u16,
        #[arg(long)]
        file: Option<PathBuf>,
    },
}

/// 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-footprint 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"))?;
    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);
        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"))
}

#[derive(serde::Deserialize)]
struct CreateInput {
    #[serde(rename = "footprintName")]
    footprint_name: String,
    pads: Vec<JsonPad>,
    #[serde(default)]
    body: Option<JsonRect>,
    #[serde(default)]
    courtyard: Option<JsonRect>,
    #[serde(default)]
    description: String,
    #[serde(default)]
    tags: String,
    /// Body/silk outline shape — accept `bodyShape`, `body_shape`, or `shape`
    /// ("circle" for round parts → fp_circle silk/fab instead of a box).
    #[serde(default, rename = "bodyShape", alias = "body_shape", alias = "shape")]
    body_shape: Option<String>,
}

#[derive(serde::Deserialize)]
struct JsonPad {
    number: String,
    x: f64,
    y: f64,
    dx: f64,
    dy: f64,
    #[serde(default)]
    shape: Option<String>,
    #[serde(default)]
    angle: Option<f64>,
    #[serde(default)]
    layers: Option<String>,
    /// Mount type — accept `mount`, `type`, or `padType` (smd | thru_hole | np_thru_hole).
    #[serde(default, alias = "type", alias = "padType")]
    mount: Option<String>,
    /// Drill diameter (mm) — accept `drill`, `drillDiameter`, or `hole`.
    #[serde(default, alias = "drillDiameter", alias = "hole")]
    drill: Option<f64>,
}

#[derive(serde::Deserialize)]
struct JsonRect {
    x1: f64,
    y1: f64,
    x2: f64,
    y2: f64,
}

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

/// Build the standalone interactive embed HTML for one footprint — the lean,
/// self-contained viewer driven by the SHARED viewer core (same as the live app).
/// Reused by both the `embed` CLI and the live server's `/embed` route.
pub fn build_embed_html(kicad_mod: &str, name: &str) -> Result<String> {
    let svg = render::render_footprint_svg_with_paste(kicad_mod, bbox_from_mod(kicad_mod))?;
    let aps = paste::paste_apertures(kicad_mod);
    let dot_count = paste::paste_overlay_svg(&aps, 0.0, 0.0).2;
    let meta = serde_json::json!({ "pasteApertures": aps.len(), "pasteDots": dot_count });
    Ok(include_str!("embed.html")
        .replace("/*__AV_CSS__*/", include_str!("viewer_core.css"))
        .replace("/*__AV_JS__*/", include_str!("viewer_core.js"))
        .replace("__NAME__", &esc(name))
        .replace("__SVG__", &svg)
        .replace("__META__", &meta.to_string()))
}

fn cmd_embed(file: PathBuf, out: Option<PathBuf>) -> Result<()> {
    let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
    let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());
    let html = build_embed_html(&content, &name)?;
    let out = out.unwrap_or_else(|| {
        let folder = file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
        folder.join(format!("{name}-embed.html"))
    });
    fs::write(&out, &html).with_context(|| format!("writing {}", out.display()))?;
    println!("OK: interactive embed ({} pads) → {}", content.matches("(pad ").count(), out.display());
    Ok(())
}

fn run() -> Result<()> {
    match Cli::parse().command {
        Cmd::Create { file, out } => cmd_create(file, out),
        Cmd::Render { file, out } => {
            let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
            let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;
            fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
            println!("OK: rendered {} → {}", file.display(), out.display());
            Ok(())
        }
        Cmd::RenderDs2sf { file, out } => cmd_render_ds2sf(file, out),
        Cmd::Placement { file, write } => cmd_placement(file, write),
        Cmd::Embed { file, out } => cmd_embed(file, out),
        Cmd::Ui { action, args, port } => cmd_ui(action, args, port),
        Cmd::Preview { file, out } => cmd_preview(file, out),
        Cmd::Serve { port, file } => {
            if let Some(f) = file {
                let content = fs::read_to_string(&f).with_context(|| format!("reading {}", f.display()))?;
                let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());
                // Always bake the solder-paste dot group (same as /load) so the
                // paste-dots layer is present and toggleable in all cases.
                let svg = render::render_footprint_svg_with_paste(&content, bbox_from_mod(&content))?;
                // When launched on a chip-fetcher library chip
                // (<root>/<MPN>/<MPN>*.kicad_mod), wire the left library rail: mark
                // this chip current and set the root so the rail lists its siblings.
                let mut mpn = String::new();
                if let Some(dir) = f.parent() {
                    if let Some(dname) = dir.file_name().and_then(|s| s.to_str()) {
                        mpn = dname.to_string();
                    }
                    if let Some(root) = dir.parent() {
                        server::set_library_root(root.to_string_lossy().to_string());
                    }
                }
                server::set_loaded_path(Some(f.to_string_lossy().to_string()));
                server::set_state(server::AppState {
                    footprint_name: name,
                    svg,
                    pad_count: content.matches("(pad ").count(),
                    kicad_mod: content,
                    mpn,
                    ..Default::default()
                });
            }
            server::run(port)
        }
    }
}

fn to_input(c: &CreateInput) -> fp_gen::FpInput {
    fp_gen::FpInput {
        name: c.footprint_name.clone(),
        pads: c
            .pads
            .iter()
            .map(|p| fp_gen::Pad {
                number: p.number.clone(),
                x: p.x,
                y: p.y,
                dx: p.dx,
                dy: p.dy,
                shape: p.shape.clone(),
                angle: p.angle,
                layers: p.layers.clone(),
                mount: p.mount.clone(),
                drill: p.drill,
            })
            .collect(),
        body: c.body.as_ref().map(|r| fp_gen::Rect { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 }),
        courtyard: c.courtyard.as_ref().map(|r| fp_gen::Rect { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 }),
        description: c.description.clone(),
        tags: c.tags.clone(),
        body_shape: c.body_shape.clone(),
    }
}

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.footprint_name.is_empty() {
        return Err(anyhow!("footprintName is required"));
    }
    if input.pads.is_empty() {
        return Err(anyhow!("pads array is required"));
    }
    let fpinput = to_input(&input);
    let kicad_mod = fp_gen::generate_kicad_mod(&fpinput);
    let bbox = fp_gen::bbox(&fpinput);

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

    let svg = render::render_footprint_svg(&kicad_mod, bbox)?;
    let svg_path = folder.join(format!("{}.svg", input.footprint_name));
    fs::write(&svg_path, &svg)?;
    let html_path = folder.join(format!("{}-viewer.html", input.footprint_name));
    fs::write(&html_path, viewer_html(&input.footprint_name, &svg, input.pads.len(), &input.description, &input.tags))?;

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

fn cmd_render_ds2sf(file: PathBuf, out: PathBuf) -> Result<()> {
    let raw = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
    let v: serde_json::Value = serde_json::from_str(&raw).context("parsing ds2sf footprint JSON")?;
    let input = fpinput_from_ds2sf(&v)?;
    let kicad_mod = fp_gen::generate_kicad_mod(&input);
    let svg = render::render_footprint_svg(&kicad_mod, fp_gen::bbox(&input))?;
    fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
    println!("OK: rendered ds2sf footprint → {} ({} pads)", out.display(), input.pads.len());
    Ok(())
}

/// Build an `FpInput` from a ds2sf `<mpn>-footprint.extracted.json` value
/// (pads[].at/.size, bodyDimensions, optional courtyard rect).
fn fpinput_from_ds2sf(v: &serde_json::Value) -> Result<fp_gen::FpInput> {
    let name = v.get("footprintName").and_then(|x| x.as_str()).unwrap_or("footprint").to_string();
    let arr = v.get("pads").and_then(|x| x.as_array()).ok_or_else(|| anyhow!("footprint JSON has no pads[]"))?;
    let num = |a: Option<&serde_json::Value>| a.and_then(|x| x.as_f64()).unwrap_or(0.0);
    let mut pads = Vec::new();
    for p in arr {
        let at = p.get("at").and_then(|x| x.as_array());
        let size = p.get("size").and_then(|x| x.as_array());
        pads.push(fp_gen::Pad {
            number: p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string(),
            x: num(at.and_then(|a| a.first())),
            y: num(at.and_then(|a| a.get(1))),
            dx: num(size.and_then(|a| a.first())),
            dy: num(size.and_then(|a| a.get(1))),
            shape: p.get("shape").and_then(|x| x.as_str()).map(String::from),
            angle: p.get("rotation").and_then(|x| x.as_f64()).or_else(|| p.get("angle").and_then(|x| x.as_f64())),
            layers: None,
            mount: p.get("mount").or_else(|| p.get("type")).and_then(|x| x.as_str()).map(String::from),
            drill: p.get("drill").or_else(|| p.get("drillDiameter")).or_else(|| p.get("hole")).and_then(|x| x.as_f64()),
        });
    }
    if pads.is_empty() {
        return Err(anyhow!("footprint extraction has no pads"));
    }
    // Body outline from bodyDimensions ([w,h] or {x,y}/{w,h}), centered.
    let body = v.get("bodyDimensions").and_then(|b| {
        let (w, h) = if let Some(a) = b.as_array() {
            (a.first().and_then(|x| x.as_f64())?, a.get(1).and_then(|x| x.as_f64())?)
        } else {
            let g = |k1: &str, k2: &str| b.get(k1).or_else(|| b.get(k2)).and_then(|x| x.as_f64());
            (g("x", "w")?, g("y", "h")?)
        };
        Some(fp_gen::Rect { x1: -w / 2.0, y1: -h / 2.0, x2: w / 2.0, y2: h / 2.0 })
    });
    // Optional explicit courtyard rect {x1,y1,x2,y2} if the extraction has one.
    let courtyard = v.get("courtyard").and_then(|c| {
        let g = |k: &str| c.get(k).and_then(|x| x.as_f64());
        Some(fp_gen::Rect { x1: g("x1")?, y1: g("y1")?, x2: g("x2")?, y2: g("y2")? })
    });
    Ok(fp_gen::FpInput {
        name,
        pads,
        body,
        courtyard,
        description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),
        tags: String::new(),
        body_shape: v.get("bodyShape").or_else(|| v.get("shape")).and_then(|x| x.as_str()).map(String::from),
    })
}

/// Compute the canonical `textPlacement` and (optionally) write it into the JSON.
/// Accepts a ds2sf extracted.json (pads[].at/.size) or an adom-footprint create
/// JSON (pads[].x/.dx). Placement is computed ONCE here (fp_gen::text_placement) —
/// exporters consume it; they do not re-derive it.
fn cmd_placement(file: PathBuf, write: bool) -> Result<()> {
    let raw = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
    let mut v: serde_json::Value = serde_json::from_str(&raw).context("parsing footprint JSON")?;
    let is_create = v.get("pads").and_then(|p| p.as_array()).and_then(|a| a.first())
        .map(|p| p.get("x").is_some() && p.get("dx").is_some())
        .unwrap_or(false);
    let input = if is_create {
        let c: CreateInput = serde_json::from_value(v.clone()).context("parsing create JSON")?;
        to_input(&c)
    } else {
        fpinput_from_ds2sf(&v)?
    };
    let tp = fp_gen::text_placement(&input);
    let tp_val = serde_json::to_value(&tp)?;
    println!("{}", serde_json::to_string_pretty(&tp_val)?);
    if write {
        let obj = v.as_object_mut().ok_or_else(|| anyhow!("JSON root is not an object; cannot add textPlacement"))?;
        obj.insert("textPlacement".to_string(), tp_val);
        fs::write(&file, serde_json::to_string_pretty(&v)? + "\n").with_context(|| format!("writing {}", file.display()))?;
        eprintln!("wrote textPlacement → {}", file.display());
    }
    Ok(())
}

fn cmd_preview(file: PathBuf, out: Option<PathBuf>) -> Result<()> {
    let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
    let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());
    let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;
    let folder = out.unwrap_or_else(|| file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")));
    let pads = content.matches("(pad ").count();
    let html_path = folder.join(format!("{name}-viewer.html"));
    fs::write(&html_path, viewer_html(&name, &svg, pads, "", ""))?;
    println!("viewer {}", html_path.display());
    Ok(())
}

pub(crate) fn first_footprint_name(content: &str) -> Option<String> {
    // Modern: (footprint "NAME" …). Legacy KiCad 5/6: (footprint NAME (layer …)).
    if let Some(start) = content.find("(footprint \"") {
        let i = start + "(footprint \"".len();
        let j = content[i..].find('"')? + i;
        return Some(content[i..j].to_string());
    }
    let i = content.find("(footprint ")? + "(footprint ".len();
    let rest = &content[i..];
    let end = rest.find(|c: char| c.is_whitespace() || c == '(').unwrap_or(rest.len());
    let name = rest[..end].trim();
    if name.is_empty() { None } else { Some(name.to_string()) }
}

/// Current RefDes (`reference`/`>NAME`) and Value (`value`/`>VALUE`) text
/// positions + size, parsed from a `.kicad_mod` — fed to the live app so it can
/// render the draggable text labels at the right spot.
pub(crate) fn fp_text_state(km: &str) -> serde_json::Value {
    let one = |kind: &str| -> serde_json::Value {
        // `(at x y [rot] [unlocked|locked])` — tolerate the trailing rotation and/or
        // KiCad's lock keyword (newer footprints write `(at 0 0 unlocked)`).
        let at = Regex::new(&format!(
            r#"\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+(-?[\d.]+)\s+(-?[\d.]+)[^)]*\)"#
        )).unwrap();
        match at.captures(km) {
            Some(c) => {
                let x: f64 = c[1].parse().unwrap_or(0.0);
                let y: f64 = c[2].parse().unwrap_or(0.0);
                let size = Regex::new(&format!(r#"\(fp_text {kind}\b[\s\S]*?\(size\s+(-?[\d.]+)"#))
                    .unwrap()
                    .captures(km)
                    .and_then(|m| m[1].parse::<f64>().ok())
                    .unwrap_or(1.0);
                // Hidden? Look for a `hide` keyword within this fp_text's block (up
                // to the next fp_text/pad) — common in vendor footprints (value hidden
                // at 0,0). The editor skips hidden labels so they don't garble.
                let start = c.get(0).map(|m| m.start()).unwrap_or(0);
                let tail = &km[start..];
                let end = tail
                    .get(1..)
                    .and_then(|t| t.find("(fp_text ").map(|i| i + 1))
                    .or_else(|| tail.find("\n  (pad "))
                    .unwrap_or_else(|| tail.len().min(400));
                let hidden = tail[..end.min(tail.len())].contains(" hide");
                serde_json::json!({ "x": x, "y": y, "size": size, "hidden": hidden })
            }
            None => serde_json::Value::Null,
        }
    };
    serde_json::json!({ "reference": one("reference"), "value": one("value") })
}

/// Rewrite the `reference`/`value` fp_text `(at x y)` positions in a `.kicad_mod`
/// (rotation dropped → horizontal, per the placement rule). Other text untouched.
pub(crate) fn rewrite_fp_text(km: &str, refx: f64, refy: f64, valx: f64, valy: f64) -> String {
    let r2 = |v: f64| (v * 100.0).round() / 100.0;
    let mut out = km.to_string();
    for (kind, x, y) in [("reference", r2(refx), r2(refy)), ("value", r2(valx), r2(valy))] {
        // Drop any trailing rotation / lock keyword → clean horizontal `(at x y)`.
        let re = Regex::new(&format!(
            r#"(\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+)-?[\d.]+\s+-?[\d.]+[^)]*(\))"#
        )).unwrap();
        let rep = format!("${{1}}{x} {y}${{2}}");
        out = re.replace(&out, rep.as_str()).into_owned();
    }
    out
}

/// Rewrite the font `(size H W)` + `(thickness T)` of a `reference`/`value`
/// fp_text (thickness tracks size at ~15%). No-op if that text has no font block.
pub(crate) fn set_fp_text_size(km: &str, kind: &str, size: f64) -> String {
    let r2 = |v: f64| (v * 100.0).round() / 100.0;
    let (sz, th) = (r2(size), r2(size * 0.15));
    let re = Regex::new(&format!(
        r#"(\(fp_text {kind}\b[\s\S]*?\(size )[-\d.]+ [-\d.]+(\)[\s\S]*?\(thickness )[-\d.]+"#
    )).unwrap();
    let rep = format!("${{1}}{sz} {sz}${{2}}{th}");
    re.replace(km, rep.as_str()).into_owned()
}

/// Bounding box (minx,miny,maxx,maxy) from pad + fp_rect extents in a .kicad_mod.
pub(crate) fn bbox_from_mod(content: &str) -> (f64, f64, f64, f64) {
    let mut xs = Vec::new();
    let mut ys = Vec::new();
    let pad = Regex::new(r"\(at (-?[\d.]+) (-?[\d.]+)(?: [-\d.]+)?\) \(size (-?[\d.]+) (-?[\d.]+)\)").unwrap();
    for c in pad.captures_iter(content) {
        if let (Ok(x), Ok(y), Ok(dx), Ok(dy)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {
            xs.push(x - dx / 2.0);
            xs.push(x + dx / 2.0);
            ys.push(y - dy / 2.0);
            ys.push(y + dy / 2.0);
        }
    }
    let rect = Regex::new(r"\(start (-?[\d.]+) (-?[\d.]+)\) \(end (-?[\d.]+) (-?[\d.]+)\)").unwrap();
    for c in rect.captures_iter(content) {
        if let (Ok(x1), Ok(y1), Ok(x2), Ok(y2)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {
            xs.push(x1);
            xs.push(x2);
            ys.push(y1);
            ys.push(y2);
        }
    }
    if xs.is_empty() {
        return (-2.0, -2.0, 2.0, 2.0);
    }
    (
        xs.iter().cloned().fold(f64::INFINITY, f64::min),
        ys.iter().cloned().fold(f64::INFINITY, f64::min),
        xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
        ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
    )
}

fn viewer_html(name: &str, svg: &str, pads: usize, desc: &str, tags: &str) -> String {
    let meta = [format!("{pads} pads"), tags.to_string()].into_iter().filter(|s| !s.is_empty() && s != "0 pads").collect::<Vec<_>>().join(" · ");
    format!(
        "<!doctype html><html><head><meta charset=\"utf-8\"><title>{t} — adom-footprint</title>\
<style>body{{margin:0;background:#0d1117;color:#e6edf3;font-family:-apple-system,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 #222b38}}.dot{{width:10px;height:10px;border-radius:50%;background:#00e6dc;box-shadow:0 0 12px #00e6dc}}\
.brand{{font-weight:700}}.brand span{{color:#8b949e;font-weight:400}}.wrap{{flex:1;display:flex;align-items:center;justify-content:center;padding:32px}}\
.card{{background:#11161f;border:1px solid #222b38;border-radius:14px;padding:22px 26px;max-width:min(92vw,720px)}}.name{{font-size:22px;font-weight:700}}.meta{{color:#8b949e;font-size:13px;margin-top:2px}}.desc{{color:#aeb6c2;font-size:13px;margin-top:6px}}\
.svgbox{{margin-top:16px;border:1px solid #222b38;border-radius:10px;background:#0d1117;overflow:hidden}}.svgbox svg{{display:block;width:100%;height:auto;max-height:64vh}}footer{{text-align:center;color:#5b6675;font-size:11px;padding:10px}}</style></head>\
<body><header><span class=\"dot\"></span><span class=\"brand\">adom-footprint <span>· PCB footprint viewer</span></span></header>\
<div class=\"wrap\"><div class=\"card\"><div class=\"name\">{t}</div>{m}{d}<div class=\"svgbox\">{svg}</div></div></div>\
<footer>rendered locally in Rust — no KiCad, no service round-trip</footer></body></html>",
        t = esc(name),
        m = if meta.is_empty() { String::new() } else { format!("<div class=\"meta\">{}</div>", esc(&meta)) },
        d = if desc.is_empty() { String::new() } else { format!("<div class=\"desc\">{}</div>", esc(desc)) },
        svg = svg,
    )
}

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