123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
//! Altium `.SchLib` (schematic symbol library) **encoder** + decoder.
//!
//! A SchLib is an OLE2/CFBF container whose payload is almost entirely ASCII
//! `|KEY=VALUE|` records. We learned the exact byte framing from a reference
//! library generated by Altium AD26 (see `templates/`), and validated the
//! decode against the `altium-schlib` reader.
//!
//! Stream layout this encoder writes:
//! ```text
//!   /FileHeader            <u32 len> "|HEADER=Protel ...|CompCount=N|LibRef0=..|PartCount0=..|..\0"
//!   /Storage               <u32 len> "|HEADER=Icon storage\0"
//!   /<LibRef>/Data         per-component record stream (one storage per component)
//! ```
//! Each `/<LibRef>/Data` is a back-to-back sequence of records:
//! ```text
//!   text record:  <u16 len><u16 0x0000>  "|RECORD=..|..\0"     (len counts the \0)
//!   pin  record:  <u16 36 ><u16 0x0100>  <36-byte binary pin payload>
//! ```

use crate::model::{Graphic, Pin, Symbol};
use anyhow::{bail, Context, Result};
use std::collections::BTreeSet;
use std::io::{Cursor, Read, Seek, Write};

/// The 26-byte fixed prefix of a binary pin record (tag .. just before the
/// Name pstring), captured from an Altium-generated reference pin. The encoder
/// patches conglomerate@14, [email protected], [email protected], [email protected] and leaves the
/// rest (which Altium fills with defaults) intact for guaranteed acceptance.
const PIN_PREFIX: [u8; 26] = [
    0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
    0x04, // [14] conglomerate (orientation bits 0..1) — patched
    0x18, 0x1e, // [15..17] length i16 — patched
    0x00, 0xf6, // [17..19] x i16 — patched
    0xff, 0x00, // [19..21] y i16 — patched
    0x00, 0x00, 0x00, 0x00, 0x00, // [21..26]
];

/// Default Altium pin length (internal units) when the model leaves it unset.
const DEFAULT_PIN_LEN: i16 = 7704;

/// Altium schematic binary coordinate units per mil. Calibrated by differential
/// analysis against Altium AD26 (1000 mil → 25600 units, exact). The model
/// carries pin X/Y in mils; the encoder scales to these units.
const UNITS_PER_MIL: f64 = 25.6;

// ---------------------------------------------------------------------------
// Encode
// ---------------------------------------------------------------------------

/// Encode one or more symbols into a `.SchLib` byte image.
pub fn encode_schlib(symbols: &[Symbol]) -> Result<Vec<u8>> {
    validate_symbols(symbols)?;
    let cursor = Cursor::new(Vec::<u8>::new());
    let mut comp = cfb::CompoundFile::create(cursor).context("create CFBF")?;

    // The CFBF storage name, the FileHeader LibRef entry, and the RECORD=1
    // LibReference must all agree, or Altium can't resolve the component —
    // compute each symbol's storage name once and use it in all three.
    let named: Vec<(String, &Symbol)> = symbols
        .iter()
        .enumerate()
        .map(|(i, s)| (sanitize_storage_name(&s.name, i), s))
        .collect();

    // FileHeader — library index listing every component.
    let fh = build_file_header(&named);
    write_root_stream(&mut comp, "FileHeader", &fh)?;

    // Storage — fixed "Icon storage" housekeeping stream.
    let storage = u32_text_record("|HEADER=Icon storage");
    write_root_stream(&mut comp, "Storage", &storage)?;

    // One storage per component, each holding a Data record stream.
    for (store_name, sym) in &named {
        comp.create_storage(&format!("/{store_name}"))
            .with_context(|| format!("create storage /{store_name}"))?;
        let data = build_component_data(sym, store_name)?;
        let mut s = comp
            .create_stream(&format!("/{store_name}/Data"))
            .with_context(|| format!("create /{store_name}/Data"))?;
        s.write_all(&data)?;
        s.flush()?;
    }

    comp.flush()?;
    let cursor = comp.into_inner();
    Ok(cursor.into_inner())
}

/// Merge `symbols` into an existing `.SchLib` image, preserving every existing
/// component **byte-for-byte**. Existing streams are copied verbatim; the
/// FileHeader index is patched (CompCount + appended `LibRef{i}`/`PartCount{i}`)
/// and one new storage per added symbol is written. Symbols whose name already
/// matches an existing component storage are skipped (no clobber). This is the
/// "install a part into the user's library" primitive.
pub fn add_symbols_to_schlib(existing: &[u8], symbols: &[Symbol]) -> Result<Vec<u8>> {
    // 1) Read all streams from the source library.
    let mut src =
        cfb::CompoundFile::open(Cursor::new(existing.to_vec())).context("open existing SchLib")?;
    let paths: Vec<String> = src
        .walk()
        .filter(|e| e.is_stream())
        .map(|e| e.path().to_string_lossy().to_string())
        .collect();
    let mut streams: Vec<(String, Vec<u8>)> = Vec::new();
    for p in &paths {
        let mut buf = Vec::new();
        src.open_stream(p)?.read_to_end(&mut buf)?;
        streams.push((p.clone(), buf));
    }

    // Existing top-level component storage names (everything that isn't a root
    // housekeeping stream).
    let mut existing_comps: BTreeSet<String> = BTreeSet::new();
    for (p, _) in &streams {
        let seg: Vec<&str> = p.trim_start_matches('/').split('/').collect();
        if seg.len() >= 2 && seg[0] != "FileHeader" && seg[0] != "Storage" {
            existing_comps.insert(seg[0].to_string());
        }
    }
    let comp_count_existing = existing_comps.len();

    validate_symbols(symbols)?;
    // Skip symbols already present. Compare SANITIZED names — the stored
    // storages are sanitized, so comparing the raw name would let e.g.
    // "LM358.A" slip past its own existing "LM358_A" storage and collide.
    let to_add: Vec<(String, &Symbol)> = symbols
        .iter()
        .enumerate()
        .map(|(i, s)| (sanitize_storage_name(&s.name, comp_count_existing + i), s))
        .filter(|(store, _)| !existing_comps.contains(store))
        .collect();
    if to_add.is_empty() {
        // Nothing new — return the original unchanged.
        return Ok(existing.to_vec());
    }

    // 2) Patch the FileHeader index.
    let fh = streams
        .iter()
        .find(|(p, _)| p == "/FileHeader")
        .context("existing SchLib has no FileHeader")?
        .1
        .clone();
    let patched_fh = patch_file_header_add(&fh, comp_count_existing, &to_add)?;

    // 3) Rebuild: copy every stream (FileHeader replaced), then add new comps.
    let mut dst = cfb::CompoundFile::create(Cursor::new(Vec::<u8>::new()))
        .context("create merged SchLib")?;
    let mut made: BTreeSet<String> = BTreeSet::new();
    for (p, bytes) in &streams {
        let data: &[u8] = if p == "/FileHeader" { &patched_fh } else { bytes };
        write_stream_deep(&mut dst, p, data, &mut made)?;
    }
    for (store, sym) in &to_add {
        let data = build_component_data(sym, store)?;
        write_stream_deep(&mut dst, &format!("/{store}/Data"), &data, &mut made)?;
    }
    dst.flush()?;
    Ok(dst.into_inner().into_inner())
}

/// Patch a SchLib FileHeader text record: bump `CompCount` by `add.len()` and
/// append a `|LibRef{i}=..|PartCount{i}=1` entry per added symbol (indices
/// continue after the existing `old_n` components).
fn patch_file_header_add(fh: &[u8], old_n: usize, add: &[(String, &Symbol)]) -> Result<Vec<u8>> {
    if fh.len() < 5 {
        bail!("FileHeader too short");
    }
    let len = u32::from_le_bytes([fh[0], fh[1], fh[2], fh[3]]) as usize;
    let end = 4 + len.saturating_sub(1); // exclude trailing \0
    let text = std::str::from_utf8(fh.get(4..end).context("FileHeader bounds")?)
        .context("FileHeader not UTF-8")?
        .to_string();

    // Replace the CompCount value in place.
    const KEY: &str = "|CompCount=";
    let ci = text.find(KEY).context("FileHeader has no CompCount")?;
    let val_start = ci + KEY.len();
    let digits: String = text[val_start..]
        .chars()
        .take_while(|c| c.is_ascii_digit())
        .collect();
    let new_n = old_n + add.len();
    let mut new_text = String::new();
    new_text.push_str(&text[..val_start]);
    new_text.push_str(&new_n.to_string());
    new_text.push_str(&text[val_start + digits.len()..]);

    // Append the new component index entries — LibRef = the SANITIZED storage
    // name, so Altium can resolve the component to its storage.
    for (k, (store, _)) in add.iter().enumerate() {
        let i = old_n + k;
        new_text.push_str(&format!("|LibRef{i}={store}|PartCount{i}=1"));
    }
    Ok(u32_text_record(&new_text))
}

/// Create any missing parent storages, then write `path` as a stream.
fn write_stream_deep<F: Read + Write + Seek>(
    comp: &mut cfb::CompoundFile<F>,
    path: &str,
    bytes: &[u8],
    made: &mut BTreeSet<String>,
) -> Result<()> {
    let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
    let mut acc = String::new();
    for seg in &parts[..parts.len().saturating_sub(1)] {
        acc.push('/');
        acc.push_str(seg);
        if made.insert(acc.clone()) {
            comp.create_storage(&acc)
                .with_context(|| format!("create storage {acc}"))?;
        }
    }
    let mut s = comp
        .create_stream(path)
        .with_context(|| format!("create stream {path}"))?;
    s.write_all(bytes)?;
    s.flush()?;
    Ok(())
}

fn write_root_stream<F: std::io::Read + std::io::Write + std::io::Seek>(
    comp: &mut cfb::CompoundFile<F>,
    name: &str,
    bytes: &[u8],
) -> Result<()> {
    let mut s = comp
        .create_stream(&format!("/{name}"))
        .with_context(|| format!("create /{name}"))?;
    s.write_all(bytes)?;
    s.flush()?;
    Ok(())
}

/// Validate symbols before encoding. Text records are `|KEY=VALUE` streams
/// with NO escape mechanism — a `|` inside a name/value desyncs the whole
/// record — and several binary fields are narrow. Refuse loudly rather than
/// emit a corrupt library.
fn validate_symbols(symbols: &[Symbol]) -> Result<()> {
    /// Max pin length: the length field is u16 with orientation in bits 0..1,
    /// so length_units ≤ 0xFFFC → 0xFFFC / 25.6 = 2559 mil.
    const MAX_PIN_LEN_MIL: i32 = 2559;
    for sym in symbols {
        let no_pipe = |what: &str, s: &str| -> Result<()> {
            if s.contains('|') {
                bail!("symbol {:?}: {what} {s:?} contains '|', which corrupts Altium text records — rename it", sym.name);
            }
            Ok(())
        };
        no_pipe("name", &sym.name)?;
        if let Some(p) = &sym.designator_prefix {
            no_pipe("designator prefix", p)?;
        }
        if let Some(f) = &sym.footprint {
            no_pipe("footprint name", f)?;
        }
        for param in &sym.parameters {
            no_pipe("parameter name", &param.name)?;
            no_pipe("parameter value", &param.value)?;
            if param.name.contains('=') {
                bail!("symbol {:?}: parameter name {:?} contains '=', which corrupts Altium text records", sym.name, param.name);
            }
        }
        for pin in &sym.pins {
            if pin.name.len() > 255 || pin.number.len() > 255 {
                bail!("symbol {:?}: pin name/number longer than 255 bytes (pin {:?})", sym.name, pin.number);
            }
            if let Some(l) = pin.length {
                if !(0..=MAX_PIN_LEN_MIL).contains(&l) {
                    bail!("symbol {:?}: pin {:?} length {l} mil out of range 0..={MAX_PIN_LEN_MIL} (Altium field limit)", sym.name, pin.number);
                }
            }
        }
    }
    Ok(())
}

/// `<u32 len>` + text + `\0`. Used for FileHeader and Storage streams.
fn u32_text_record(text: &str) -> Vec<u8> {
    let mut payload = text.as_bytes().to_vec();
    payload.push(0);
    let mut out = (payload.len() as u32).to_le_bytes().to_vec();
    out.extend_from_slice(&payload);
    out
}

/// `<u16 len><u16 flags>` + text + `\0`. The component-data text record framing.
/// Errors instead of silently wrapping the u16 frame length (a >64 KiB record —
/// e.g. a polyline with thousands of points — would desync the whole stream).
fn u16_text_record(text: &str) -> Result<Vec<u8>> {
    let mut payload = text.as_bytes().to_vec();
    payload.push(0);
    if payload.len() > u16::MAX as usize {
        bail!("text record too large for u16 framing ({} bytes) — simplify the symbol graphics", payload.len());
    }
    let mut out = (payload.len() as u16).to_le_bytes().to_vec();
    out.extend_from_slice(&0u16.to_le_bytes()); // flags = 0
    out.extend_from_slice(&payload);
    Ok(out)
}

fn build_file_header(symbols: &[(String, &Symbol)]) -> Vec<u8> {
    let mut s = String::from(
        "|HEADER=Protel for Windows - Schematic Library Editor Binary File Version 5.0\
|Weight=0|MinorVersion=9|UniqueID=ADOMSFHX|FontIdCount=1|Size1=10\
|FontName1=Times New Roman|UseMBCS=T|IsBOC=T|SheetStyle=9|BorderOn=T\
|SheetNumberSpaceSize=12|AreaColor=16317695|SnapGridOn=T|SnapGridSize=10\
|VisibleGridOn=T|VisibleGridSize=10|CustomX=18000|CustomY=18000|UseCustomSheet=T\
|ReferenceZonesOn=T|Display_Unit=0",
    );
    s.push_str(&format!("|CompCount={}", symbols.len()));
    for (i, (store_name, _)) in symbols.iter().enumerate() {
        let parts = 1; // single-part symbols for now
        // LibRef must equal the storage name (and RECORD=1 LibReference).
        s.push_str(&format!("|LibRef{i}={store_name}|PartCount{i}={parts}"));
    }
    u32_text_record(&s)
}

/// `lib_ref` is the storage name — LibReference, the FileHeader LibRef entry,
/// and the CFBF storage MUST all agree or Altium can't resolve the component.
fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> {
    let mut out = Vec::new();
    let pin_count = sym.pins.len();
    let body = sym.body.unwrap_or_default();
    let prefix = sym.designator_prefix.clone().unwrap_or_else(|| "U".into());
    let uid = pseudo_uid(&sym.name, 0);

    // RECORD=1 — component header.
    out.extend(u16_text_record(&format!(
        "|RECORD=1|LibReference={lib_ref}|PartCount=1|DisplayModeCount=1|IndexInSheet=-1\
|OwnerPartId=-1|CurrentPartId=1|LibraryPath=*|SourceLibraryName=*|SheetPartFileName=*\
|TargetFileName=*|UniqueID={uid}|AreaColor=11599871|Color=128|PartIDLocked=T\
|AllPinCount={pin_count}",
    ))?);

    // Body: emit the symbol's own graphics if it has any; otherwise a default
    // body rectangle. Either way these are the records between header and pins.
    if sym.graphics.is_empty() {
        out.extend(u16_text_record(&format!(
            "|RECORD=14|OwnerPartId=1|Location.X={x1}|Location.Y={y1}|Corner.X={x2}|Corner.Y={y2}\
|LineWidth=1|Color=16711680|AreaColor=11599871|IsSolid=T|UniqueID={uid2}",
            x1 = body.x1 / 10,
            y1 = body.y1 / 10,
            x2 = body.x2 / 10,
            y2 = body.y2 / 10,
            uid2 = pseudo_uid(&sym.name, 14),
        ))?);
    } else {
        for (gi, g) in sym.graphics.iter().enumerate() {
            out.extend(emit_graphic(g, &sym.name, gi)?);
        }
    }

    // Pin records (binary).
    for (i, pin) in sym.pins.iter().enumerate() {
        out.extend(frame_pin(encode_pin_payload(pin, i)?));
    }

    // RECORD=34 — Designator label.
    out.extend(u16_text_record(&format!(
        "|RECORD=34|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=5|Color=8388608\
|FontID=1|Text={prefix}?|Name=Designator|ReadOnlyState=1|UniqueID={uid3}",
        uid3 = pseudo_uid(&sym.name, 34),
    ))?);

    // RECORD=41 — Comment label.
    out.extend(u16_text_record(&format!(
        "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=-15|Color=8388608\
|FontID=1|Text=*|Name=Comment|UniqueID={uid4}",
        uid4 = pseudo_uid(&sym.name, 41),
    ))?);

    // RECORD=41 — component property fields (Value, Manufacturer, MPN, …). Each
    // is another parameter record; visible ones are drawn (stacked below the
    // comment), the rest carry IsHidden=T like standard metadata parameters.
    for (pi, param) in sym.parameters.iter().enumerate() {
        let uidp = pseudo_uid(&sym.name, 4100 + pi as u32);
        let rec = if param.visible {
            let y = -25 - (pi as i32) * 10;
            format!(
                "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y={y}\
|Color=8388608|FontID=1|Text={t}|Name={n}|UniqueID={uidp}",
                t = param.value, n = param.name
            )
        } else {
            format!(
                "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Color=8388608|FontID=1|IsHidden=T\
|Text={t}|Name={n}|UniqueID={uidp}",
                t = param.value, n = param.name
            )
        };
        out.extend(u16_text_record(&rec)?);
    }

    // RECORD=44 — implementations list. Then, if the symbol names a footprint,
    // the implementation that links it: RECORD=45 (the PCBLIB model reference)
    // plus its RECORD=46/48 children. OwnerIndex values are record positions in
    // this stream: R1=0, R14=1, pins=2..(pin_count+1), R34, R41, params.., R44…
    out.extend(u16_text_record("|RECORD=44")?);
    if let Some(fp) = sym.footprint.as_deref().filter(|s| !s.is_empty()) {
        // record positions: R1=0, body records, pins, R34, R41-comment, params, R44.
        let body_recs = if sym.graphics.is_empty() { 1 } else { sym.graphics.len() };
        let idx_44 = pin_count + body_recs + 3 + sym.parameters.len();
        let idx_45 = idx_44 + 1;
        // RECORD=45 — the PCBLIB model link. ModelName + a single datafile entry
        // both name the footprint; IsCurrent=T makes it the active footprint.
        out.extend(u16_text_record(&format!(
            "|RECORD=45|OwnerIndex={idx_44}|IndexInSheet=-1|ModelName={fp}|ModelType=PCBLIB\
|DatafileCount=1|ModelDatafileEntity0={fp}|ModelDatafileKind0=PCBLIB|IsCurrent=T|UniqueID={uid45}",
            uid45 = pseudo_uid(&sym.name, 45),
        ))?);
        // RECORD=46 / 48 — implementation children (empty → implicit identity
        // pin↔pad map, valid whenever pin numbers equal pad numbers).
        out.extend(u16_text_record(&format!("|RECORD=46|OwnerIndex={idx_45}"))?);
        out.extend(u16_text_record(&format!("|RECORD=48|OwnerIndex={idx_45}"))?);
    }

    Ok(out)
}

/// Split a mil value into Altium schematic form: a base in 10-mil units plus a
/// `_Frac` in 1/100000 of that unit, so `value_mil = base*10 + frac/10000`. This
/// is how Altium stores sub-10-mil precision (e.g. a 25-mil radius = base 2,
/// frac 50000). `floor`+positive-frac keeps negatives exact (-25 → base -3,
/// frac 50000 → -30 + 5 = -25).
fn split_coord(mil: f64) -> (i64, i64) {
    let units = mil / 10.0;
    let mut base = units.floor() as i64;
    let mut frac = ((units - units.floor()) * 100_000.0).round() as i64;
    if frac >= 100_000 {
        base += 1;
        frac -= 100_000;
    }
    (base, frac)
}

/// `|{key}={base}` plus `|{key}_Frac={frac}` (the frac field omitted when zero,
/// matching how Altium itself writes grid-aligned coordinates).
fn coord_field(key: &str, mil: f64) -> String {
    let (b, f) = split_coord(mil);
    if f != 0 {
        format!("|{key}={b}|{key}_Frac={f}")
    } else {
        format!("|{key}={b}")
    }
}

/// Emit one symbol graphic as its Altium record, with sub-10-mil precision via
/// `_Frac` fields. Polyline→RECORD=6, Rect→RECORD=14, Circle→RECORD=11,
/// Arc→RECORD=12.
fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Result<Vec<u8>> {
    let uid = pseudo_uid(name, 1000 + gi as u32);
    // Altium standard schematic-symbol line color for passives = pure blue,
    // RGB(0,0,255) stored as R + G*256 + B*65536 = 16711680. (Was 128 = maroon.)
    const BLUE: u32 = 16711680;
    match g {
        Graphic::Polyline { points, .. } => {
            let mut s = format!(
                "|RECORD=6|OwnerPartId=1|LineWidth=1|Color={BLUE}|LocationCount={}",
                points.len()
            );
            for (i, p) in points.iter().enumerate() {
                s.push_str(&coord_field(&format!("X{}", i + 1), p[0]));
                s.push_str(&coord_field(&format!("Y{}", i + 1), p[1]));
            }
            s.push_str(&format!("|UniqueID={uid}"));
            u16_text_record(&s)
        }
        Graphic::Rect { x1, y1, x2, y2, filled, .. } => u16_text_record(&format!(
            "|RECORD=14|OwnerPartId=1{}{}{}{}\
|LineWidth=1|Color={BLUE}|AreaColor=11599871|IsSolid={}|UniqueID={uid}",
            coord_field("Location.X", *x1),
            coord_field("Location.Y", *y1),
            coord_field("Corner.X", *x2),
            coord_field("Corner.Y", *y2),
            if *filled { "T" } else { "F" }
        )),
        Graphic::Circle { cx, cy, r, .. } => u16_text_record(&format!(
            "|RECORD=11|OwnerPartId=1{}{}{}{}\
|LineWidth=1|Color={BLUE}|UniqueID={uid}",
            coord_field("Location.X", *cx),
            coord_field("Location.Y", *cy),
            coord_field("Radius", *r),
            coord_field("SecondaryRadius", *r),
        )),
        Graphic::Arc { cx, cy, r, start, end, .. } => u16_text_record(&format!(
            "|RECORD=12|OwnerPartId=1{}{}{}|StartAngle={:.3}|EndAngle={:.3}\
|LineWidth=1|Color={BLUE}|UniqueID={uid}",
            coord_field("Location.X", *cx),
            coord_field("Location.Y", *cy),
            coord_field("Radius", *r),
            start, end
        )),
    }
}

/// Build the 36+ byte binary pin payload (before framing).
fn encode_pin_payload(pin: &Pin, _idx: usize) -> Result<Vec<u8>> {
    let mut p = PIN_PREFIX.to_vec();
    // conglom byte[14] = 0x04 → a plain pin (no edge-symbol triangle), rotation
    // bits (0..1) = 0. Matches Altium's own pins (build_symbol ground truth):
    // orientation lives ONLY in the length field, so conglom's rotation bits must
    // be 0 — leaving them nonzero (was 0x07 = rot 3) misplaced the pin number.
    p[14] = 0x04;
    // PIN ORIENTATION lives in the LOW 2 BITS of the length field [15:17] — NOT
    // the conglom byte. Calibrated against Altium AD26 + LM555: byte-orient 0 =
    // points left, 2 = points right. KiCad angle and Altium orientation are 180°
    // apart, so Altium_orient = (kicad_angle/90 + 2) % 4.
    let aorient = ((pin.rotation as u32 / 90 + 2) % 4) as u16;
    let length = pin
        .length
        .map(|l| (l as f64 * UNITS_PER_MIL).round() as i32)
        .unwrap_or(DEFAULT_PIN_LEN as i32);
    // The field is u16 with orientation packed in bits 0..1 — a longer pin
    // would silently wrap BOTH the length and the orientation. validate_symbols
    // rejects these up front; this is the backstop.
    if !(0..=0xFFFC).contains(&length) {
        bail!("pin {:?} length {} units exceeds the u16 field (max 2559 mil)", pin.number, length);
    }
    let field = ((length as u16) & 0xFFFC) | aorient; // length in bits 2..15, orient in 0..1
    p[15..17].copy_from_slice(&field.to_le_bytes());
    // Altium's pin Location is the BODY end (where the pin meets the symbol),
    // not the connection point. The neutral model stores the connection point
    // (KiCad convention); convert by moving length-mils toward the body, i.e.
    // along the KiCad angle direction.
    let len_mil = pin
        .length
        .unwrap_or((DEFAULT_PIN_LEN as f64 / UNITS_PER_MIL).round() as i32);
    let (dx, dy) = match (pin.rotation / 90) % 4 {
        0 => (1, 0),   // angle 0: body is to the +x
        1 => (0, 1),   // angle 90: body to +y
        2 => (-1, 0),  // angle 180: body to -x
        _ => (0, -1),  // angle 270: body to -y
    };
    let body_x = pin.x + len_mil * dx;
    let body_y = pin.y + len_mil * dy;
    // X = round(mil*25.6) as i24 at [17:20]; Y = round(mil/10) as i16 at [20:22].
    let ux = (body_x as f64 * UNITS_PER_MIL).round() as i32;
    let xb = ux.to_le_bytes();
    p[17] = xb[0];
    p[18] = xb[1];
    p[19] = xb[2];
    let uy = (body_y as f64 / 10.0).round() as i32 as i16;
    p[20..22].copy_from_slice(&uy.to_le_bytes());
    // strings: Name, Designator, "", "|&|"
    push_pstr(&mut p, &pin.name)?;
    push_pstr(&mut p, &pin.number)?;
    push_pstr(&mut p, "")?;
    push_pstr(&mut p, "|&|")?;
    p.push(0x00);
    Ok(p)
}

fn frame_pin(payload: Vec<u8>) -> Vec<u8> {
    let mut out = (payload.len() as u16).to_le_bytes().to_vec();
    out.extend_from_slice(&0x0100u16.to_le_bytes()); // flags = 0x0100 (binary pin)
    out.extend_from_slice(&payload);
    out
}

/// Pascal string: `<u8 len>` + bytes. Errors instead of silently wrapping the
/// length prefix (a >255-byte string would desync the pin record framing).
fn push_pstr(buf: &mut Vec<u8>, s: &str) -> Result<()> {
    let b = s.as_bytes();
    if b.len() > 255 {
        bail!("string too long for a pascal-string field ({} bytes, max 255): {:?}…", b.len(), &s[..32.min(s.len())]);
    }
    buf.push(b.len() as u8);
    buf.extend_from_slice(b);
    Ok(())
}

/// Altium UniqueIDs are 8 uppercase letters. Generate a stable pseudo-id from
/// the component name + a salt so distinct records don't collide. Deterministic
/// (no RNG) so encodes are reproducible.
fn pseudo_uid(name: &str, salt: u32) -> String {
    let mut h: u64 = 1469598103934665603;
    for b in name.bytes().chain(salt.to_le_bytes()) {
        h ^= b as u64;
        h = h.wrapping_mul(1099511628211);
    }
    let mut out = String::with_capacity(8);
    for i in 0..8 {
        let v = ((h >> (i * 5)) & 0x1f) as u8;
        out.push((b'A' + (v % 26)) as char);
    }
    out
}

/// CFBF storage name for a component. Only characters CFBF actually forbids
/// are replaced ('/', '\', ':', '!', controls), so names like "LM358.A" keep
/// their real spelling — important because this name doubles as the LibRef /
/// LibReference Altium resolves by. OLE2 names max out at 31 UTF-16 units.
fn sanitize_storage_name(name: &str, idx: usize) -> String {
    let cleaned: String = name
        .chars()
        .map(|c| if matches!(c, '/' | '\\' | ':' | '!') || (c as u32) < 0x20 { '_' } else { c })
        .take(31)
        .collect();
    if cleaned.is_empty() {
        format!("Component_{idx}")
    } else {
        cleaned
    }
}

// ---------------------------------------------------------------------------
// Decode (thin wrapper over the validated altium-schlib reader)
// ---------------------------------------------------------------------------

/// Decode a `.SchLib` byte image into its first component's symbol JSON, via the
/// validated `altium-schlib` reader. (Multi-component decode lands next.)
pub fn decode_schlib_first(bytes: &[u8]) -> Result<serde_json::Value> {
    altium_schlib::parse_altium_schlib_bytes(bytes).context("altium-schlib decode")
}