123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908
mod deliver;
mod server;
mod manage;

use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::process;

const VERSION: &str = include_str!("../VERSION");
// SAFETY NET — install subcommand fetches from wiki first; this is the offline fallback
const SKILL: &str = include_str!("../SKILL.md");

#[derive(Parser)]
#[command(name = "adom-lbr", version = VERSION.trim(), about = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Generate a .lbr from a KiCad .kicad_sym (and optional .kicad_mod).
    /// If --sym is omitted but --fp is given, a symbol is synthesized from the
    /// footprint pads so footprint-only parts still form a placeable deviceset.
    Generate {
        /// Path to the .kicad_sym file (optional when --fp is given)
        #[arg(long)]
        sym: Option<PathBuf>,
        /// Path to the .kicad_mod footprint file (optional — generates a placeholder if omitted)
        #[arg(long)]
        fp: Option<PathBuf>,
        /// Output .lbr file path (default: <name>.lbr in the same directory)
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Symbol name override
        #[arg(long)]
        name: Option<String>,
        /// Chip outline drawn on the schematic symbol as <wire>s, OVERRIDING the
        /// kicad_sym's own drawing. PREFERRED: adom-symbol's OUTLINE CONTRACT
        /// `<mpn>-symbol-outline.mm.svg` (+ its `.json` sidecar) — placed 1:1 in
        /// symbol mm-space. Else a legacy flattened outline SVG. Composes with --fp.
        #[arg(long)]
        symbol_art: Option<PathBuf>,
        /// Size of LEGACY symbol art as a fraction of the body's smaller side.
        /// Ignored for the `.mm.svg` contract (placement is exact).
        #[arg(long, default_value_t = 0.38)]
        symbol_art_scale: f64,
    },
    /// Lint an existing .lbr file for Fusion 360 compatibility
    Lint {
        /// Path to the .lbr file
        path: PathBuf,
    },
    /// Validate a .lbr file has complete deviceset (symbol + package + connects)
    Validate {
        /// Path to the .lbr file
        path: PathBuf,
    },
    /// Import an EAGLE .lbr and convert to KiCad .kicad_sym
    Import {
        /// Path to the .lbr file
        path: PathBuf,
        /// Output .kicad_sym file path (default: <name>.kicad_sym)
        #[arg(short, long)]
        output: Option<PathBuf>,
    },
    /// Verify a .kicad_sym via service-kicad (headless KiCad validation)
    Check {
        /// Path to the .kicad_sym file
        path: PathBuf,
    },
    /// Export a Fusion-style 3-column INTERACTIVE HTML package — symbol | footprint
    /// | 3D, each pan/zoomable, with a pin→pad mapping table carrying the datasheet
    /// descriptions below. Self-contained for embedding in an iframe (e.g. a wiki
    /// component page). Assembles from a chip directory's renders + extracted data.
    Embed {
        /// Chip directory (a chip-fetcher library entry) to assemble the viewer from
        #[arg(long)]
        dir: PathBuf,
        /// Output .html path (default: <mpn>-lbr-embed.html in the dir)
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Run the live app server (AI-drivable: /state, /load, /eval, /console)
    Serve {
        /// Port to listen on
        #[arg(long, default_value_t = 8784)]
        port: u16,
        /// Optional .lbr to load into the view on startup
        #[arg(long)]
        file: Option<PathBuf>,
    },
    /// adom-lbr Manager: browse wiki components, view symbol/footprint/3D + the
    /// footprint layer stackup (KiCad + Altium). Renders SVG on the fly per click.
    Manage {
        /// Port to listen on
        #[arg(long, default_value_t = 8785)]
        port: u16,
    },
    /// Import a KiCad symbol + footprint into the canonical adom-lbr JSON (the hub).
    ImportKicad {
        /// Path to the .kicad_sym file
        #[arg(long)]
        sym: PathBuf,
        /// Path to the .kicad_mod footprint file
        #[arg(long)]
        fp: PathBuf,
        /// Manufacturer part number / component id
        #[arg(long, default_value = "PART")]
        mpn: String,
        /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Convert an adom-lbr JSON into native Altium .SchLib + .PcbLib (no Altium needed).
    ExportAltium {
        /// Path to the adom-lbr JSON
        part: PathBuf,
        /// Output directory (files named <mpn>.SchLib / <mpn>.PcbLib)
        #[arg(long, default_value = ".")]
        out_dir: PathBuf,
    },
    /// Convert an adom-lbr JSON into one self-contained Altium .IntLib.
    ExportIntlib {
        /// Path to the adom-lbr JSON
        part: PathBuf,
        /// Output .IntLib path (default: <mpn>.IntLib)
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Convert an adom-lbr JSON into KiCad .kicad_sym + .kicad_mod.
    ExportKicad {
        /// Path to the adom-lbr JSON
        part: PathBuf,
        /// Output directory (files named <mpn>.kicad_sym / <mpn>.kicad_mod)
        #[arg(long, default_value = ".")]
        out_dir: PathBuf,
    },
    /// Add an adom-lbr JSON's symbol into an EXISTING Altium .SchLib (non-destructive merge).
    AddToSchlib {
        /// Existing .SchLib to merge into
        existing: PathBuf,
        /// Path to the adom-lbr JSON (or a bare Symbol / array of Symbols)
        part: PathBuf,
        /// Output .SchLib path (default: merged.SchLib)
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Convert an Altium .SchLib / .PcbLib / .IntLib into the canonical adom-lbr JSON.
    /// Pass a .SchLib or .PcbLib and the other half via --pcblib/--schlib, or one .IntLib.
    ImportAltium {
        /// Path to a .SchLib, .PcbLib, or .IntLib
        file: PathBuf,
        /// The .PcbLib half (when `file` is a .SchLib)
        #[arg(long)]
        pcblib: Option<PathBuf>,
        /// The .SchLib half (when `file` is a .PcbLib)
        #[arg(long)]
        schlib: Option<PathBuf>,
        /// MPN / component id for the output part (default: the symbol's name)
        #[arg(long, default_value = "")]
        mpn: String,
        /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Install skill + bash completions
    Install,
    /// Check health
    Health,
    /// Generate shell completions
    Completions {
        #[arg(value_enum)]
        shell: clap_complete::Shell,
    },
}

// ── KiCad .kicad_sym parser ──

#[derive(Debug, Serialize, Deserialize)]
struct KicadPin {
    name: String,
    number: String,
    pin_type: String,
    x: f64,
    y: f64,
    angle: u32,
    length: f64,
}

#[derive(Debug, Serialize, Deserialize)]
struct KicadBody {
    left: f64,
    top: f64,
    right: f64,
    bottom: f64,
}

#[derive(Debug, Serialize, Deserialize)]
struct GroupLabel {
    text: String,
    x: f64,
    y: f64,
}

#[derive(Debug, Serialize, Deserialize)]
struct ParsedSymbol {
    name: String,
    reference: String,
    value: String,
    footprint: String,
    description: String,
    pins: Vec<KicadPin>,
    body: Option<KicadBody>,
    group_labels: Vec<GroupLabel>,
    graphics: Vec<SymGraphic>,
}

fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {
    let name = if let Some(n) = name_override {
        n.to_string()
    } else {
        let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();
        re.captures(content)
            .and_then(|c| c.get(1))
            .map(|m| m.as_str().to_string())
            .ok_or("Cannot find symbol name in .kicad_sym")?
    };

    let mut pins = Vec::new();
    // The pin angle may be an int (`180`) or a float (`180.0`) depending on the
    // KiCad version that wrote the symbol; accept both. The shape/electrical
    // tokens may also be hyphenated (e.g. `power_in`, `no_connect`).
    let pin_re = regex_lite::Regex::new(
        r#"(?s)\(pin\s+([\w-]+)\s+[\w-]+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\).*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#
    ).unwrap();

    for cap in pin_re.captures_iter(content) {
        pins.push(KicadPin {
            pin_type: cap[1].to_string(),
            x: cap[2].parse().unwrap_or(0.0),
            y: cap[3].parse().unwrap_or(0.0),
            angle: cap[4].parse::<f64>().unwrap_or(0.0).round() as u32,
            length: cap[5].parse().unwrap_or(2.54),
            name: cap[6].to_string(),
            number: cap[7].to_string(),
        });
    }

    let body = {
        let rect_re = regex_lite::Regex::new(
            r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#
        ).unwrap();
        rect_re.captures(content).map(|c| KicadBody {
            left: c[1].parse().unwrap_or(0.0),
            top: c[2].parse().unwrap_or(0.0),
            right: c[3].parse().unwrap_or(0.0),
            bottom: c[4].parse().unwrap_or(0.0),
        })
    };

    // The symbol's own drawing: (polyline (pts (xy …))) and (arc (start)(mid)(end)).
    let mut graphics: Vec<SymGraphic> = Vec::new();
    let xy_re = regex_lite::Regex::new(r#"\(xy\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();
    for b in sexpr_blocks(content, "polyline") {
        let pts: Vec<(f64, f64)> = xy_re.captures_iter(b)
            .map(|c| (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0)))
            .collect();
        if pts.len() >= 2 { graphics.push(SymGraphic::Poly(pts)); }
    }
    let arc_re = regex_lite::Regex::new(
        r#"(?s)\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\).*?\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\).*?\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#
    ).unwrap();
    for b in sexpr_blocks(content, "arc") {
        if let Some(c) = arc_re.captures(b) {
            graphics.push(SymGraphic::Arc {
                start: (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0)),
                mid: (c[3].parse().unwrap_or(0.0), c[4].parse().unwrap_or(0.0)),
                end: (c[5].parse().unwrap_or(0.0), c[6].parse().unwrap_or(0.0)),
            });
        }
    }

    let mut group_labels = Vec::new();
    let text_re = regex_lite::Regex::new(
        r#"\(text "([^"]+)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)"#
    ).unwrap();
    for cap in text_re.captures_iter(content) {
        group_labels.push(GroupLabel {
            text: cap[1].to_string(),
            x: cap[2].parse().unwrap_or(0.0),
            y: cap[3].parse().unwrap_or(0.0),
        });
    }

    let prop = |key: &str| -> String {
        let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, key)).unwrap();
        re.captures(content)
            .and_then(|c| c.get(1))
            .map(|m| m.as_str().to_string())
            .unwrap_or_default()
    };

    Ok(ParsedSymbol {
        name,
        reference: prop("Reference"),
        value: prop("Value"),
        footprint: prop("Footprint"),
        description: prop("Description"),
        pins,
        body,
        group_labels,
        graphics,
    })
}

// ── EAGLE .lbr parser (import) ──

fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {
    let pin_re = regex_lite::Regex::new(
        r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#
    ).unwrap();

    let sym_name_re = regex_lite::Regex::new(r#"<symbol name="([^"]+)">"#).unwrap();
    let name = sym_name_re.captures(content)
        .and_then(|c| c.get(1))
        .map(|m| m.as_str().to_string())
        .ok_or("Cannot find <symbol name=\"...\"> in .lbr")?;

    let desc_re = regex_lite::Regex::new(r#"<description>([^<]*)</description>"#).unwrap();
    let description = desc_re.captures(content)
        .and_then(|c| c.get(1))
        .map(|m| m.as_str().to_string())
        .unwrap_or_default()
        .replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
        .replace("&quot;", "\"").replace("&apos;", "'");

    let prefix_re = regex_lite::Regex::new(r#"<deviceset[^>]*prefix="([^"]+)""#).unwrap();
    let reference = prefix_re.captures(content)
        .and_then(|c| c.get(1))
        .map(|m| m.as_str().to_string())
        .unwrap_or_else(|| "U".to_string());

    let rect_re = regex_lite::Regex::new(
        r#"<wire x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)" width="[^"]*" layer="94"/>"#
    ).unwrap();

    let mut min_x = f64::MAX;
    let mut min_y = f64::MAX;
    let mut max_x = f64::MIN;
    let mut max_y = f64::MIN;
    let mut has_body = false;
    for cap in rect_re.captures_iter(content) {
        has_body = true;
        let x1: f64 = cap[1].parse().unwrap_or(0.0);
        let y1: f64 = cap[2].parse().unwrap_or(0.0);
        let x2: f64 = cap[3].parse().unwrap_or(0.0);
        let y2: f64 = cap[4].parse().unwrap_or(0.0);
        min_x = min_x.min(x1).min(x2);
        min_y = min_y.min(y1).min(y2);
        max_x = max_x.max(x1).max(x2);
        max_y = max_y.max(y1).max(y2);
    }

    let body = if has_body {
        Some(KicadBody { left: min_x, top: max_y, right: max_x, bottom: min_y })
    } else {
        None
    };

    let connect_re = regex_lite::Regex::new(
        r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#
    ).unwrap();
    let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();
    for cap in connect_re.captures_iter(content) {
        pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());
    }

    let length_val = |kw: &str| -> f64 {
        match kw {
            "point" => 0.0,
            "short" => 2.54,
            "middle" => 5.08,
            "long" => 7.62,
            _ => 2.54,
        }
    };

    let eagle_to_kicad_dir = |d: &str| -> &'static str {
        match d {
            "pwr" => "power_in",
            "in" => "input",
            "out" => "output",
            "io" => "bidirectional",
            "pas" => "passive",
            "nc" => "unconnected",
            "hiz" => "tri_state",
            "oc" => "open_collector",
            _ => "bidirectional",
        }
    };

    let rot_re = regex_lite::Regex::new(r#"rot="R(\d+)""#).unwrap();

    let mut pins = Vec::new();
    for cap in pin_re.captures_iter(content) {
        let pin_name_raw = cap[1].to_string();
        let pin_name = pin_name_raw.replace("@2", "").replace("@3", "")
            .replace("@4", "").replace("@5", "").replace("@6", "")
            .replace("@7", "").replace("@8", "").replace("@9", "");

        let pad_number = pin_to_pad.get(&pin_name_raw)
            .cloned()
            .unwrap_or_else(|| "0".to_string());

        let angle = rot_re.captures(&cap[6])
            .and_then(|c| c.get(1))
            .and_then(|m| m.as_str().parse::<u32>().ok())
            .unwrap_or(0);

        pins.push(KicadPin {
            name: pin_name,
            number: pad_number,
            pin_type: eagle_to_kicad_dir(&cap[5]).to_string(),
            x: cap[2].parse().unwrap_or(0.0),
            y: cap[3].parse().unwrap_or(0.0),
            angle,
            length: length_val(&cap[4]),
        });
    }

    Ok(ParsedSymbol {
        value: name.clone(),
        name,
        reference,
        footprint: String::new(),
        description,
        pins,
        body,
        group_labels: Vec::new(),
        graphics: Vec::new(),
    })
}

// ── KiCad .kicad_sym generator ──

fn generate_kicad_sym(sym: &ParsedSymbol) -> String {
    let mut s = String::with_capacity(4096);
    s.push_str("(kicad_symbol_lib\n  (version 20231120)\n  (generator \"adom-lbr\")\n");
    s.push_str(&format!("  (symbol \"{}\"\n", sym.name));
    s.push_str("    (pin_names (offset 1.016))\n");
    s.push_str("    (exclude_from_sim no)\n");
    s.push_str("    (in_bom yes)\n    (on_board yes)\n");

    s.push_str(&format!("    (property \"Reference\" \"{}\"\n      (at 0 2.54 0)\n      (effects (font (size 1.27 1.27)))\n    )\n", sym.reference));
    s.push_str(&format!("    (property \"Value\" \"{}\"\n      (at 0 -2.54 0)\n      (effects (font (size 1.27 1.27)))\n    )\n", sym.name));
    s.push_str(&format!("    (property \"Footprint\" \"{}\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n",
        sym.footprint.replace('"', "\\\"")
    ));
    s.push_str("    (property \"Datasheet\" \"\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n");
    if !sym.description.is_empty() {
        s.push_str(&format!("    (property \"Description\" \"{}\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n",
            sym.description.replace('"', "\\\"")
        ));
    }

    s.push_str(&format!("    (symbol \"{}_0_{}\"\n", sym.name, 1));

    if let Some(body) = &sym.body {
        s.push_str(&format!("      (rectangle (start {} {}) (end {} {})\n        (stroke (width 0.254) (type default))\n        (fill (type background))\n      )\n",
            body.left, body.top, body.right, body.bottom
        ));
    }

    for pin in &sym.pins {
        let kicad_angle = match pin.angle {
            0 => 0,
            90 => 90,
            180 => 180,
            270 => 270,
            _ => 0,
        };
        s.push_str(&format!(
            "      (pin {} line (at {} {} {}) (length {}) (name \"{}\") (number \"{}\"))\n",
            pin.pin_type, pin.x, pin.y, kicad_angle, pin.length,
            pin.name.replace('"', "\\\""),
            pin.number.replace('"', "\\\"")
        ));
    }

    s.push_str("    )\n");
    s.push_str("  )\n)\n");
    s
}

// ── EAGLE .lbr generator ──

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

fn pin_direction(kicad_type: &str) -> &'static str {
    match kicad_type {
        "power_in" => "pwr",
        "power_out" => "out",
        "input" => "in",
        "output" => "out",
        "bidirectional" => "io",
        "passive" => "pas",
        "unconnected" => "nc",
        "tri_state" => "hiz",
        "open_collector" | "open_emitter" => "oc",
        _ => "io",
    }
}

fn pin_rotation(angle: u32) -> &'static str {
    match angle {
        180 => " rot=\"R180\"",
        90 => " rot=\"R90\"",
        270 => " rot=\"R270\"",
        _ => "",
    }
}

fn pin_length_keyword(len: f64) -> &'static str {
    if len <= 0.0 { "point" }
    else if len <= 2.54 { "short" }
    else if len <= 5.08 { "middle" }
    else { "long" }
}

fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> {
    // Effective EAGLE pin name: when the KiCad pin name is empty (common on
    // mechanical / 1-pin parts: `(number "1")(name "")`), fall back to the pad
    // NUMBER. Fusion REJECTS `<pin name="">` / `<connect pin="">`, so a name is
    // mandatory. This is the single source of truth for both the <pin> and the
    // <connect> emission, so the fallback fixes both at once.
    let eff: Vec<String> = pins.iter().enumerate().map(|(i, p)| {
        let n = p.name.trim();
        if !n.is_empty() {
            n.to_string()
        } else {
            let num = p.number.trim();
            if !num.is_empty() { num.to_string() } else { format!("P{}", i + 1) }
        }
    }).collect();
    let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
    for n in &eff {
        *counts.entry(n.clone()).or_insert(0) += 1;
    }
    let mut seen: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
    eff.iter().map(|name| {
        if counts[name] == 1 {
            name.clone()
        } else {
            let c = seen.entry(name.clone()).or_insert(0);
            *c += 1;
            if *c == 1 { name.clone() } else { format!("{}@{}", name, c) }
        }
    }).collect()
}

/// The first `(footprint "NAME" …)` name in a `.kicad_mod`, if any.
fn first_footprint_name(km: &str) -> Option<String> {
    let re = regex_lite::Regex::new(r#"\(footprint\s+"([^"]+)""#).unwrap();
    re.captures(km).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())
}

/// Synthesize a placeable symbol from a `.kicad_mod`'s pads when there is no
/// `.kicad_sym`. One symbol pin per unique pad number, balanced left/right, so
/// the resulting EAGLE deviceset has the correct pin/pad names and connects.
fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {
    // Pad numbers in document order, de-duplicated (thru-hole pairs share a no.).
    // Pad ids may be quoted ("A1") or bare (1); skip mechanical pads ("").
    let pad_re = regex_lite::Regex::new(r#"\(pad\s+(?:"([^"]*)"|(\S+))"#).unwrap();
    let mut nums: Vec<String> = Vec::new();
    for c in pad_re.captures_iter(km) {
        let n = c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();
        if !n.is_empty() && !nums.contains(&n) {
            nums.push(n);
        }
    }
    let count = nums.len().max(1);
    let half = (count + 1) / 2;
    let body_h = (half as f64) * 2.54 + 2.54;
    let pins: Vec<KicadPin> = nums.iter().enumerate().map(|(i, num)| {
        let (x, y, angle) = if i < half {
            (-12.7, (half as f64 / 2.0 - i as f64) * 2.54, 0)
        } else {
            (12.7, (half as f64 / 2.0 - (i - half) as f64) * 2.54, 180)
        };
        KicadPin {
            name: num.clone(),
            number: num.clone(),
            pin_type: "passive".into(),
            x, y, angle, length: 2.54,
        }
    }).collect();
    ParsedSymbol {
        name: name.to_string(),
        reference: "U".into(),
        value: name.to_string(),
        footprint: name.to_string(),
        description: format!("{name} (symbol synthesized from footprint pads)"),
        pins,
        body: Some(KicadBody { left: -7.62, top: body_h / 2.0, right: 7.62, bottom: -body_h / 2.0 }),
        group_labels: Vec::new(),
        graphics: Vec::new(),
    }
}

/// A drawing primitive from the `.kicad_sym` symbol body (KiCad symbol space,
/// Y-up — same as the pins, so it carries through to EAGLE without a Y-flip).
#[derive(Debug, Serialize, Deserialize)]
enum SymGraphic {
    Poly(Vec<(f64, f64)>),
    Arc { start: (f64, f64), mid: (f64, f64), end: (f64, f64) },
}

/// One SMD/thru-hole pad parsed from a `.kicad_mod`, in KiCad coordinates
/// (millimetres, Y-down). `drill` is `Some` only for thru-hole pads.
struct ModPad {
    name: String,
    smd: bool,
    x: f64,
    y: f64,
    dx: f64,
    dy: f64,
    angle: f64,
    drill: Option<f64>,
    /// EAGLE pad shape mapped from the KiCad shape token: "round" | "square" |
    /// "long" | "octagon".
    shape: String,
}

/// Split a `.kicad_mod` into the raw text of each `(<tag> …)` s-expression,
/// balancing parens so multi-line definitions are captured whole. `tag` is the
/// opening token without its paren, e.g. `"pad"` or `"fp_line"`.
fn sexpr_blocks<'a>(km: &'a str, tag: &str) -> Vec<&'a str> {
    let open = format!("({tag}");
    let mut blocks = Vec::new();
    let mut i = 0;
    while let Some(rel) = km[i..].find(&open) {
        let start = i + rel;
        // Require whitespace after the token so "(pads"/"(fp_lines" don't match.
        let after = &km[start + open.len()..];
        if !after.starts_with(|c: char| c.is_whitespace()) {
            i = start + open.len();
            continue;
        }
        let mut depth = 0i32;
        let mut end = start;
        for (k, c) in km[start..].char_indices() {
            match c {
                '(' => depth += 1,
                ')' => {
                    depth -= 1;
                    if depth == 0 {
                        end = start + k + 1;
                        break;
                    }
                }
                _ => {}
            }
        }
        if end > start {
            blocks.push(&km[start..end]);
            i = end;
        } else {
            break;
        }
    }
    blocks
}

/// Parse every real (named) pad out of a `.kicad_mod`, preserving the true
/// per-side positions, sizes, and rotations. Mechanical pads (empty name) and
/// pads missing position/size are skipped.
fn parse_mod_pads(km: &str) -> Vec<ModPad> {
    // Groups: 1/2 = pad name (quoted/bare), 3 = type (smd/thru_hole), 4 = SHAPE
    // (circle/oval/rect/roundrect/…) — the shape word we were previously dropping.
    let name_re = regex_lite::Regex::new(r#"^\(pad\s+(?:"([^"]*)"|(\S+))\s+(\S+)\s+(\S+)"#).unwrap();
    let at_re = regex_lite::Regex::new(r#"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\s*\)"#).unwrap();
    let size_re = regex_lite::Regex::new(r#"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\)"#).unwrap();
    let drill_re = regex_lite::Regex::new(r#"\(drill\s+(-?[\d.]+)"#).unwrap();

    let mut pads = Vec::new();
    for block in sexpr_blocks(km, "pad") {
        let Some(nc) = name_re.captures(block) else { continue };
        let name = nc.get(1).or_else(|| nc.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();
        if name.is_empty() {
            continue;
        }
        let smd = nc.get(3).map(|m| m.as_str()).unwrap_or("") == "smd";
        let Some(at) = at_re.captures(block) else { continue };
        let Some(size) = size_re.captures(block) else { continue };
        let x: f64 = at[1].parse().unwrap_or(0.0);
        let y: f64 = at[2].parse().unwrap_or(0.0);
        let angle: f64 = at.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0);
        let dx: f64 = size[1].parse().unwrap_or(0.0);
        let dy: f64 = size[2].parse().unwrap_or(0.0);
        let drill = drill_re.captures(block).and_then(|c| c[1].parse().ok());
        // KiCad pad shape → EAGLE pad shape.
        let shape = match nc.get(4).map(|m| m.as_str()).unwrap_or("") {
            "circle" => "round",
            "rect" => "square",
            "oval" => "long",
            "roundrect" | "custom" => "round",
            _ => "round",
        }.to_string();
        pads.push(ModPad { name, smd, x, y, dx, dy, angle, drill, shape });
    }
    pads
}

/// Map a KiCad pad rotation (degrees) to an EAGLE `rot=` attribute, matching
/// Fusion 360's own library export convention: the KiCad angle maps directly
/// (KiCad 90 → `R90`), so a non-square pad on a QFP/SOIC top/bottom side is
/// oriented along the lead. Rect pads are 180°-symmetric so the Y-flip sense
/// is moot; the direct mapping is what Fusion writes (verified by the Fusion
/// bridge against a round-tripped `.flbr`).
fn smd_rotation(angle: f64) -> &'static str {
    let a = ((angle.round() as i64) % 360 + 360) % 360;
    match a {
        90 => " rot=\"R90\"",
        180 => " rot=\"R180\"",
        270 => " rot=\"R270\"",
        _ => "",
    }
}

/// Bounding box (min_x, min_y, max_x, max_y) of `fp_line` endpoints on the
/// given layer, in KiCad coordinates. Each `fp_line` is extracted as a balanced
/// block and matched against its OWN `(layer …)`, so coordinates never leak
/// across layers. Returns `None` if the layer has no lines.
fn fp_line_bbox(km: &str, layer: &str) -> Option<(f64, f64, f64, f64)> {
    let pt_re = regex_lite::Regex::new(r#"\((?:start|end)\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();
    let layer_re = regex_lite::Regex::new(r#"\(layer\s+"([^"]+)"\)"#).unwrap();
    let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    let mut found = false;
    for block in sexpr_blocks(km, "fp_line") {
        if layer_re.captures(block).map(|c| c[1].to_string()).as_deref() != Some(layer) {
            continue;
        }
        for c in pt_re.captures_iter(block) {
            let x: f64 = c[1].parse().unwrap_or(0.0);
            let y: f64 = c[2].parse().unwrap_or(0.0);
            minx = minx.min(x); miny = miny.min(y);
            maxx = maxx.max(x); maxy = maxy.max(y);
            found = true;
        }
    }
    found.then_some((minx, miny, maxx, maxy))
}

/// Derive the package silkscreen outline bbox from the `.kicad_mod` body.
/// Prefers the silkscreen layer, falls back to fab, then to the pad extents.
fn package_outline(km: &str, pads: &[ModPad]) -> (f64, f64, f64, f64) {
    fp_line_bbox(km, "F.SilkS")
        .or_else(|| fp_line_bbox(km, "F.Fab"))
        .unwrap_or_else(|| {
            let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
            for p in pads {
                minx = minx.min(p.x - p.dx); miny = miny.min(p.y - p.dy);
                maxx = maxx.max(p.x + p.dx); maxy = maxy.max(p.y + p.dy);
            }
            (minx, miny, maxx, maxy)
        })
}

/// Flatten a 3-point (start → mid → end) circular arc into a polyline of `segs`
/// segments. Falls back to the three literal points when they're collinear.
fn flatten_arc(start: (f64, f64), mid: (f64, f64), end: (f64, f64), segs: usize) -> Vec<(f64, f64)> {
    use std::f64::consts::PI;
    let ((ax, ay), (bx, by), (cx, cy)) = (start, mid, end);
    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
    if d.abs() < 1e-9 {
        return vec![start, mid, end];
    }
    let ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
    let uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
    let r = ((ax - ux).powi(2) + (ay - uy).powi(2)).sqrt();
    let a0 = (ay - uy).atan2(ax - ux);
    let a1 = (cy - uy).atan2(cx - ux);
    let am = (by - uy).atan2(bx - ux);
    let norm = |mut t: f64| { while t < 0.0 { t += 2.0 * PI; } while t >= 2.0 * PI { t -= 2.0 * PI; } t };
    let ccw_end = norm(a1 - a0);           // CCW sweep from start→end
    let ccw_mid = norm(am - a0);           // CCW offset of the mid point
    // If mid lies within the CCW sweep, go CCW; else go CW the other way round.
    let (dir, sweep) = if ccw_mid <= ccw_end { (1.0, ccw_end) } else { (-1.0, 2.0 * PI - ccw_end) };
    (0..=segs).map(|i| {
        let a = a0 + dir * sweep * (i as f64 / segs as f64);
        (ux + r * a.cos(), uy + r * a.sin())
    }).collect()
}

/// Map a KiCad graphics layer to its EAGLE package-layer number. Only silk/fab
/// are drawn in the package outline; copper/mask/paste/courtyard are skipped.
fn kicad_layer_to_eagle(klayer: &str) -> Option<u32> {
    match klayer {
        "F.SilkS" => Some(21),
        "B.SilkS" => Some(22),
        "F.Fab" => Some(51),
        "B.Fab" => Some(52),
        _ => None,
    }
}

/// Transcribe the footprint's REAL silkscreen/fab graphics — `fp_circle`,
/// `fp_rect`, `fp_line`, `fp_arc` — into EAGLE `<circle>`/`<wire>` on the mapped
/// layer (21 tPlace / 51 tDocu / …), Y negated (KiCad Y-down → EAGLE Y-up).
/// Returns "" when the footprint carries no silk/fab graphics, so the caller can
/// fall back to a bbox rectangle.
fn package_graphics(km: &str) -> String {
    let layer_re = regex_lite::Regex::new(r#"\(layer\s+"([^"]+)"\)"#).unwrap();
    let center_re = regex_lite::Regex::new(r#"\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();
    let start_re = regex_lite::Regex::new(r#"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();
    let mid_re = regex_lite::Regex::new(r#"\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();
    let end_re = regex_lite::Regex::new(r#"\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();
    let width_re = regex_lite::Regex::new(r#"\(width\s+(-?[\d.]+)\)"#).unwrap();
    let el_of = |b: &str| layer_re.captures(b).and_then(|c| kicad_layer_to_eagle(&c[1]));
    let w_of = |b: &str| width_re.captures(b).and_then(|c| c[1].parse::<f64>().ok()).unwrap_or(0.127);
    let f = |re: &regex_lite::Regex, b: &str| re.captures(b).map(|c| (c[1].parse::<f64>().unwrap_or(0.0), c[2].parse::<f64>().unwrap_or(0.0)));

    let mut out = String::new();
    for b in sexpr_blocks(km, "fp_circle") {
        let (Some(el), Some((cx, cy)), Some((ex, ey))) = (el_of(b), f(&center_re, b), f(&end_re, b)) else { continue };
        let radius = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt();
        out.push_str(&format!("  <circle x=\"{:.4}\" y=\"{:.4}\" radius=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", cx, -cy, radius, w_of(b), el));
    }
    for b in sexpr_blocks(km, "fp_rect") {
        let (Some(el), Some((x1, y1r)), Some((x2, y2r))) = (el_of(b), f(&start_re, b), f(&end_re, b)) else { continue };
        let (y1, y2, w) = (-y1r, -y2r, w_of(b));
        for (ax, ay, bx, by) in [(x1, y1, x2, y1), (x2, y1, x2, y2), (x2, y2, x1, y2), (x1, y2, x1, y1)] {
            out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", ax, ay, bx, by, w, el));
        }
    }
    for b in sexpr_blocks(km, "fp_line") {
        let (Some(el), Some((sx, sy)), Some((ex, ey))) = (el_of(b), f(&start_re, b), f(&end_re, b)) else { continue };
        out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", sx, -sy, ex, -ey, w_of(b), el));
    }
    for b in sexpr_blocks(km, "fp_arc") {
        let (Some(el), Some(s), Some(m), Some(e)) = (el_of(b), f(&start_re, b), f(&mid_re, b), f(&end_re, b)) else { continue };
        let w = w_of(b);
        for win in flatten_arc(s, m, e, 12).windows(2) {
            out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", win[0].0, -win[0].1, win[1].0, -win[1].1, w, el));
        }
    }
    out
}

/// Resolved `--symbol-art` input. PREFERRED: an adom-symbol OUTLINE CONTRACT
/// (`<mpn>-symbol-outline.mm.svg` + `<mpn>-symbol-outline.json` sidecar) whose
/// polylines are ALREADY placed in the symbol's mm space; the sidecar gives the
/// exact Y-up transform, so it's transcribed 1:1 with zero guessing. Anything
/// else is a `Legacy` raw outline we centre + scale by eye.
enum SymbolArt {
    Contract { mm_svg: String, dx: f64, svg_cy: f64, e_cy: f64 },
    Legacy(String),
}

/// Resolve a `--symbol-art` path. A `*.mm.svg` with a readable `*.json` sidecar
/// is a contract; otherwise legacy.
fn resolve_symbol_art(path: &std::path::Path) -> Result<SymbolArt, String> {
    let content = fs::read_to_string(path).map_err(|e| format!("Cannot read symbol art {}: {}", path.display(), e))?;
    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
    if name.ends_with(".mm.svg") {
        let json_path = path.with_file_name(name.replace(".mm.svg", ".json"));
        if let Ok(js) = fs::read_to_string(&json_path) {
            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&js) {
                let g = |a: &str, b: &str| v.get(a).and_then(|o| o.get(b)).and_then(|n| n.as_f64());
                if let (Some(svg_cx), Some(svg_cy), Some(e_cx), Some(e_cy)) = (
                    g("placement_svg_mm", "center_x"), g("placement_svg_mm", "center_y"),
                    g("placement_eagle_mm", "center_x"), g("placement_eagle_mm", "center_y"),
                ) {
                    return Ok(SymbolArt::Contract { mm_svg: content, dx: svg_cx - e_cx, svg_cy, e_cy });
                }
            }
        }
        eprintln!("WARN: {name} looks like an outline contract but its .json sidecar is missing/unreadable — falling back to centre+scale guessing.");
    }
    Ok(SymbolArt::Legacy(content))
}

/// Transcribe a contract `.mm.svg` (symbol mm-space, Y-down) to EAGLE `<wire>`s
/// (Y-up), 1:1: `eagle_x = svg_x - dx`, `eagle_y = e_cy - (svg_y - svg_cy)`.
fn contract_wires(mm_svg: &str, dx: f64, svg_cy: f64, e_cy: f64) -> (String, usize) {
    let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();
    let mut out = String::new();
    let mut segs = 0usize;
    for c in poly_re.captures_iter(mm_svg) {
        let closed = &c[1] == "polygon";
        let mut pts: Vec<(f64, f64)> = c[2].split_whitespace().filter_map(|p| {
            let mut it = p.split(',');
            let x: f64 = it.next()?.trim().parse().ok()?;
            let y: f64 = it.next()?.trim().parse().ok()?;
            Some((x - dx, e_cy - (y - svg_cy)))
        }).collect();
        if closed { if let Some(&first) = pts.first() { pts.push(first); } }
        for w in pts.windows(2) {
            out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));
            segs += 1;
        }
    }
    (out, segs)
}

/// Legacy raw outline SVG → wires: centre + scale to ~`scale_frac` of the body's
/// smaller side, decimate in output space so dense sampling collapses.
fn symbol_art_wires(svg: &str, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {
    let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();
    let mut polys: Vec<Vec<(f64, f64)>> = Vec::new();
    for c in poly_re.captures_iter(svg) {
        let closed = &c[1] == "polygon";
        let mut pts: Vec<(f64, f64)> = c[2].split_whitespace().filter_map(|p| {
            let mut it = p.split(',');
            let x: f64 = it.next()?.trim().parse().ok()?;
            let y: f64 = it.next()?.trim().parse().ok()?;
            Some((x, y))
        }).collect();
        if closed { if let Some(&first) = pts.first() { pts.push(first); } }
        polys.push(pts);
    }
    let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    for poly in &polys { for &(x, y) in poly {
        minx = minx.min(x); miny = miny.min(y); maxx = maxx.max(x); maxy = maxy.max(y);
    }}
    let (sw, sh) = (maxx - minx, maxy - miny);
    if !(sw > 0.0 && sh > 0.0) { return (String::new(), 0); }
    let (target, cx, cy) = match body {
        Some(b) => {
            let bw = (b.right - b.left).abs();
            let bh = (b.top - b.bottom).abs();
            (bw.min(bh) * scale_frac, (b.left + b.right) / 2.0, (b.top + b.bottom) / 2.0)
        }
        None => (26.0 * scale_frac, 0.0, 0.0),
    };
    let scale = target / sw.max(sh);
    let tol_mm = 0.12;
    let (smx, smy) = ((minx + maxx) / 2.0, (miny + maxy) / 2.0);
    let map = |x: f64, y: f64| (cx + (x - smx) * scale, cy - (y - smy) * scale);
    let mut out = String::new();
    let mut segs = 0usize;
    for poly in &polys {
        let mut kept: Vec<(f64, f64)> = Vec::new();
        let n = poly.len();
        for (i, &(px, py)) in poly.iter().enumerate() {
            let pp = map(px, py);
            if i == 0 || i == n - 1 { kept.push(pp); }
            else if let Some(&last) = kept.last() {
                if ((pp.0 - last.0).powi(2) + (pp.1 - last.1).powi(2)).sqrt() >= tol_mm { kept.push(pp); }
            }
        }
        for w in kept.windows(2) {
            out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));
            segs += 1;
        }
    }
    (out, segs)
}

/// Wire block for a resolved symbol-art input.
fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {
    match art {
        SymbolArt::Contract { mm_svg, dx, svg_cy, e_cy } => contract_wires(mm_svg, *dx, *svg_cy, *e_cy),
        SymbolArt::Legacy(svg) => symbol_art_wires(svg, body, scale_frac),
    }
}

fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64) -> String {
    let mut xml = String::with_capacity(8192);

    xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
    xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");
    xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");
    xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");
    xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");

    // Layers
    xml.push_str(EAGLE_LAYERS);

    xml.push_str("<library>\n");
    xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description)));

    // Packages section — generate a placeholder SMD package from pin positions
    xml.push_str("<packages>\n");
    xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(&sym.name)));

    // Real footprint from the .kicad_mod when given; else a placeholder.
    let mod_pads = fp_content.map(parse_mod_pads).unwrap_or_default();
    if !mod_pads.is_empty() {
        let km = fp_content.unwrap();
        xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.name)));
        for p in &mod_pads {
            if p.smd {
                xml.push_str(&format!(
                    "  <smd name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" dx=\"{:.4}\" dy=\"{:.4}\" layer=\"1\"{}/>\n",
                    esc_xml(&p.name), p.x, -p.y, p.dx, p.dy, smd_rotation(p.angle)));
            } else {
                // Round thru-hole pads: real drill + circular diameter (not oblong).
                let drill = p.drill.unwrap_or_else(|| p.dx.min(p.dy) * 0.6);
                let diameter = if p.shape == "round" { p.dx } else { p.dx.max(p.dy) };
                xml.push_str(&format!(
                    "  <pad name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" drill=\"{:.4}\" diameter=\"{:.4}\" shape=\"{}\"{}/>\n",
                    esc_xml(&p.name), p.x, -p.y, drill, diameter, p.shape, smd_rotation(p.angle)));
            }
        }
        // Real silk/fab graphics (circles/lines/arcs); bbox rectangle only as fallback.
        let (minx, miny, maxx, maxy) = package_outline(km, &mod_pads);
        let (sx1, sy1, sx2, sy2) = (minx, -maxy, maxx, -miny);
        let silk = package_graphics(km);
        if silk.is_empty() {
            for (x1, y1, x2, y2) in [(sx1, sy1, sx2, sy1), (sx2, sy1, sx2, sy2), (sx2, sy2, sx1, sy2), (sx1, sy2, sx1, sy1)] {
                xml.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.127\" layer=\"21\"/>\n", x1, y1, x2, y2));
            }
        } else {
            xml.push_str(&silk);
        }
        let mut top = sy2;
        let mut bot = sy1;
        for p in &mod_pads {
            let py = -p.y;
            let r = p.dx.max(p.dy) / 2.0;
            top = top.max(py + r);
            bot = bot.min(py - r);
        }
        xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", top + 0.6));
        xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", bot - 0.6));
    } else {
    xml.push_str(&format!("  <description>{} placeholder footprint</description>\n", esc_xml(&sym.name)));

    // Generate SMD pads from pin numbers
    let pad_size = 0.5;
    let pitch = 0.5;
    let pin_count = sym.pins.len();
    let half = (pin_count + 1) / 2;

    for (i, pin) in sym.pins.iter().enumerate() {
        let (px, py) = if i < half {
            // Left column
            (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)
        } else {
            // Right column
            (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)
        };
        xml.push_str(&format!(
            "  <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",
            esc_xml(&pin.number), px, py, pad_size, pad_size
        ));
    }

    // Package outline
    let body_w = pin_count as f64 * pitch / 2.0 + 1.0;
    let body_h = half as f64 * pitch + 1.0;
    xml.push_str(&format!(
        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",
        -body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0
    ));
    xml.push_str(&format!(
        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",
        body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0
    ));
    xml.push_str(&format!(
        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",
        body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0
    ));
    xml.push_str(&format!(
        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",
        -body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0
    ));

    xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", body_h / 2.0 + 0.5));
    xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", -body_h / 2.0 - 0.5));
    }

    xml.push_str("</package>\n");
    xml.push_str("</packages>\n");

    // Symbols section
    xml.push_str("<symbols>\n");
    xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));

    if let Some(body) = &sym.body {
        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.top, body.right, body.top));
        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.top, body.right, body.bottom));
        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.bottom, body.left, body.bottom));
        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.bottom, body.left, body.top));
    }

    // The chip drawing. A `--symbol-art` SVG is an explicit OVERRIDE; otherwise
    // transcribe the `.kicad_sym`'s OWN (polyline)/(arc) graphics as raw layer-94
    // wires (KiCad symbol space is Y-up, same as the pins → carried through 1:1).
    if let Some(art) = symbol_art {
        let (wires, _) = symbol_art_block(art, sym.body.as_ref(), symbol_art_scale);
        xml.push_str(&wires);
    } else {
        for g in &sym.graphics {
            match g {
                SymGraphic::Poly(pts) => {
                    for w in pts.windows(2) {
                        xml.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));
                    }
                }
                SymGraphic::Arc { start, mid, end } => {
                    let pts = flatten_arc(*start, *mid, *end, 12);
                    for w in pts.windows(2) {
                        xml.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));
                    }
                }
            }
        }
    }

    let name_y = sym.body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0);
    let value_y = sym.body.as_ref().map(|b| b.bottom - 2.54).unwrap_or(-10.0);
    xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"95\" align=\"bottom-center\">&gt;NAME</text>\n", name_y));
    xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">&gt;VALUE</text>\n", value_y));

    let deduped_names = dedup_pin_names(&sym.pins);
    for (i, pin) in sym.pins.iter().enumerate() {
        xml.push_str(&format!(
            "  <pin name=\"{}\" x=\"{}\" y=\"{}\" length=\"{}\" direction=\"{}\"{}/>\n",
            esc_xml(&deduped_names[i]), pin.x, pin.y,
            pin_length_keyword(pin.length),
            pin_direction(&pin.pin_type),
            pin_rotation(pin.angle)
        ));
    }

    let body_center_x = sym.body.as_ref().map(|b| (b.left + b.right) / 2.0).unwrap_or(0.0);
    let body_left = sym.body.as_ref().map(|b| b.left).unwrap_or(-10.0);
    let body_right = sym.body.as_ref().map(|b| b.right).unwrap_or(10.0);
    for label in &sym.group_labels {
        let (lx, align) = if label.x > body_center_x {
            (body_right - 1.0, " align=\"right\"")
        } else {
            (body_left + 1.0, "")
        };
        xml.push_str(&format!(
            "  <text x=\"{}\" y=\"{}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{}>{}</text>\n",
            lx, label.y, align, esc_xml(&label.text)
        ));
    }

    xml.push_str("</symbol>\n");
    xml.push_str("</symbols>\n");

    // Deviceset with COMPLETE device (package + connects)
    let ref_prefix = if sym.reference.is_empty() { "U" } else { &sym.reference };
    xml.push_str("<devicesets>\n");
    xml.push_str(&format!("<deviceset name=\"{}\" prefix=\"{}\">\n", esc_xml(&sym.name), esc_xml(ref_prefix)));
    xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.description)));
    xml.push_str("  <gates>\n");
    xml.push_str(&format!("    <gate name=\"G$1\" symbol=\"{}\" x=\"0\" y=\"0\"/>\n", esc_xml(&sym.name)));
    xml.push_str("  </gates>\n");
    xml.push_str("  <devices>\n");
    xml.push_str(&format!("    <device name=\"\" package=\"{}\">\n", esc_xml(&sym.name)));
    xml.push_str("      <connects>\n");

    // Generate connect mappings: gate pin name → pad number
    for (i, pin) in sym.pins.iter().enumerate() {
        xml.push_str(&format!(
            "        <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",
            esc_xml(&deduped_names[i]), esc_xml(&pin.number)
        ));
    }

    xml.push_str("      </connects>\n");
    xml.push_str("      <technologies><technology name=\"\"/></technologies>\n");
    xml.push_str("    </device>\n");
    xml.push_str("  </devices>\n");
    xml.push_str("</deviceset>\n");
    xml.push_str("</devicesets>\n");

    xml.push_str("</library>\n</drawing>\n</eagle>");
    xml
}

// ── Linter ──

#[derive(Debug, Serialize)]
pub(crate) struct LintResult {
    pub passed: bool,
    pub errors: Vec<LintError>,
    pub warnings: Vec<LintError>,
}

#[derive(Debug, Serialize)]
pub(crate) struct LintError {
    pub code: String,
    pub message: String,
}

pub(crate) fn lint_lbr(content: &str) -> LintResult {
    let mut errors = Vec::new();
    let mut warnings = Vec::new();

    if !content.contains("<packages>") || !content.contains("</packages>") {
        errors.push(LintError { code: "missing-packages".into(), message: "No <packages> section found".into() });
    }
    if !content.contains("<symbols>") || !content.contains("</symbols>") {
        errors.push(LintError { code: "missing-symbols".into(), message: "No <symbols> section found".into() });
    }
    if !content.contains("<devicesets>") || !content.contains("</devicesets>") {
        errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });
    }

    // Check for empty packages
    if content.contains("<packages></packages>") || content.contains("<packages/>") {
        errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });
    }

    // Check for empty connects
    if content.contains("<connects/>") || content.contains("<connects></connects>") {
        errors.push(LintError { code: "empty-connects".into(), message: "Device has no pin-to-pad connect mappings — Fusion cannot use this component".into() });
    }

    // Check device has a package attribute
    if content.contains("<device name=\"\">") && !content.contains("package=") {
        errors.push(LintError { code: "device-no-package".into(), message: "Device has no package attribute — Fusion needs a package to show the component".into() });
    }

    // Per-symbol pin analysis. Pin names must be NON-EMPTY (Fusion rejects
    // <pin name="">) and UNIQUE WITHIN a symbol. Across symbols, a repeat is
    // LEGAL — e.g. a merged multi-part lib where four 1-pin symbols each have a
    // pin "1" — so the uniqueness check is scoped to each <symbol> block, not
    // the whole file.
    let sym_block_re = regex_lite::Regex::new(r#"(?s)<symbol name="([^"]*)">(.*?)</symbol>"#).unwrap();
    let pin_name_re = regex_lite::Regex::new(r#"<pin name="([^"]*)""#).unwrap();
    let mut sym_pins: std::collections::HashMap<String, std::collections::HashSet<String>> =
        std::collections::HashMap::new();
    for sc in sym_block_re.captures_iter(content) {
        let sname = sc[1].to_string();
        let body = &sc[2];
        let mut within: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
        let mut set = std::collections::HashSet::new();
        for pc in pin_name_re.captures_iter(body) {
            let pname = pc[1].to_string();
            if pname.trim().is_empty() {
                errors.push(LintError {
                    code: "empty-pin-name".into(),
                    message: format!("Symbol '{}' has a <pin name=\"\"> — Fusion rejects empty pin names (fall back to the pad number)", sname),
                });
                continue;
            }
            *within.entry(pname.clone()).or_insert(0) += 1;
            set.insert(pname);
        }
        for (name, count) in &within {
            if *count > 1 {
                errors.push(LintError {
                    code: "duplicate-pin-name".into(),
                    message: format!("Pin name '{}' appears {} times in symbol '{}' — EAGLE requires unique pin names within a symbol (use @suffix)", name, count, sname),
                });
            }
        }
        sym_pins.insert(sname, set);
    }

    // Connect integrity: no empty pin=, and every connect's pin must resolve to a
    // named <pin> in its gate's symbol. Resolve per-deviceset (gate names like
    // G$1 repeat across devicesets, so a global map would cross-wire them).
    let deviceset_re = regex_lite::Regex::new(r#"(?s)<deviceset\b.*?</deviceset>"#).unwrap();
    let gate_re = regex_lite::Regex::new(r#"<gate name="([^"]*)" symbol="([^"]*)""#).unwrap();
    let connect_full_re = regex_lite::Regex::new(r#"<connect gate="([^"]*)" pin="([^"]*)" pad="([^"]*)"/>"#).unwrap();
    for ds in deviceset_re.captures_iter(content) {
        let block = &ds[0];
        let mut gate_sym: std::collections::HashMap<String, String> = std::collections::HashMap::new();
        for g in gate_re.captures_iter(block) {
            gate_sym.insert(g[1].to_string(), g[2].to_string());
        }
        for c in connect_full_re.captures_iter(block) {
            let (gate, pin) = (c[1].to_string(), c[2].to_string());
            if pin.trim().is_empty() {
                errors.push(LintError {
                    code: "empty-connect-pin".into(),
                    message: "A <connect> has pin=\"\" — Fusion rejects empty connect pins (map to the gate's pin name)".into(),
                });
                continue;
            }
            if let Some(sym) = gate_sym.get(&gate) {
                if let Some(names) = sym_pins.get(sym) {
                    if !names.contains(&pin) {
                        errors.push(LintError {
                            code: "connect-pin-unresolved".into(),
                            message: format!("Connect pin '{}' (gate '{}') matches no <pin name> in symbol '{}' — Fusion cannot bind it", pin, gate, sym),
                        });
                    }
                }
            }
        }
    }

    // Check pad names match pin numbers in connects
    let connect_re = regex_lite::Regex::new(r#"<connect[^>]*pad="([^"]+)""#).unwrap();
    let smd_re = regex_lite::Regex::new(r#"<smd name="([^"]+)""#).unwrap();
    let pad_names: std::collections::HashSet<_> = smd_re.captures_iter(content).map(|c| c[1].to_string()).collect();
    for cap in connect_re.captures_iter(content) {
        let pad = &cap[1];
        if !pad_names.contains(pad) && !pad_names.is_empty() {
            warnings.push(LintError {
                code: "connect-pad-missing".into(),
                message: format!("Connect references pad '{}' which doesn't exist in the package", pad),
            });
        }
    }

    // Check XML is well-formed
    if content.matches('<').count() != content.matches('>').count() {
        errors.push(LintError { code: "malformed-xml".into(), message: "Unbalanced XML angle brackets".into() });
    }

    // Fusion-specific checks
    if !content.contains("layer=\"94\"") {
        warnings.push(LintError { code: "no-symbol-layer".into(), message: "No elements on layer 94 (Symbols) — the symbol body may be invisible".into() });
    }

    LintResult {
        passed: errors.is_empty(),
        errors,
        warnings,
    }
}

// ── Layers constant ──

const EAGLE_LAYERS: &str = r#"<layers>
<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>
<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>
<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>
<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>
<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>
<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>
<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>
<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>
<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>
<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>
<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>
<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>
<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>
<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>
<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>
<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>
<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>
<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>
<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>
<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>
<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>
<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>
<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>
<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>
<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>
<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>
<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>
<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>
<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>
<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>
<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>
<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>
<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>
<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>
<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>
<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>
<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>
<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>
<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>
<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>
<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>
<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>
<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>
</layers>
"#;

// ── Main ──

/// Read a text file or exit with a clear error.
fn read_or_exit(path: &std::path::Path) -> String {
    fs::read_to_string(path).unwrap_or_else(|e| {
        eprintln!("ERROR: read {}: {e}", path.display());
        process::exit(1);
    })
}

/// Load an adom-lbr JSON into the canonical part.
fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart {
    let json = read_or_exit(path);
    serde_json::from_str(&json).unwrap_or_else(|e| {
        eprintln!("ERROR: parse adom-lbr JSON {}: {e}", path.display());
        process::exit(1);
    })
}

/// Seed the symbol's Altium property fields from the part identity (Value drawn;
/// Manufacturer/MPN/Package hidden), skipping empties + names already present.
fn seed_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) {
    use altium_codec::Parameter;
    let mut push = |name: &str, value: &str, visible: bool| {
        if value.is_empty()
            || symbol.parameters.iter().any(|p| p.name.eq_ignore_ascii_case(name))
        {
            return;
        }
        symbol.parameters.push(Parameter { name: name.into(), value: value.into(), visible });
    };
    push("Value", &part.value, true);
    push("Manufacturer", &part.manufacturer, false);
    push("Manufacturer Part Number", &part.mpn, false);
    push("Package", &part.package, false);
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale } => {
            let fp_content = fp.as_ref().map(|p| {
                fs::read_to_string(p).unwrap_or_else(|e| {
                    eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);
                    process::exit(1);
                })
            });

            let parsed = match (&sym, &fp_content) {
                (Some(sym_path), _) => {
                    let sym_content = fs::read_to_string(sym_path).unwrap_or_else(|e| {
                        eprintln!("ERROR: Cannot read {}: {}", sym_path.display(), e);
                        eprintln!("Hint: Check the .kicad_sym path exists.");
                        process::exit(1);
                    });
                    parse_kicad_sym(&sym_content, name.as_deref()).unwrap_or_else(|e| {
                        eprintln!("ERROR: {}", e);
                        process::exit(1);
                    })
                }
                (None, Some(fp)) => {
                    // Footprint-only: synthesize a placeable symbol from the pads.
                    let syn_name = name.clone()
                        .or_else(|| first_footprint_name(fp))
                        .unwrap_or_else(|| "FOOTPRINT".to_string());
                    synth_symbol_from_mod(fp, &syn_name)
                }
                (None, None) => {
                    eprintln!("ERROR: Generate needs at least --sym or --fp.");
                    eprintln!("Hint: `adom-lbr generate --fp part.kicad_mod` synthesizes a symbol from the pads.");
                    process::exit(1);
                }
            };

            let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));
            let art = match symbol_art.as_ref().map(|p| resolve_symbol_art(p)) {
                Some(Ok(a)) => Some(a),
                Some(Err(e)) => { eprintln!("ERROR: {e}"); process::exit(1); }
                None => None,
            };
            let lbr = generate_lbr(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale);

            // Lint before writing
            let lint = lint_lbr(&lbr);
            for err in &lint.errors {
                eprintln!("LINT ERROR: [{}] {}", err.code, err.message);
            }
            for warn in &lint.warnings {
                eprintln!("LINT WARN: [{}] {}", warn.code, warn.message);
            }

            let out_path = output.unwrap_or_else(|| {
                out_default.with_extension("lbr")
            });

            fs::write(&out_path, &lbr).unwrap_or_else(|e| {
                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);
                process::exit(1);
            });

            println!("OK: Generated {} ({} pins, {} bytes)", out_path.display(), parsed.pins.len(), lbr.len());
            println!("HINT: Lint {} with {} error(s), {} warning(s)", if lint.passed { "PASSED" } else { "FAILED" }, lint.errors.len(), lint.warnings.len());

            if !lint.passed {
                process::exit(2);
            }
        }

        Commands::Lint { path } => {
            let content = fs::read_to_string(&path).unwrap_or_else(|e| {
                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);
                eprintln!("Hint: Check the path exists. Use `ls` to verify.");
                process::exit(1);
            });

            let result = lint_lbr(&content);
            if result.passed {
                println!("OK: Lint passed with 0 error(s), {} warning(s)", result.warnings.len());
            } else {
                eprintln!("ERROR: Lint failed with {} error(s)", result.errors.len());
                for err in &result.errors {
                    eprintln!("  [{}] {}", err.code, err.message);
                }
                eprintln!("Hint: Fix the errors above, then re-run `adom-lbr lint {}`", path.display());
            }
            for warn in &result.warnings {
                eprintln!("WARN: [{}] {}", warn.code, warn.message);
            }
            // Also output JSON for programmatic consumers
            let json = serde_json::to_string_pretty(&result).unwrap();
            println!("{}", json);

            if !result.passed {
                process::exit(2);
            }
        }

        Commands::Validate { path } => {
            let content = fs::read_to_string(&path).unwrap_or_else(|e| {
                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);
                process::exit(1);
            });

            let result = lint_lbr(&content);
            if result.passed {
                println!("OK: {} is a valid EAGLE .lbr with complete deviceset", path.display());
            } else {
                eprintln!("FAIL: {} has {} error(s)", path.display(), result.errors.len());
                for err in &result.errors {
                    eprintln!("  [{}] {}", err.code, err.message);
                }
                process::exit(2);
            }
        }

        Commands::Import { path, output } => {
            let content = fs::read_to_string(&path).unwrap_or_else(|e| {
                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);
                process::exit(1);
            });

            let parsed = parse_eagle_lbr(&content).unwrap_or_else(|e| {
                eprintln!("ERROR: {}", e);
                process::exit(1);
            });

            let kicad_sym = generate_kicad_sym(&parsed);

            let out_path = output.unwrap_or_else(|| {
                path.with_extension("kicad_sym")
            });

            fs::write(&out_path, &kicad_sym).unwrap_or_else(|e| {
                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);
                process::exit(1);
            });

            println!("OK: Imported {} → {} ({} pins)", path.display(), out_path.display(), parsed.pins.len());
            println!("HINT: Run 'adom-lbr check {}' to validate via service-kicad", out_path.display());
        }

        Commands::Check { path } => {
            let content = fs::read_to_string(&path).unwrap_or_else(|e| {
                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);
                process::exit(1);
            });

            let mut errors: Vec<String> = Vec::new();

            if !content.contains("(kicad_symbol_lib") {
                errors.push("Missing (kicad_symbol_lib) header — not a valid .kicad_sym file".into());
            }
            if !content.contains("(symbol \"") {
                errors.push("No (symbol) definitions found".into());
            }

            let pin_count = content.matches("(pin ").count();
            if pin_count == 0 {
                errors.push("No pins found — symbol has no electrical connections".into());
            }

            if !content.contains("(property \"Reference\"") {
                errors.push("Missing Reference property".into());
            }
            if !content.contains("(property \"Value\"") {
                errors.push("Missing Value property".into());
            }

            // Check for duplicate pin names within each symbol
            let sym_re = regex_lite::Regex::new(r#"\(pin \w+ \w+ \(at [^)]+\) \(length [^)]+\) \(name "([^"]+)""#).unwrap();
            let mut pin_names: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
            for cap in sym_re.captures_iter(&content) {
                *pin_names.entry(cap[1].to_string()).or_insert(0) += 1;
            }
            let dups: Vec<_> = pin_names.iter().filter(|(_, &c)| c > 1).collect();
            if !dups.is_empty() && !dups.iter().all(|(n, _)| n.contains('@') || n.as_str() == "~") {
                let dup_list: Vec<_> = dups.iter().filter(|(n, _)| !n.contains('@') && n.as_str() != "~").map(|(n, c)| format!("{} (x{})", n, c)).collect();
                if !dup_list.is_empty() {
                    // Duplicate pin names are valid in KiCad for power pins (DVDD, IOVDD, GND).
                    // Warn but don't error — EAGLE import will need @N suffixes.
                    eprintln!("WARN: Duplicate pin names (will need @N suffix for EAGLE): {}", dup_list.join(", "));
                }
            }

            if errors.is_empty() {
                println!("OK: {} validated — {} pins, {} unique pin names", path.display(), pin_count, pin_names.len());
            } else {
                eprintln!("ERROR: {} failed validation with {} error(s)", path.display(), errors.len());
                for e in &errors {
                    eprintln!("  {}", e);
                }
                eprintln!("Hint: Fix the errors above, then re-run `adom-lbr check {}`", path.display());
                process::exit(2);
            }
        }

        Commands::Embed { dir, out } => {
            if let Err(e) = cmd_embed(&dir, out) {
                eprintln!("ERROR: {e:#}");
                process::exit(1);
            }
        }
        Commands::Manage { port } => {
            if let Err(e) = manage::run(port) {
                eprintln!("ERROR: {e}");
                process::exit(1);
            }
        }
        Commands::Serve { port, file } => {
            if let Some(f) = file {
                match fs::read_to_string(&f) {
                    Ok(content) => server::set_state(server::analyze_lbr(&content)),
                    Err(e) => {
                        eprintln!("ERROR: Cannot read {}: {}", f.display(), e);
                        process::exit(1);
                    }
                }
            }
            if let Err(e) = server::run(port) {
                eprintln!("ERROR: {e:#}");
                process::exit(1);
            }
        }

        Commands::ImportKicad { sym, fp, mpn, out } => {
            let sym_txt = read_or_exit(&sym);
            let fp_txt = read_or_exit(&fp);
            let part = altium_codec::import_kicad(&sym_txt, &fp_txt, &mpn)
                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });
            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{mpn}.adom-lbr.json")));
            fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();
            println!("OK: wrote {} (adom-lbr JSON — the canonical hub)", outp.display());
            println!("Hint: convert it with `adom-lbr export-altium {}` or `export-intlib`.", outp.display());
        }

        Commands::ExportAltium { part, out_dir } => {
            let p = load_part(&part);
            let mut symbol = p.symbol.clone();
            seed_parameters(&mut symbol, &p);
            let sym_bytes = altium_codec::encode_schlib(&[symbol])
                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });
            let fp_bytes = altium_codec::encode_pcblib(&p.footprint)
                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });
            let sp = out_dir.join(format!("{}.SchLib", p.mpn));
            let pp = out_dir.join(format!("{}.PcbLib", p.mpn));
            fs::write(&sp, &sym_bytes).ok();
            fs::write(&pp, &fp_bytes).ok();
            println!("OK: wrote {} ({} pins) + {} ({} pads)",
                sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());
        }

        Commands::ExportIntlib { part, out } => {
            let p = load_part(&part);
            let mut symbol = p.symbol.clone();
            seed_parameters(&mut symbol, &p);
            let descr = if p.value.is_empty() { p.mpn.clone() } else { format!("{} {}", p.value, p.package) };
            let bytes = altium_codec::encode_intlib(&symbol, &p.footprint, &descr)
                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });
            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{}.IntLib", p.mpn)));
            fs::write(&outp, &bytes).ok();
            println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());
        }

        Commands::ExportKicad { part, out_dir } => {
            let p = load_part(&part);
            let mut symbol = p.symbol.clone();
            seed_parameters(&mut symbol, &p);
            let sym_txt = altium_codec::export_kicad_symbol(&symbol);
            let fp_txt = altium_codec::export_kicad_footprint(&p.footprint);
            let sp = out_dir.join(format!("{}.kicad_sym", p.mpn));
            let fp = out_dir.join(format!("{}.kicad_mod", p.mpn));
            fs::write(&sp, sym_txt).ok();
            fs::write(&fp, fp_txt).ok();
            println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)",
                sp.display(), p.symbol.pins.len(), fp.display(),
                p.footprint.pads.len(), p.footprint.graphics.len());
        }

        Commands::AddToSchlib { existing, part, out } => {
            let existing_bytes = fs::read(&existing)
                .unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", existing.display()); process::exit(1); });
            let json = read_or_exit(&part);
            let syms: Vec<altium_codec::Symbol> =
                if let Ok(p) = serde_json::from_str::<altium_codec::AdomLbrPart>(&json) {
                    vec![p.symbol]
                } else if let Ok(v) = serde_json::from_str::<Vec<altium_codec::Symbol>>(&json) {
                    v
                } else {
                    vec![serde_json::from_str::<altium_codec::Symbol>(&json)
                        .unwrap_or_else(|e| { eprintln!("ERROR: parse Symbol/AdomLbrPart: {e}"); process::exit(1); })]
                };
            let merged = altium_codec::add_symbols_to_schlib(&existing_bytes, &syms)
                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });
            let outp = out.unwrap_or_else(|| PathBuf::from("merged.SchLib"));
            fs::write(&outp, &merged).ok();
            println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());
        }

        Commands::ImportAltium { file, pcblib, schlib, mpn, out } => {
            let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
            let read = |p: &std::path::Path| -> Vec<u8> {
                fs::read(p).unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", p.display()); process::exit(1); })
            };
            let part = if ext == "intlib" {
                altium_codec::decode_intlib_part(&read(&file), &mpn)
            } else if ext == "pcblib" {
                let sch = schlib.as_ref().map(|p| read(p));
                altium_codec::decode_altium(sch.as_deref(), Some(&read(&file)), &mpn)
            } else {
                // treat as .SchLib; auto-find the .PcbLib next to it if not given
                let pcb_path = pcblib.or_else(|| {
                    let cand = file.with_extension("PcbLib");
                    cand.exists().then_some(cand)
                });
                let pcb = pcb_path.as_ref().map(|p| read(p));
                altium_codec::decode_altium(Some(&read(&file)), pcb.as_deref(), &mpn)
            }
            .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });
            let name = if part.mpn.is_empty() { "part".to_string() } else { part.mpn.clone() };
            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{name}.adom-lbr.json")));
            fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();
            println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",
                outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());
        }

        Commands::Install => {
            let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());
            let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-lbr");
            fs::create_dir_all(&skill_dir).ok();
            fs::write(skill_dir.join("SKILL.md"), SKILL).ok();
            println!("Installed skill: adom-lbr -> {}", skill_dir.display());
            println!("Installed adom-lbr v{}", VERSION.trim());
        }

        Commands::Health => {
            println!("OK: adom-lbr v{}", VERSION.trim());
        }

        Commands::Completions { shell } => {
            use clap::CommandFactory;
            clap_complete::generate(shell, &mut Cli::command(), "adom-lbr", &mut std::io::stdout());
        }
    }
}

// ── `embed`: Fusion-style 3-column interactive HTML package ──

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

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

/// (human label, hex color) for a ds2sf/kicad pin type token.
fn pin_type_style(t: &str) -> (&'static str, &'static str) {
    match t.to_lowercase().replace('-', "_").as_str() {
        "power_in" | "power_out" | "power" => ("Power", "#ef5350"),
        "input" | "in" => ("Input", "#26c6da"),
        "output" | "out" => ("Output", "#66bb6a"),
        "bidirectional" | "bidi" | "io" => ("Bidir", "#ab47bc"),
        "open_collector" | "open_emitter" => ("Open", "#ffa726"),
        "tri_state" => ("Tri-state", "#ffa726"),
        "passive" => ("Passive", "#90a4ae"),
        "unspecified" | "" => ("—", "#90a4ae"),
        "no_connect" | "nc" | "unconnected" => ("NC", "#5b6673"),
        _ => ("Signal", "#80cbc4"),
    }
}

fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> {
    for n in names {
        if let Ok(s) = fs::read_to_string(dir.join(n)) {
            return Some(s);
        }
    }
    None
}

/// Find the first file in `dir` whose name ends with `suffix` (case-insensitive
/// on the suffix), returning its path. Results are sorted for determinism.
///
/// ds2sf names its emitted files by the MPN / symbolName (e.g.
/// `74HC595BQ,115-symbol.extracted.json`), which frequently differs — in case or
/// exact form — from the chip *directory* name (e.g. `74hc595bq-dhvqfn16`). So
/// deriving the filename from the dir name misses the file entirely. Matching by
/// suffix finds it regardless of the prefix.
fn find_by_suffix(dir: &std::path::Path, suffix: &str) -> Option<PathBuf> {
    let suf = suffix.to_lowercase();
    let mut hits: Vec<PathBuf> = fs::read_dir(dir).ok()?
        .filter_map(|e| e.ok().map(|e| e.path()))
        .filter(|p| p.file_name().and_then(|n| n.to_str())
            .map(|n| n.to_lowercase().ends_with(&suf)).unwrap_or(false))
        .collect();
    hits.sort();
    hits.into_iter().next()
}

/// Read a dir file by exact name (any of `names`) first, then fall back to the
/// first file matching `suffix`. Robust to MPN-vs-dirname prefix drift.
fn read_first_or_suffix(dir: &std::path::Path, names: &[String], suffix: &str) -> Option<String> {
    read_first(dir, names)
        .or_else(|| find_by_suffix(dir, suffix).and_then(|p| fs::read_to_string(p).ok()))
}

fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> {
    let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();
    // renders
    let sym_svg = read_first_or_suffix(dir, &[format!("{mpn}-symbol.svg")], "-symbol.svg")
        .unwrap_or_else(|| "<div class=\"ph\">no symbol render in this directory</div>".into());
    let fp_svg = read_first(dir,
            &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])
        .or_else(|| find_by_suffix(dir, "-footprint.authoritative.svg").and_then(|p| fs::read_to_string(p).ok()))
        .or_else(|| find_by_suffix(dir, "-footprint.svg").and_then(|p| fs::read_to_string(p).ok()))
        .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());
    let threed = {
        let cands = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]
            .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists())
            .or_else(|| ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]
                .iter().find_map(|s| find_by_suffix(dir, s)));
        match cands.and_then(|p| fs::read(&p).ok()) {
            Some(bytes) => format!("<img src=\"data:image/png;base64,{}\">", b64(&bytes)),
            None => "<div class=\"ph\">no 3D render in this directory</div>".into(),
        }
    };
    // optional pin→pad connects from a .lbr
    let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();
    if let Some(lbr) = read_first_or_suffix(dir, &[format!("{mpn}.lbr")], ".lbr") {
        if let Ok(re) = regex_lite::Regex::new(r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#) {
            for cap in re.captures_iter(&lbr) {
                pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());
            }
        }
    }
    // pins + descriptions from the extracted symbol JSON
    let mut pins_json = String::from("[]");
    let mut pad_count = fp_svg.matches("adom-pad").count().max(0);
    if let Some(ext) = read_first_or_suffix(dir, &[format!("{mpn}-symbol.extracted.json")], "-symbol.extracted.json") {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&ext) {
            if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) {
                let rows: Vec<serde_json::Value> = arr.iter().map(|p| {
                    let num = p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string();
                    let raw_t = p.get("type").and_then(|x| x.as_str()).unwrap_or("");
                    let (label, color) = pin_type_style(raw_t);
                    let pad = pin_to_pad.get(p.get("name").and_then(|x| x.as_str()).unwrap_or(""))
                        .cloned().unwrap_or_else(|| num.clone());
                    serde_json::json!({
                        "number": num,
                        "name": p.get("name").and_then(|x| x.as_str()).unwrap_or(""),
                        "pad": pad,
                        "type": label,
                        "color": color,
                        "desc": p.get("description").and_then(|x| x.as_str()).unwrap_or(""),
                    })
                }).collect();
                pad_count = pad_count.max(rows.len());
                pins_json = serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into());
            }
        }
    }
    let pin_count = serde_json::from_str::<serde_json::Value>(&pins_json).ok()
        .and_then(|v| v.as_array().map(|a| a.len())).unwrap_or(0);

    let html = include_str!("embed.html")
        .replace("__NAME__", &esc_html(&mpn))
        .replace("__SYM__", &sym_svg)
        .replace("__FP__", &fp_svg)
        .replace("__THREED__", &threed)
        .replace("__PINS__", &pins_json)
        .replace("__PADN__", &pad_count.to_string())
        .replace("__PINN__", &pin_count.to_string());
    let out = out.unwrap_or_else(|| dir.join(format!("{mpn}-lbr-embed.html")));
    fs::write(&out, &html).map_err(|e| format!("writing {}: {e}", out.display()))?;
    println!("OK: 3-column interactive embed ({pin_count} pins) → {}", out.display());
    Ok(())
}