//! Altium `.PcbDoc` board parser.
//!
//! A PcbDoc is an OLE2 / Compound File. Each primitive type lives in its own
//! storage (`Tracks6`, `Vias6`, `Arcs6`, `Pads6`, `Nets6`, `Components6`,
//! `Board6`), each with a `Data` stream. Two record shapes appear:
//!
//!   * TEXT streams (Nets6, Components6, Board6): `[u32 len][ASCII |KEY=VAL|…]`
//!   * BINARY streams (Tracks6, Vias6, Arcs6): `[u8 type][u32 len][payload]`,
//!     with a common payload prefix — layer at 0, net index at 3 (u16, 0xFFFF =
//!     no net), component at 7 — and coordinates from offset 13 as i32 in
//!     Altium's internal unit of 1/10000 mil.
//!
//!   * Pads6 is the odd one: record type 0x02 followed by SIX length-prefixed
//!     sub-blocks; block 0 is the designator pstring and block 4 the geometry.
//!
//! Altium is Y-up, we are Y-down, so every Y is negated.
//!
//! Like the EAGLE path there is no headless Altium renderer (Altium has no CLI),
//! so these boards use our own self-render with the same interaction overlay.

use crate::pcb_render::{Arc, Board, Comp, Gfx, Pad, Track, Via, ZonePoly};
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::{Cursor, Read};

/// Altium internal unit (1/10000 mil) -> mm
const U: f64 = 2.54e-6;

pub fn looks_like_altium(bytes: &[u8]) -> bool {
    bytes.len() > 8 && bytes[..8] == [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]
}

fn i32_at(b: &[u8], o: usize) -> i32 {
    if o + 4 > b.len() { return 0; }
    i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn u32_at(b: &[u8], o: usize) -> u32 {
    if o + 4 > b.len() { return 0; }
    u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn u16_at(b: &[u8], o: usize) -> u16 {
    if o + 2 > b.len() { return 0; }
    u16::from_le_bytes([b[o], b[o + 1]])
}
fn f64_at(b: &[u8], o: usize) -> f64 {
    if o + 8 > b.len() { return 0.0; }
    let mut a = [0u8; 8];
    a.copy_from_slice(&b[o..o + 8]);
    f64::from_le_bytes(a)
}
fn mm(v: i32) -> f64 { v as f64 * U }

/// `|KEY=VAL|KEY=VAL|` -> map (keys upper-cased by Altium already)
fn kv(rec: &str) -> HashMap<String, String> {
    rec.trim_matches('|')
        .split('|')
        .filter_map(|p| p.split_once('='))
        .map(|(k, v)| (k.to_uppercase(), v.to_string()))
        .collect()
}

/// Altium writes lengths like "10909.6457mil" or "1.5mm".
fn dim(s: &str) -> f64 {
    let t = s.trim();
    if let Some(v) = t.strip_suffix("mil") { v.trim().parse::<f64>().unwrap_or(0.0) * 0.0254 }
    else if let Some(v) = t.strip_suffix("mm") { v.trim().parse::<f64>().unwrap_or(0.0) }
    else { t.parse::<f64>().unwrap_or(0.0) * U }
}

fn altium_layer(n: u8) -> &'static str {
    match n {
        1 => "F.Cu",
        32 => "B.Cu",
        2..=31 => "In.Cu",
        33 => "F.SilkS",
        34 => "B.SilkS",
        74 => "F.Cu",        // multi-layer (vias/THT pads)
        56..=73 => "F.Fab",  // mechanical / assembly layers
        _ => "",
    }
}

fn read_stream(c: &mut cfb::CompoundFile<Cursor<Vec<u8>>>, path: &str) -> Option<Vec<u8>> {
    let mut s = c.open_stream(path).ok()?;
    let mut buf = Vec::new();
    s.read_to_end(&mut buf).ok()?;
    Some(buf)
}

/// `[u32 len][bytes]*` — the ASCII property-record streams.
fn text_records(d: &[u8]) -> Vec<String> {
    let mut out = Vec::new();
    let mut off = 0usize;
    while off + 4 <= d.len() {
        let ln = u32_at(d, off) as usize;
        off += 4;
        if ln == 0 || off + ln > d.len() { break; }
        out.push(String::from_utf8_lossy(&d[off..off + ln]).trim_end_matches('\0').to_string());
        off += ln;
    }
    out
}

/// `[u8 type][u32 len][payload]*` — the fixed-shape binary streams.
fn bin_records(d: &[u8]) -> Vec<(u8, &[u8])> {
    let mut out = Vec::new();
    let mut off = 0usize;
    while off + 5 <= d.len() {
        let t = d[off];
        let ln = u32_at(d, off + 1) as usize;
        off += 5;
        if off + ln > d.len() { break; }
        out.push((t, &d[off..off + ln]));
        off += ln;
    }
    out
}

pub fn parse_pcbdoc(bytes: &[u8]) -> Result<Board, String> {
    let mut cf = cfb::CompoundFile::open(Cursor::new(bytes.to_vec()))
        .map_err(|e| format!("altium .PcbDoc: not a compound file ({e})"))?;
    let mut b = Board::default();
    // Mechanical-layer geometry, kept with its layer number and extent so we can
    // pick out which one is the BOARD OUTLINE (see below).
    let mut mech: Vec<(u8, Gfx, f64, f64, f64, f64)> = Vec::new();

    // ── nets: index -> name (the binary streams reference nets by index) ────
    let mut net_names: Vec<String> = Vec::new();
    if let Some(d) = read_stream(&mut cf, "/Nets6/Data") {
        for r in text_records(&d) {
            net_names.push(kv(&r).get("NAME").cloned().unwrap_or_default());
        }
    }
    // our model keys nets by u32 starting at 1; 0 means "no net"
    for (i, n) in net_names.iter().enumerate() {
        b.nets.insert(i as u32 + 1, n.clone());
    }
    let netid = |raw: u16| -> u32 { if raw == 0xFFFF || raw as usize >= net_names.len() { 0 } else { raw as u32 + 1 } };

    // ── tracks ──────────────────────────────────────────────────────────────
    if let Some(d) = read_stream(&mut cf, "/Tracks6/Data") {
        for (_, p) in bin_records(&d) {
            if p.len() < 33 { continue; }
            let layer = altium_layer(p[0]);
            if layer.is_empty() { continue; }
            let (x1, y1) = (mm(i32_at(p, 13)), -mm(i32_at(p, 17)));
            let (x2, y2) = (mm(i32_at(p, 21)), -mm(i32_at(p, 25)));
            let w = mm(i32_at(p, 29));
            let net = netid(u16_at(p, 3));
            if layer.ends_with(".Cu") {
                b.tracks.push(Track { x1, y1, x2, y2, width: w, layer: layer.into(), net });
            } else {
                let g = Gfx { d: format!("M {:.4} {:.4} L {:.4} {:.4}", x1, y1, x2, y2), width: w, closed_fill: false };
                match layer {
                    "F.SilkS" => b.silk_f.push(g),
                    "B.SilkS" => b.silk_b.push(g),
                    _ => mech.push((p[0], g, x1.min(x2), y1.min(y2), x1.max(x2), y1.max(y2))),
                }
            }
        }
    }

    // ── arcs (centre + radius + start/end angle, degrees CCW) ───────────────
    if let Some(d) = read_stream(&mut cf, "/Arcs6/Data") {
        for (_, p) in bin_records(&d) {
            if p.len() < 45 { continue; }
            let layer = altium_layer(p[0]);
            if layer.is_empty() { continue; }
            let (cx, cy) = (mm(i32_at(p, 13)), -mm(i32_at(p, 17)));
            let r = mm(i32_at(p, 21));
            let (sa, ea) = (f64_at(p, 25), f64_at(p, 33));
            let w = mm(i32_at(p, 41));
            let d_path = arc_path(cx, cy, r, sa, ea);
            let net = netid(u16_at(p, 3));
            if layer.ends_with(".Cu") {
                b.arcs.push(Arc { d: d_path, layer: layer.into(), width: w, net });
            } else {
                let g = Gfx { d: d_path, width: w, closed_fill: false };
                match layer {
                    "F.SilkS" => b.silk_f.push(g),
                    "B.SilkS" => b.silk_b.push(g),
                    _ => mech.push((p[0], g, cx - r, cy - r, cx + r, cy + r)),
                }
            }
        }
    }

    // ── vias ────────────────────────────────────────────────────────────────
    if let Some(d) = read_stream(&mut cf, "/Vias6/Data") {
        for (_, p) in bin_records(&d) {
            if p.len() < 29 { continue; }
            b.vias.push(Via {
                x: mm(i32_at(p, 13)), y: -mm(i32_at(p, 17)),
                size: mm(i32_at(p, 21)), drill: mm(i32_at(p, 25)),
                net: netid(u16_at(p, 3)),
            });
        }
    }

    // ── copper pours ────────────────────────────────────────────────────────
    // Polygons6 (TEXT) holds each pour's net; Regions6 (BINARY) holds the poured
    // outlines and references its parent by polygon index at payload offset 5.
    // The region's OWN net field is always 0xFFFF, so the net must come from here.
    let mut poly_net: Vec<u32> = Vec::new();
    if let Some(d) = read_stream(&mut cf, "/Polygons6/Data") {
        for r in text_records(&d) {
            let m = kv(&r);
            let raw = m.get("NET").map(|v| v.trim_end_matches('\0').trim().to_string()).unwrap_or_default();
            poly_net.push(raw.parse::<u16>().map(netid).unwrap_or(0));
        }
    }
    if let Some(d) = read_stream(&mut cf, "/Regions6/Data") {
        for (_, p) in bin_records(&d) {
            if p.len() < 22 { continue; }
            let layer = altium_layer(p[0]);
            if !layer.ends_with(".Cu") { continue; }
            let poly = u16_at(p, 5) as usize;
            let net = poly_net.get(poly).copied().unwrap_or(0);
            // [u32 kvlen][kv ascii][u32 vertex count][(f64 x, f64 y) * count]
            let kvlen = u32_at(p, 18) as usize;
            let mut q = 22 + kvlen;
            if q + 4 > p.len() { continue; }
            let count = u32_at(p, q) as usize;
            q += 4;
            if count < 3 || q + count * 16 > p.len() { continue; }
            let mut d_path = String::new();
            for k in 0..count {
                let x = f64_at(p, q + k * 16) * U;
                let y = -f64_at(p, q + k * 16 + 8) * U;
                let _ = write!(d_path, "{} {:.4} {:.4} ", if k == 0 { "M" } else { "L" }, x, y);
            }
            d_path.push('Z');
            b.zones.push(ZonePoly { d: d_path, layer: layer.into(), net });
        }
    }
    // Fills6: axis-aligned rectangular copper fills
    if let Some(d) = read_stream(&mut cf, "/Fills6/Data") {
        for (_, p) in bin_records(&d) {
            if p.len() < 29 { continue; }
            let layer = altium_layer(p[0]);
            if !layer.ends_with(".Cu") { continue; }
            let (x1, y1) = (mm(i32_at(p, 13)), -mm(i32_at(p, 17)));
            let (x2, y2) = (mm(i32_at(p, 21)), -mm(i32_at(p, 25)));
            b.zones.push(ZonePoly {
                d: format!("M {x1:.4} {y1:.4} L {x2:.4} {y1:.4} L {x2:.4} {y2:.4} L {x1:.4} {y2:.4} Z"),
                layer: layer.into(),
                net: netid(u16_at(p, 3)),
            });
        }
    }

    // ── pads (record 0x02, six length-prefixed sub-blocks) ──────────────────
    let mut pad_by_comp: HashMap<u16, Vec<usize>> = HashMap::new();
    if let Some(d) = read_stream(&mut cf, "/Pads6/Data") {
        let mut off = 0usize;
        while off < d.len() {
            let rt = d[off];
            off += 1;
            let nblocks = match rt { 0x02 => 6, 0x05 => 2, 0x01 | 0x03 | 0x04 | 0x06 | 0x09 | 0x0B | 0x0C => 1, _ => break };
            let mut blocks: Vec<&[u8]> = Vec::with_capacity(nblocks);
            let mut ok = true;
            for _ in 0..nblocks {
                if off + 4 > d.len() { ok = false; break; }
                let bl = u32_at(&d, off) as usize;
                off += 4;
                if off + bl > d.len() { ok = false; break; }
                blocks.push(&d[off..off + bl]);
                off += bl;
            }
            if !ok { break; }
            if rt != 0x02 || blocks.len() < 5 { continue; }
            let designator = {
                let b0 = blocks[0];
                if b0.is_empty() { String::new() }
                else { let n = b0[0] as usize; String::from_utf8_lossy(&b0[1..(1 + n).min(b0.len())]).to_string() }
            };
            let pd = blocks[4];
            if pd.len() < 50 { continue; }
            let layer = pd[0];
            let net = netid(u16_at(pd, 3));
            let comp = u16_at(pd, 7);
            let x = mm(i32_at(pd, 13));
            let y = -mm(i32_at(pd, 17));
            let w = u32_at(pd, 21) as f64 * U;
            let h = u32_at(pd, 25) as f64 * U;
            let hole = u32_at(pd, 45) as f64 * U;
            // Altium shape byte: 1 Round, 2 Rectangular, 3 Octagonal, 4 RoundedRect.
            // A "Round" pad is only a CIRCLE when it's square — otherwise it's a
            // stadium (e.g. the SOT-223's 1.31x3.24 tab), which read as a tiny
            // circle when this mapped 1 => circle unconditionally.
            let square = (w - h).abs() < 0.01;
            let shape = match pd[49] {
                1 => if square { "circle" } else { "oval" },
                2 => "rect",
                3 | 4 => "roundrect",
                _ => "rect",
            };
            // Rotation is a f64 in BLOCK 4 at offset 52 (not block 5, which is
            // usually absent or holds a subnormal). Reading the wrong block left
            // every rotated pad unrotated — e.g. the SOT-223's 270-degree tab and
            // pins rendered with width/height transposed.
            let rot = {
                let r = f64_at(pd, 52);
                if r.is_finite() && r.abs() <= 360.0 { r } else { 0.0 }
            };
            let side = if layer == 32 { 'B' } else { 'F' };
            let mount = if hole > 0.0 { "thru_hole" } else { "smd" };
            pad_by_comp.entry(comp).or_default().push(b.pads.len());
            b.pads.push(Pad {
                reference: String::new(), // filled in from Components6 below
                num: designator, net, x, y, rot: -rot, w, h,
                shape: shape.into(), drill: hole, mount: mount.into(), side,
            });
        }
    }

    // ── components: designator + placement; also names the pads ─────────────
    if let Some(d) = read_stream(&mut cf, "/Components6/Data") {
        for (i, r) in text_records(&d).iter().enumerate() {
            let m = kv(r);
            let reference = m.get("SOURCEDESIGNATOR").or_else(|| m.get("NAME"))
                .cloned().unwrap_or_else(|| format!("C{i}"));
            let x = m.get("X").map(|v| dim(v)).unwrap_or(0.0);
            let y = -m.get("Y").map(|v| dim(v)).unwrap_or(0.0);
            let side = if m.get("LAYER").map(|l| l.eq_ignore_ascii_case("BOTTOM")).unwrap_or(false) { "bottom" } else { "top" };

            // adopt this component's pads (they carry the component index)
            let mut bbox = [x - 0.5, y - 0.5, x + 0.5, y + 0.5];
            let idxs = pad_by_comp.get(&(i as u16)).cloned().unwrap_or_default();
            if !idxs.is_empty() {
                let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
                for &pi in &idxs {
                    let p = &mut b.pads[pi];
                    p.reference = reference.clone();
                    mnx = mnx.min(p.x - p.w / 2.0); mxx = mxx.max(p.x + p.w / 2.0);
                    mny = mny.min(p.y - p.h / 2.0); mxy = mxy.max(p.y + p.h / 2.0);
                }
                bbox = [mnx, mny, mxx, mxy];
            }
            b.comps.push(Comp {
                reference,
                value: m.get("COMMENT").cloned().unwrap_or_default(),
                footprint: m.get("PATTERN").cloned().unwrap_or_default(),
                side: side.into(),
                mpn: String::new(), lcsc: String::new(),
                x, y, bbox, pad_count: idxs.len(),
            });
        }
    }

    // ── board outline ───────────────────────────────────────────────────────
    // Altium keeps the REAL outline as ordinary geometry on a mechanical layer,
    // arcs and all — that's the properly filleted shape. `Board6`'s VX/VY vertex
    // list describes the same outline but its fillets live in separate KIND/CX/
    // CY/R/SA/EA fields, and chaining those back into one path is fiddly; drawing
    // just the vertices chamfers every rounded corner. So prefer the mechanical
    // outline: pick the mech layer whose geometry spans the largest area (the
    // outline bounds everything else) and promote it to the edge layer.
    let mut per_layer: HashMap<u8, (f64, f64, f64, f64, usize)> = HashMap::new();
    for (ly, _, x0, y0, x1, y1) in &mech {
        let e = per_layer.entry(*ly).or_insert((f64::MAX, f64::MAX, f64::MIN, f64::MIN, 0));
        e.0 = e.0.min(*x0); e.1 = e.1.min(*y0); e.2 = e.2.max(*x1); e.3 = e.3.max(*y1); e.4 += 1;
    }
    // Pick the TIGHTEST mech layer that still bounds all the copper. Taking the
    // largest instead grabs the drawing frame / title block on boards that have
    // one (SL1's A3 sheet border ballooned the board to 395x283mm).
    let (mut cx0, mut cy0, mut cx1, mut cy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    let mut fit_cu = |x: f64, y: f64| { cx0 = cx0.min(x); cy0 = cy0.min(y); cx1 = cx1.max(x); cy1 = cy1.max(y); };
    for p in &b.pads { fit_cu(p.x - p.w / 2.0, p.y - p.h / 2.0); fit_cu(p.x + p.w / 2.0, p.y + p.h / 2.0); }
    for t in &b.tracks { fit_cu(t.x1, t.y1); fit_cu(t.x2, t.y2); }
    for v in &b.vias { fit_cu(v.x - v.size / 2.0, v.y - v.size / 2.0); fit_cu(v.x + v.size / 2.0, v.y + v.size / 2.0); }
    let have_cu = cx0 <= cx1;
    let area = |v: &(f64, f64, f64, f64, usize)| (v.2 - v.0) * (v.3 - v.1);
    let tol = 2.0; // mm of slack: the outline sits slightly outside the copper
    let bounds_copper = |v: &(f64, f64, f64, f64, usize)| {
        !have_cu || (v.0 <= cx0 + tol && v.1 <= cy0 + tol && v.2 >= cx1 - tol && v.3 >= cy1 - tol)
    };
    let cands: Vec<_> = per_layer.iter().filter(|(_, v)| v.4 >= 4 && v.2 > v.0 && v.3 > v.1).collect();
    let outline_layer = cands.iter()
        .filter(|(_, v)| bounds_copper(v))
        .min_by(|a, b| area(a.1).partial_cmp(&area(b.1)).unwrap_or(std::cmp::Ordering::Equal))
        .or_else(|| cands.iter().max_by(|a, b| area(a.1).partial_cmp(&area(b.1)).unwrap_or(std::cmp::Ordering::Equal)))
        .map(|(k, _)| **k);

    for (ly, g, ..) in mech {
        if Some(ly) == outline_layer { b.edges.push(g); } else { b.fab_f.push(g); }
    }

    if b.pads.is_empty() && b.tracks.is_empty() && b.comps.is_empty() {
        return Err("altium .PcbDoc: no primitives decoded".into());
    }
    Ok(b)
}

/// Centre/radius/angles -> SVG arc. Altium angles are degrees CCW from +X, and
/// our Y is already flipped, so the sweep direction inverts.
fn arc_path(cx: f64, cy: f64, r: f64, sa: f64, ea: f64) -> String {
    let mut sweep = ea - sa;
    while sweep <= 0.0 { sweep += 360.0; }
    if sweep >= 359.9 {
        // full circle — two half arcs (a single arc command can't close)
        return format!("M {:.4} {:.4} A {r:.4} {r:.4} 0 1 0 {:.4} {:.4} A {r:.4} {r:.4} 0 1 0 {:.4} {:.4} Z",
            cx - r, cy, cx + r, cy, cx - r, cy);
    }
    let pt = |deg: f64| (cx + r * deg.to_radians().cos(), cy - r * deg.to_radians().sin());
    let (x1, y1) = pt(sa);
    let (x2, y2) = pt(ea);
    let large = if sweep > 180.0 { 1 } else { 0 };
    format!("M {:.4} {:.4} A {r:.4} {r:.4} 0 {} 0 {:.4} {:.4}", x1, y1, large, x2, y2)
}