123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
//! Native Rust parser for Altium .PcbLib (and .IntLib) footprint libraries.
//!
//! Altium .PcbLib is a Microsoft OLE2 / Compound File Binary container. Each
//! footprint lives in its own top-level storage (e.g. `/D8`, `/SOP50P490X110-10N`)
//! and the interesting stream is `/<name>/Data` — Altium's binary record format
//! laid out as a sequence of typed records (Pad / Track / Arc / Text / ...).
//!
//! This crate extracts every footprint's pad list and emits JSON shaped like
//! KiCad's `parse_kicad_mod`-style output, so downstream tools (chipsmith's
//! cross-source agreement matrix, concur's consensus check) can compare KiCad
//! / Fusion / Altium pads side-by-side.
//!
//! ## Public API
//!
//! - [`parse_altium_pcblib_path`] — read a `.PcbLib` from disk.
//! - [`parse_altium_pcblib_bytes`] — parse from raw bytes (handy for tests
//!   and for streams already in memory).
//!
//! Both return a `serde_json::Value` with this shape:
//!
//! ```json
//! {
//!   "module_name": "D8",
//!   "pad_count": 8,
//!   "pads": [{
//!     "number": "1", "type": "smd", "shape": "rect",
//!     "at": [-2.4638, -1.905, 0.0],
//!     "size": [1.9812, 0.5588],
//!     "layers": ["F.Cu", "F.Paste", "F.Mask"],
//!     "drill": null
//!   }],
//!   "silk_markers": [],
//!   "pin1_silk": { "found": false },
//!   "_source": "altium_pcblib",
//!   "_alternates": [ /* other footprints in the lib */ ]
//! }
//! ```
//!
//! ## Record format (reverse-engineered from real-world chip libraries)
//!
//! Each footprint's `Data` stream begins with a length-prefixed Pascal-style
//! footprint name:
//!
//! ```text
//! [u32 LE name_block_len][u8 name_pstring_len][name bytes...]
//! ```
//!
//! After the name, records appear back-to-back. Every record starts with a
//! u8 type byte. Different record types have different shapes:
//!
//! - **Track (0x04)** / **Arc (0x01)** / **Region (0x0B)** / **Body (0x0C)** /
//!   **Fill (0x06)** / **Via (0x03)**: `[u8 type][u32 LE len][len bytes payload]`.
//!
//! - **Pad (0x02)**: a sequence of 6 length-prefixed sub-blocks following the
//!   type byte. Each sub-block is `[u32 LE block_len][block_len bytes]`. The
//!   first sub-block holds the pad designator (Pascal string with 1-byte len).
//!   The fifth sub-block holds the main pad data — usually 194 or 202 bytes
//!   depending on the Altium PcbLib format version. The sixth is typically
//!   length-zero (trailing padding).
//!
//! - **Text (0x05)**: two length-prefixed sub-blocks (main payload + style
//!   trailer). We don't decode it — it's only consumed to keep the cursor
//!   aligned past silkscreen text.
//!
//! ### Pad data block layout (block 5)
//!
//! ```text
//! [0]      u8 layer (1 = TOP_CU, 32 = BOTTOM_CU, 21 = TOP_OVERLAY, ...)
//! [1]      u8 net/flag (0x08 in v3, 0x0c in v4 — used as a version probe)
//! [2]      u8 reserved
//! [3..13]  10 bytes of 0xFF padding (component / net id sentinels)
//! [13..17] i32 LE x position (units = 1 / 10000 mil = 2.54e-6 mm)
//! [17..21] i32 LE y position (Altium Y-up — flip sign for KiCad's Y-down)
//! [21..25] u32 LE x size, top layer
//! [25..29] u32 LE y size, top layer
//! [29..33] u32 LE x size, mid layer (multi-layer thru-hole pads)
//! [33..37] u32 LE y size, mid layer
//! [37..41] u32 LE x size, bottom layer
//! [41..45] u32 LE y size, bottom layer
//! [45..49] u32 LE hole size (0 → SMD; > 0 → thru-hole)
//! [49]     u8 shape, top layer (1 round, 2 rect, 3 octagonal, 9 rounded-rect)
//! [50]     u8 shape, mid layer
//! [51]     u8 shape, bottom layer
//! [52..60] f64 LE rotation in degrees (only present in 202-byte v4 layout)
//! ```
//!
//! Past offset 60 the layout drifts between Altium releases (paste / mask
//! offsets, plated bool, GUIDs, primitive index, …). We don't need any of that
//! for cross-source agreement, so we stop reading.
//!
//! Rotation matters only for the rendered bounding box: KiCad records the
//! "logical" pad shape and rotates it; Altium 194-byte pads bake the rotation
//! into x_size/y_size and store rotation = 0. Both representations cover the
//! same physical region, which is what cross-source agreement actually
//! compares.

use anyhow::{anyhow, bail, Context, Result};
use std::io::{Cursor, Read};
use std::path::Path;

const ALTIUM_UNIT_TO_MM: f64 = 0.00000254; // 1 / 10000 mil → mm

/// Parse an Altium .PcbLib (or .IntLib's PCB lib stream) from a file path.
/// Returns the JSON for the FIRST footprint in the library that has at least
/// one pad. Other footprints, if present, ride along under `_alternates`.
pub fn parse_altium_pcblib_path(path: &Path) -> Result<serde_json::Value> {
    let bytes = std::fs::read(path)
        .with_context(|| format!("read .PcbLib: {}", path.display()))?;
    parse_altium_pcblib_bytes(&bytes)
}

/// Parse an Altium .PcbLib from raw bytes.
pub fn parse_altium_pcblib_bytes(bytes: &[u8]) -> Result<serde_json::Value> {
    if bytes.len() < 8 || &bytes[..8] != [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] {
        bail!("not an OLE2 compound document (bad magic)");
    }

    // Altium occasionally writes PcbLib FATs that overshoot the file's actual
    // sector count (cfb refuses with "Malformed FAT … FAT has N entries, but
    // file has only M sectors"). The unreferenced trailing sectors don't carry
    // real data — pad with 0xFF (CFBF "free sector" sentinel) and re-open. 1
    // MiB is plenty for any real chip footprint library we've seen.
    let owned;
    let cursor = if bytes.len() < 1024 * 1024 {
        owned = {
            let mut v = bytes.to_vec();
            v.resize(1024 * 1024, 0xFF);
            v
        };
        Cursor::new(owned.as_slice())
    } else {
        Cursor::new(bytes)
    };

    let mut comp = cfb::CompoundFile::open(cursor)
        .map_err(|e| anyhow!("open OLE2: {e}"))?;

    // Walk top-level storages. Skip housekeeping ones (`/Library`,
    // `/FileVersionInfo`); each remaining storage is a footprint.
    let footprint_storages: Vec<String> = comp.read_root_storage()
        .filter_map(|ent| {
            if !ent.is_storage() { return None; }
            let name = ent.name().to_string();
            if name == "Library" || name == "FileVersionInfo" { return None; }
            // Skip OLE2 metadata streams that start with the 0x01 control char
            if name.starts_with('\u{1}') { return None; }
            Some(name)
        })
        .collect();

    if footprint_storages.is_empty() {
        bail!("no footprint storages found in PcbLib");
    }

    let mut parsed: Vec<serde_json::Value> = Vec::new();
    for name in &footprint_storages {
        let stream_path = format!("/{name}/Data");
        let mut stream = match comp.open_stream(&stream_path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        let mut data = Vec::new();
        if stream.read_to_end(&mut data).is_err() { continue; }
        match parse_footprint_stream(&data) {
            Ok(v) => parsed.push(v),
            Err(_e) => {
                // Surface the error in the JSON so the caller can flag it,
                // but keep walking — other footprints may parse cleanly.
                parsed.push(serde_json::json!({
                    "module_name": name,
                    "pad_count": 0,
                    "pads": [],
                    "silk_markers": [],
                    "pin1_silk": { "found": false },
                    "_source": "altium_pcblib",
                    "_parse_error": _e.to_string(),
                }));
            }
        }
    }

    if parsed.is_empty() {
        bail!("no parseable footprints in PcbLib");
    }

    // Pick the footprint with the most pads as primary; alternates ride along.
    parsed.sort_by(|a, b| {
        let pa = a.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);
        let pb = b.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);
        pb.cmp(&pa)
    });

    let mut primary = parsed.remove(0);
    if let Some(obj) = primary.as_object_mut() {
        obj.insert("_source".to_string(), serde_json::Value::String("altium_pcblib".into()));
        if !parsed.is_empty() {
            obj.insert("_alternates".to_string(), serde_json::Value::Array(parsed));
        }
    }
    Ok(primary)
}

/// Emit a `.kicad_mod` from a parsed footprint. The pads are already stored in
/// KiCad terms (shape names, F.Cu/F.Paste/F.Mask layers, mm sizes, rotation), so
/// this is a direct serializer — the EXPORT path (rendering uses the native SVG).
pub fn to_kicad_mod(footprint: &serde_json::Value, name: &str) -> String {
    let mut out = format!(
        "(footprint \"{name}\" (version 20221018) (generator altium-pcblib) (layer \"F.Cu\")\n  \
         (attr smd)\n  \
         (fp_text reference \"REF**\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n  \
         (fp_text value \"{name}\" (at 0 3) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n"
    );
    let pads = footprint.get("pads").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    for p in &pads {
        let num = p.get("number").and_then(|v| v.as_str()).unwrap_or("");
        let shape = p.get("shape").and_then(|v| v.as_str()).unwrap_or("rect");
        let ptype = p.get("type").and_then(|v| v.as_str()).unwrap_or("smd");
        let at = p.get("at").and_then(|v| v.as_array()).cloned().unwrap_or_default();
        let size = p.get("size").and_then(|v| v.as_array()).cloned().unwrap_or_default();
        let x = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
        let y = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
        let rot = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);
        let w = size.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
        let h = size.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
        let at_s = if rot != 0.0 { format!("{x:.4} {y:.4} {rot}") } else { format!("{x:.4} {y:.4}") };
        let layers = p.get("layers").and_then(|v| v.as_array())
            .map(|a| a.iter().filter_map(|l| l.as_str()).map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(" "))
            .filter(|s| !s.is_empty())
            .unwrap_or_else(|| "\"F.Cu\" \"F.Paste\" \"F.Mask\"".into());
        if ptype == "thru_hole" {
            let drill = p.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.6);
            out += &format!("  (pad \"{num}\" thru_hole {shape} (at {at_s}) (size {w:.4} {h:.4}) (drill {drill:.4}) (layers {layers}))\n");
        } else {
            out += &format!("  (pad \"{num}\" smd {shape} (at {at_s}) (size {w:.4} {h:.4}) (layers {layers}))\n");
        }
    }
    out += ")\n";
    out
}

fn parse_footprint_stream(data: &[u8]) -> Result<serde_json::Value> {
    if data.len() < 4 {
        bail!("stream too short ({} bytes)", data.len());
    }

    // Footprint name: [u32 LE block_len][u8 pstring_len][bytes...]
    let name_block_len = read_u32_le(data, 0)? as usize;
    if 4 + name_block_len > data.len() {
        bail!("footprint name block overruns stream");
    }
    let name_block = &data[4..4 + name_block_len];
    let module_name = if name_block.is_empty() {
        String::new()
    } else {
        let plen = name_block[0] as usize;
        if 1 + plen > name_block.len() {
            String::new()
        } else {
            String::from_utf8_lossy(&name_block[1..1 + plen]).to_string()
        }
    };

    let mut cursor = 4 + name_block_len;
    let mut pads: Vec<serde_json::Value> = Vec::new();
    let silk_markers: Vec<serde_json::Value> = Vec::new();

    // Altium footprints lay pads at the head of the stream, then silk /
    // courtyard / fab tracks, then optionally text / region / component-body
    // records that we don't model. Walk records until we run out, but stop
    // gracefully the moment we lose alignment — at that point we already
    // have the pads we need for cross-source agreement, and the silkscreen
    // / fab data isn't worth misreading.
    while cursor < data.len() {
        let record_type = data[cursor];
        cursor += 1;

        // Number of [u32 len][bytes] sub-blocks expected per record type.
        // Reverse-engineered from the bundled samples — most records carry a
        // single payload block; pads carry six (designator, layer-name, four
        // metadata blocks); text carries two (main + style trailer).
        let sub_blocks: usize = match record_type {
            0x02 => 6,                      // Pad
            0x05 => 2,                      // Text
            0x01 | 0x03 | 0x04 | 0x06 |
            0x09 | 0x0B | 0x0C => 1,        // Arc / Via / Track / Fill / Conn / Region / Body
            _ => {
                // Unknown record — stop without erroring out. Pads come
                // first; a stray byte mid-stream is usually a Text
                // sub-block we miscounted, not a real new record kind.
                break;
            }
        };

        let mut blocks: Vec<&[u8]> = Vec::with_capacity(sub_blocks);
        let mut ok = true;
        for _ in 0..sub_blocks {
            if cursor + 4 > data.len() { ok = false; break; }
            let blen = read_u32_le(data, cursor).ok().map(|v| v as usize).unwrap_or(usize::MAX);
            // Sanity bound: any sub-block bigger than the whole stream is
            // proof we lost alignment. Bail out and keep the pads we have.
            if blen > data.len() { ok = false; break; }
            cursor += 4;
            if cursor + blen > data.len() { ok = false; break; }
            blocks.push(&data[cursor..cursor + blen]);
            cursor += blen;
        }
        if !ok {
            break;
        }

        if record_type == 0x02 {
            if let Some(pad_json) = decode_pad(&blocks) {
                pads.push(pad_json);
            }
        }
    }

    let pad_count = pads.len();
    let pin1_silk = serde_json::json!({"found": false});
    Ok(serde_json::json!({
        "module_name": module_name,
        "pad_count": pad_count,
        "pads": pads,
        "silk_markers": silk_markers,
        "pin1_silk": pin1_silk,
    }))
}

fn decode_pad(blocks: &[&[u8]]) -> Option<serde_json::Value> {
    if blocks.len() < 5 { return None; }

    // Block 0: designator pstring (e.g. "01 31" = "1")
    let designator = decode_pstring(blocks[0]);

    // Block 4: main pad data, 194 bytes (v3) or 202 bytes (v4+)
    let pd = blocks[4];
    if pd.len() < 49 {
        return None;
    }

    let layer_byte = pd[0];

    // X / Y in 1/10000 mil units. Altium Y-up → KiCad Y-down (flip sign).
    let x_units = read_i32_le(pd, 13)?;
    let y_units = read_i32_le(pd, 17)?;
    let x_mm = x_units as f64 * ALTIUM_UNIT_TO_MM;
    let y_mm = -(y_units as f64 * ALTIUM_UNIT_TO_MM);

    let xsize_top = read_u32_le(pd, 21).ok()? as f64 * ALTIUM_UNIT_TO_MM;
    let ysize_top = read_u32_le(pd, 25).ok()? as f64 * ALTIUM_UNIT_TO_MM;
    // mid / bot sizes available at 29/33/37/41 if we ever need them
    let hole_units = read_u32_le(pd, 45).ok()?;
    let hole_mm = hole_units as f64 * ALTIUM_UNIT_TO_MM;
    let shape_byte = pd[49];

    // Rotation lives at block5[52..60] in v4+. v3 (194-byte) layout doesn't
    // store it (rotation is baked into xsize/ysize). Read what's there; if
    // the f64 isn't a finite angle we just default to 0. The bounding box is
    // still correct because Altium pre-rotates the size for v3.
    let rotation = if pd.len() >= 60 {
        let raw = read_f64_le(pd, 52).unwrap_or(0.0);
        if raw.is_finite() && raw.abs() < 360.0 + 1e-6 {
            // Normalize to [-360, 360); KiCad accepts any angle but typical
            // values are 0/90/180/270.
            raw
        } else {
            0.0
        }
    } else {
        0.0
    };

    // Altium "round" (1) covers both circular pads and stadium-shaped SMD
    // pads with rounded ends. KiCad splits those: "circle" for w == h,
    // "oval" for w != h.
    let shape = match shape_byte {
        1 if (xsize_top - ysize_top).abs() < 1e-3 => "circle",
        1 => "oval",
        _ => altium_shape_to_kicad(shape_byte),
    };
    let pad_type = if hole_units == 0 {
        "smd"
    } else {
        "thru_hole"
    };

    let layers = altium_layers_for_pad(layer_byte, pad_type);

    let drill = if hole_units == 0 {
        serde_json::Value::Null
    } else {
        serde_json::json!({
            "shape": "circle",
            "diameter": hole_mm,
        })
    };

    Some(serde_json::json!({
        "number": designator,
        "type": pad_type,
        "shape": shape,
        "at": [round6(x_mm), round6(y_mm), round4(rotation)],
        "size": [round6(xsize_top), round6(ysize_top)],
        "layers": layers,
        "drill": drill,
    }))
}

fn decode_pstring(block: &[u8]) -> String {
    if block.is_empty() { return "?".to_string(); }
    let plen = block[0] as usize;
    if 1 + plen > block.len() { return "?".to_string(); }
    let raw = &block[1..1 + plen];
    // Altium pads designators with NUL out to a fixed width; trim trailing NULs.
    let trimmed = raw.iter().take_while(|&&b| b != 0).copied().collect::<Vec<_>>();
    String::from_utf8_lossy(&trimmed).to_string()
}

fn altium_shape_to_kicad(shape: u8) -> &'static str {
    match shape {
        1 => "circle",     // round — caller specializes to "oval" when w != h
        2 => "rect",
        3 => "rect",       // octagonal — KiCad has no octagonal; rect is closest
        9 => "roundrect",
        _ => "rect",
    }
}

/// Map an Altium layer byte to a KiCad-style layer list. SMD pads claim
/// F.Cu + F.Paste + F.Mask (or B.* on the bottom). Thru-hole pads claim
/// *.Cu + *.Paste + *.Mask so they appear on every layer.
fn altium_layers_for_pad(layer: u8, pad_type: &str) -> Vec<String> {
    if pad_type == "thru_hole" {
        return vec!["*.Cu".into(), "*.Paste".into(), "*.Mask".into()];
    }
    let on_bottom = matches!(layer, 32);
    if on_bottom {
        vec!["B.Cu".into(), "B.Paste".into(), "B.Mask".into()]
    } else {
        vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()]
    }
}

fn read_u32_le(buf: &[u8], off: usize) -> Result<u32> {
    if off + 4 > buf.len() { bail!("u32 read past end of buffer at {off}"); }
    Ok(u32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]))
}

fn read_i32_le(buf: &[u8], off: usize) -> Option<i32> {
    if off + 4 > buf.len() { return None; }
    Some(i32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]))
}

fn read_f64_le(buf: &[u8], off: usize) -> Option<f64> {
    if off + 8 > buf.len() { return None; }
    Some(f64::from_le_bytes([
        buf[off], buf[off+1], buf[off+2], buf[off+3],
        buf[off+4], buf[off+5], buf[off+6], buf[off+7],
    ]))
}

fn round6(x: f64) -> f64 { (x * 1_000_000.0).round() / 1_000_000.0 }
fn round4(x: f64) -> f64 { (x * 10_000.0).round() / 10_000.0 }

// ----------------------------------------------------------------------------
// SVG renderer — authoritative debug visualisation
// ----------------------------------------------------------------------------
//
// Downstream tools (chipsmith's overlay, concur's consensus check) interpret
// the parsed JSON. If their renders disagree with the SVG produced here,
// THEY are the bug — this rendering is what the parser actually believes the
// pads to be, drawn directly from `at` / `size` / `rot` / `shape` with no
// further interpretation.
//
// Convention:
//   - Coordinates are in millimetres. SVG units = mm. No scaling applied.
//   - Y is positive-down (matches both KiCad and our parser's emitted JSON,
//     where Altium's Y-up is already flipped at parse time).
//   - Rotation is taken straight from `at[2]`. Applied via SVG's
//     `transform="rotate(deg cx cy)"` which rotates clockwise as seen on
//     screen — same visual effect as KiCad's rotation.
//
// What the SVG shows:
//   - Footprint name + pad count in the corner.
//   - One <rect>/<circle>/<ellipse> per pad, coloured by layer
//     (F.Cu = orange, B.Cu = blue, thru-hole = both).
//   - The drill hole as a white circle inside thru-hole pads.
//   - Pad number text un-rotated for readability (centred on the pad).
//   - Origin crosshair at (0, 0) so you can sanity-check pad placement.
//   - viewBox auto-fitted with 1 mm padding around the pad bounding box.

/// Render the parsed footprint JSON as a standalone SVG document. The input
/// must be the value returned by [`parse_altium_pcblib_bytes`] /
/// [`parse_altium_pcblib_path`] — either the primary footprint or one of its
/// `_alternates`.
pub fn render_footprint_svg(footprint: &serde_json::Value) -> String {
    let module_name = footprint
        .get("module_name").and_then(|v| v.as_str()).unwrap_or("?");
    let pads = footprint
        .get("pads").and_then(|v| v.as_array()).cloned().unwrap_or_default();

    // Compute the post-rotation axis-aligned bbox of every pad so the
    // viewBox covers the whole footprint with a small margin.
    let mut min_x = f64::INFINITY;
    let mut min_y = f64::INFINITY;
    let mut max_x = f64::NEG_INFINITY;
    let mut max_y = f64::NEG_INFINITY;
    for p in &pads {
        if let Some(bb) = pad_post_rotation_bbox(p) {
            min_x = min_x.min(bb.0);
            min_y = min_y.min(bb.1);
            max_x = max_x.max(bb.2);
            max_y = max_y.max(bb.3);
        }
    }
    if !min_x.is_finite() {
        // No pads with usable geometry — emit an empty placeholder so the
        // caller still gets a valid SVG document.
        return format!(
            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 10 10" width="200" height="200"><text x="0" y="0" font-size="1" text-anchor="middle" font-family="monospace">{module_name}: no pads</text></svg>"#
        );
    }
    let pad = 1.0_f64; // 1 mm margin around the pad cluster
    let vb_x = min_x - pad;
    let vb_y = min_y - pad;
    let vb_w = (max_x - min_x) + 2.0 * pad;
    let vb_h = (max_y - min_y) + 2.0 * pad;

    // Render each pad. Always 1 mm = 1 unit so callers can pixel-measure.
    let px_per_mm = 50.0_f64; // hint to image viewers (overridable via CSS)
    let mut body = String::new();
    body.push_str(&format!(
        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.4} {:.4} {:.4} {:.4}" width="{:.0}" height="{:.0}" font-family="monospace">"#,
        vb_x, vb_y, vb_w, vb_h,
        vb_w * px_per_mm, vb_h * px_per_mm,
    ));
    body.push('\n');

    // Background — pale grid so axes read clearly.
    body.push_str(&format!(
        r##"  <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" fill="#fafafa" stroke="none"/>"##,
        vb_x, vb_y, vb_w, vb_h,
    ));
    body.push('\n');

    // Origin crosshair — 1 mm long arms.
    body.push_str(r##"  <g stroke="#888" stroke-width="0.04" fill="none">"##);
    body.push('\n');
    body.push_str(r#"    <line x1="-0.5" y1="0" x2="0.5" y2="0"/>"#);
    body.push('\n');
    body.push_str(r#"    <line x1="0" y1="-0.5" x2="0" y2="0.5"/>"#);
    body.push('\n');
    body.push_str(r#"  </g>"#);
    body.push('\n');

    // Pads.
    for p in &pads {
        body.push_str(&render_pad_svg(p));
    }

    // Header text — top-left corner of viewBox.
    body.push_str(&format!(
        r##"  <text x="{:.4}" y="{:.4}" font-size="0.5" fill="#222">{} ({} pads)</text>"##,
        vb_x + 0.2, vb_y + 0.6, escape_xml(module_name), pads.len(),
    ));
    body.push('\n');
    body.push_str("</svg>\n");
    body
}

/// Convenience: render the primary footprint plus every `_alternates` entry,
/// one above the next, into a single SVG so a reviewer can flip through
/// every variant a `.PcbLib` declares.
pub fn render_all_footprints_svg(parsed: &serde_json::Value) -> String {
    // Collect primary + every alternate as separate SVG bodies, then stack
    // them vertically inside one outer <svg>.
    let mut footprints: Vec<&serde_json::Value> = vec![parsed];
    if let Some(alts) = parsed.get("_alternates").and_then(|v| v.as_array()) {
        footprints.extend(alts.iter());
    }
    // Render each independently, then string-concat into a vertical stack.
    let inner: Vec<String> = footprints.iter().map(|fp| render_footprint_svg(fp)).collect();
    // Crude but effective: inline each as <svg ... y="offset"> inside an
    // outer wrapper. SVG nesting works in browsers and most viewers.
    let mut out = String::new();
    let panel_height = 400.0_f64;
    let panel_gap = 40.0_f64;
    let total_h = footprints.len() as f64 * (panel_height + panel_gap);
    out.push_str(&format!(
        r#"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="{:.0}">"#,
        total_h,
    ));
    out.push('\n');
    for (i, svg) in inner.iter().enumerate() {
        let y = i as f64 * (panel_height + panel_gap);
        out.push_str(&format!(
            r#"  <svg x="0" y="{:.0}" width="800" height="{:.0}" preserveAspectRatio="xMidYMid meet">"#,
            y, panel_height,
        ));
        // strip the outer <svg> tag from the inner doc so we just splice
        // its content in.
        let trimmed = svg
            .trim_start_matches(|c: char| c != '>').trim_start_matches('>')
            .trim_end()
            .trim_end_matches("</svg>");
        out.push_str(trimmed);
        out.push_str("</svg>\n");
    }
    out.push_str("</svg>\n");
    out
}

fn render_pad_svg(p: &serde_json::Value) -> String {
    let number = p.get("number").and_then(|v| v.as_str()).unwrap_or("?");
    let shape = p.get("shape").and_then(|v| v.as_str()).unwrap_or("rect");
    let pad_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("smd");
    let at = p.get("at").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    let size = p.get("size").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    let cx = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
    let cy = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
    let rot = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);
    let w = size.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
    let h = size.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);

    // Layer colour: F.Cu → warm orange, B.Cu → cool blue, thru-hole → both.
    let on_back = p.get("layers").and_then(|v| v.as_array())
        .map(|arr| arr.iter().any(|l| l.as_str() == Some("B.Cu")))
        .unwrap_or(false);
    let fill = if pad_type == "thru_hole" {
        "#a8d4a8" // soft green for thru-hole
    } else if on_back {
        "#7cb6ff"
    } else {
        "#ff9a4a"
    };
    let stroke = "#222";

    // Rotation: SVG's `rotate(angle cx cy)` rotates clockwise on screen
    // when applied to a Y-down coordinate system, which matches how KiCad
    // displays its rotated pads. So we use the angle as-is.
    let rot_attr = if rot.abs() > 1e-6 {
        format!(r#" transform="rotate({:.4} {:.4} {:.4})""#, rot, cx, cy)
    } else {
        String::new()
    };

    let mut out = String::new();
    out.push_str("  <g>\n");
    match shape {
        "circle" => {
            let r = w.max(h) / 2.0;
            out.push_str(&format!(
                r#"    <circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,
                cx, cy, r, fill, stroke, rot_attr,
            ));
            out.push('\n');
        }
        "oval" => {
            // KiCad "oval" = stadium: rect with semicircle ends. Approximate
            // with rounded-rect rx = min(w, h)/2.
            let r = w.min(h) / 2.0;
            out.push_str(&format!(
                r#"    <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" rx="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,
                cx - w / 2.0, cy - h / 2.0, w, h, r, fill, stroke, rot_attr,
            ));
            out.push('\n');
        }
        "roundrect" => {
            let r = (w.min(h) * 0.25).max(0.05);
            out.push_str(&format!(
                r#"    <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" rx="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,
                cx - w / 2.0, cy - h / 2.0, w, h, r, fill, stroke, rot_attr,
            ));
            out.push('\n');
        }
        _ => {
            // rect (or unknown — draw as rect)
            out.push_str(&format!(
                r#"    <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,
                cx - w / 2.0, cy - h / 2.0, w, h, fill, stroke, rot_attr,
            ));
            out.push('\n');
        }
    }

    // Drill hole — drawn AFTER the pad body so it sits on top.
    if let Some(drill) = p.get("drill") {
        if let Some(d) = drill.get("diameter").and_then(|v| v.as_f64()) {
            out.push_str(&format!(
                r##"    <circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="#fff" stroke="{}" stroke-width="0.02"/>"##,
                cx, cy, d / 2.0, stroke,
            ));
            out.push('\n');
        }
    }

    // Pad number — un-rotated so it stays readable regardless of rot.
    let label_size = (w.min(h) * 0.5).clamp(0.15, 0.6);
    out.push_str(&format!(
        r##"    <text x="{:.4}" y="{:.4}" font-size="{:.4}" text-anchor="middle" dominant-baseline="middle" fill="#111">{}</text>"##,
        cx, cy, label_size, escape_xml(number),
    ));
    out.push('\n');
    out.push_str("  </g>\n");
    out
}

/// Compute the axis-aligned bounding box of a pad after applying its rotation.
/// Returns (min_x, min_y, max_x, max_y) in mm.
fn pad_post_rotation_bbox(p: &serde_json::Value) -> Option<(f64, f64, f64, f64)> {
    let at = p.get("at").and_then(|v| v.as_array())?;
    let size = p.get("size").and_then(|v| v.as_array())?;
    let cx = at.first()?.as_f64()?;
    let cy = at.get(1)?.as_f64()?;
    let rot_deg = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);
    let w = size.first()?.as_f64()?;
    let h = size.get(1)?.as_f64()?;
    let theta = rot_deg.to_radians();
    let (s, c) = (theta.sin(), theta.cos());
    // Half-extents along rotated axes — corners are at ±(w/2)*(c, s) ± (h/2)*(-s, c)
    let hx = (w / 2.0 * c).abs() + (h / 2.0 * s).abs();
    let hy = (w / 2.0 * s).abs() + (h / 2.0 * c).abs();
    Some((cx - hx, cy - hy, cx + hx, cy + hy))
}

fn escape_xml(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&apos;"),
            _ => out.push(ch),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    /// LM358D is a SOIC-8 with 8 SMD pads at ±2.4638 mm × {±1.905, ±0.635} mm,
    /// each 1.9812 × 0.5588 mm. Pad 1 is at (-2.4638, -1.905) in KiCad
    /// convention.
    #[test]
    fn lm358d_decodes_eight_pads_pad1_at_top_left() {
        const LM358D_PCBLIB: &[u8] = include_bytes!(
            "../../chip-fetcher/library/LM358D/LM358D.PcbLib"
        );
        let parsed = parse_altium_pcblib_bytes(LM358D_PCBLIB)
            .expect("parse LM358D PcbLib");
        let pad_count = parsed.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);
        assert_eq!(pad_count, 8, "LM358D should have 8 pads, got {pad_count}");
        let pads = parsed.get("pads").and_then(|v| v.as_array()).expect("pads array");
        let pad1 = pads.iter().find(|p| {
            p.get("number").and_then(|v| v.as_str()) == Some("1")
        }).expect("pad 1");
        let at = pad1.get("at").and_then(|v| v.as_array()).expect("at");
        let x = at[0].as_f64().unwrap();
        let y = at[1].as_f64().unwrap();
        let size = pad1.get("size").and_then(|v| v.as_array()).expect("size");
        let w = size[0].as_f64().unwrap();
        let h = size[1].as_f64().unwrap();
        // Pad 1 centre is at roughly (-2.46, -1.91); width 1.98, height 0.56
        assert!((x - -2.4638).abs() < 0.05, "pad 1 X = {x}");
        assert!((y - -1.905).abs() < 0.05, "pad 1 Y = {y}");
        assert!((w - 1.9812).abs() < 0.05, "pad 1 W = {w}");
        assert!((h - 0.5588).abs() < 0.05, "pad 1 H = {h}");
        let layers = pad1.get("layers").and_then(|v| v.as_array()).expect("layers");
        assert!(layers.iter().any(|l| l.as_str() == Some("F.Cu")));
    }
}