//! Fetch a `.kicad_sch` rendered to an authentic KiCad SVG from service-kicad,
//! and compose it (cropped to the drawing) with our component hotspot overlay.
//!
//! Like the layout viewer: the visual is the EDA's own export; we only add an
//! invisible interaction layer aligned to the same page-mm coordinate system.

use crate::sch_parse::{self, Sheet};
use crate::sexpr::esc;
use anyhow::{bail, Result};
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::time::Duration;

const SERVICE_DEFAULT: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";

#[derive(Serialize)]
pub struct RenderMeta {
    pub view_box: [f64; 4],
    pub components: Vec<sch_parse::Comp>,
    pub comp_count: usize,
    #[serde(default)]
    pub sub_sheets: Vec<sch_parse::SubSheet>,
}

fn base() -> String {
    std::env::var("KICAD_SERVICE_API").unwrap_or_else(|_| SERVICE_DEFAULT.to_string()).trim_end_matches('/').to_string()
}

/// Fetch the KiCad SVG for the whole sheet.
fn fetch_svg(sch: &str) -> Result<String> {
    let url = format!("{}/kicad/sch/export/svg", base());
    let resp = ureq::post(&url)
        .set("Content-Type", "application/octet-stream")
        .timeout(Duration::from_secs(30))
        .send_bytes(sch.as_bytes())
        .map_err(|e| anyhow::anyhow!("service-kicad sch export: {e}"))?;
    let svg = resp.into_string()?;
    if !svg.contains("<path") { bail!("service-kicad returned an empty sheet"); }
    Ok(svg)
}

fn extract_inner(svg: &str) -> String {
    let open_end = match svg.find("<svg").and_then(|s| svg[s..].find('>').map(|e| s + e + 1)) { Some(i) => i, None => return String::new() };
    let close = svg.rfind("</svg>").unwrap_or(svg.len());
    svg[open_end..close].to_string()
}

/// Tag each KiCad drawing element (path / circle / stroked-text group) with the
/// component whose bbox CONTAINS the element (`data-cref`), so the frontend can
/// recolour just that symbol's real lines on hover. Wires (which span outside any
/// one symbol) and the page frame are never captured.
fn tag_symbols(inner: &str,
               segs: &HashMap<crate::kicad_self::SegKey, String>,
               circles: &HashMap<(i32, i32, i32), String>,
               fields: &[(String, String, f64, f64)],
               pins: &[(String, f64, f64)],
               labels: &[(f64, f64)]) -> String {
    use regex::Regex;
    let num = Regex::new(r"-?\d+\.?\d*").unwrap();
    // bbox of a coordinate string (from a `d`/`points` attr; NOT the whole tag)
    let ebbox = |coords: &str| -> Option<(f64, f64, f64, f64)> {
        let ns: Vec<f64> = num.find_iter(coords).filter_map(|m| m.as_str().parse().ok()).collect();
        if ns.len() < 2 { return None; }
        let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
        for p in ns.chunks_exact(2) { mnx = mnx.min(p[0]); mxx = mxx.max(p[0]); mny = mny.min(p[1]); mxy = mxy.max(p[1]); }
        if mnx.is_finite() { Some((mnx, mny, mxx, mxy)) } else { None }
    };
    // The ONLY text that is a symbol's own is its Reference/Value field, placed at
    // an exact `(at)` position (`fields`). Given a rendered text group's page bbox,
    // return the reference whose Reference/Value anchor sits inside it (closest to
    // centre when several qualify). Pin names/numbers, net & power labels have no
    // field anchor there, so they stay standalone — exactly the user's rule.
    let field_at = |bb: (f64, f64, f64, f64)| -> Option<&str> {
        let (x0, y0, x1, y1) = bb;
        let t = 1.5;
        let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
        let mut best: Option<(&str, f64)> = None;
        for (rf, _txt, fx, fy) in fields {
            if *fx >= x0 - t && *fx <= x1 + t && *fy >= y0 - t && *fy <= y1 + t {
                let d = (fx - cx).powi(2) + (fy - cy).powi(2);
                if best.map_or(true, |(_, bd)| d < bd) { best = Some((rf.as_str(), d)); }
            }
        }
        best.map(|(r, _)| r)
    };
    // PIN NAME / NUMBER text: symbol geometry too. A pin's body-end anchors its
    // name, its stub-midpoint anchors its number; a text group covering either (a
    // bit of slack) belongs to that symbol. Net/power labels sit at the pin's
    // CONNECTION point, which is NOT an anchor here, so they stay standalone.
    let pin_at = |bb: (f64, f64, f64, f64)| -> Option<&str> {
        let (x0, y0, x1, y1) = bb;
        let t = 1.6;
        let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
        let mut best: Option<(&str, f64)> = None;
        for (rf, ax, ay) in pins {
            if *ax >= x0 - t && *ax <= x1 + t && *ay >= y0 - t && *ay <= y1 + t {
                let d = (ax - cx).powi(2) + (ay - cy).powi(2);
                if best.map_or(true, |(_, bd)| d < bd) { best = Some((rf.as_str(), d)); }
            }
        }
        best.map(|(r, _)| r)
    };

    // Injecting an attribute must PRESERVE self-closing tags: KiCad emits
    // `<path … />`, so the captured tail ends in `/`. Strip it and re-emit `/>`,
    // otherwise the element stops being self-closed and the rest of the sheet
    // nests inside it (which mangles the whole drawing).
    let split_close = |tail: &str| -> (String, &'static str) {
        let t = tail.trim_end();
        match t.strip_suffix('/') {
            Some(rest) => (rest.to_string(), "/>"),
            None => (tail.to_string(), ">"),
        }
    };

    // Only SYMBOL graphics belong to a component. KiCad wraps each primitive in a
    // `<g style="…stroke:#RRGGBB…">`: symbol bodies + fields are RED-family
    // (#840000, #A90000), while WIRES/junctions/no-connects are GREEN/TEAL
    // (#006464, #009600) and the paper is near-white. A big connector like J3 has
    // a 25x35mm bbox that ENCLOSES lots of wires and labels; tagging by bbox alone
    // captured them all, so hovering J3 lit half the sheet. Track the enclosing
    // group's stroke colour and only tag inside red-family (or stroked-text)
    // groups — a wire is never part of a component.
    let gcolor = Regex::new(r#"stroke:\s*#([0-9A-Fa-f]{6})"#).unwrap();
    // red-family = R channel clearly the largest → a symbol/field stroke
    let is_symbol_color = |hex: &str| -> bool {
        let (r, g, b) = (
            u8::from_str_radix(&hex[0..2], 16).unwrap_or(0),
            u8::from_str_radix(&hex[2..4], 16).unwrap_or(0),
            u8::from_str_radix(&hex[4..6], 16).unwrap_or(0),
        );
        r as i32 > g as i32 + 20 && r as i32 > b as i32 + 20
    };

    let tag_re = Regex::new(r#"<g\b[^>]*>|</g>|<path\b[^>]*>|<rect\b[^>]*>|<circle\b[^>]*>"#).unwrap();
    let path_d = Regex::new(r#"\bd="([^"]*)""#).unwrap();
    let rect_xywh = Regex::new(r#"x="([-\d.]+)"\s*y="([-\d.]+)"\s*width="([-\d.]+)"\s*height="([-\d.]+)""#).unwrap();
    let circ_cxy = Regex::new(r#"cx="([-\d.]+)"\s*cy="([-\d.]+)""#).unwrap();
    let circ_r = Regex::new(r#"cx="([-\d.]+)"\s*cy="([-\d.]+)"\s*r="([-\d.]+)""#).unwrap();
    let tf_re = Regex::new(r#"transform="([^"]*)""#).unwrap();

    // KiCad rotates symbols/text with `transform="rotate(a cx cy)"`; the element's
    // raw coords are in the PRE-rotation frame, so we must compose the group
    // transform stack and map coords to PAGE space before containment-testing —
    // otherwise a rotated label lands in a random bbox (this is what lit up J3).
    #[derive(Clone, Copy)]
    struct M { a: f64, b: f64, c: f64, d: f64, e: f64, f: f64 } // [a c e; b d f]
    let ident = M { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 };
    let mul = |p: M, q: M| M {
        a: p.a * q.a + p.c * q.b, b: p.b * q.a + p.d * q.b,
        c: p.a * q.c + p.c * q.d, d: p.b * q.c + p.d * q.d,
        e: p.a * q.e + p.c * q.f + p.e, f: p.b * q.e + p.d * q.f + p.f,
    };
    let apply = |m: &M, x: f64, y: f64| (m.a * x + m.c * y + m.e, m.b * x + m.d * y + m.f);
    let parse_tf = |s: &str, base: M| -> M {
        let mut cur = base;
        for cap in Regex::new(r"(\w+)\s*\(([^)]*)\)").unwrap().captures_iter(s) {
            let args: Vec<f64> = cap[2].split([',', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.parse().ok()).collect();
            let t = match (&cap[1], args.as_slice()) {
                ("translate", [x, y, ..]) => M { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: *x, f: *y },
                ("translate", [x]) => M { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: *x, f: 0.0 },
                ("scale", [sx, sy, ..]) => M { a: *sx, b: 0.0, c: 0.0, d: *sy, e: 0.0, f: 0.0 },
                ("scale", [sx]) => M { a: *sx, b: 0.0, c: 0.0, d: *sx, e: 0.0, f: 0.0 },
                ("rotate", [deg, cx, cy, ..]) => {
                    let r = deg.to_radians(); let (co, si) = (r.cos(), r.sin());
                    // translate(cx,cy) * rotate * translate(-cx,-cy)
                    M { a: co, b: si, c: -si, d: co, e: cx - co * cx + si * cy, f: cy - si * cx - co * cy }
                }
                ("rotate", [deg]) => { let r = deg.to_radians(); M { a: r.cos(), b: r.sin(), c: -r.sin(), d: r.cos(), e: 0.0, f: 0.0 } }
                _ => M { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 },
            };
            cur = mul(cur, t);
        }
        cur
    };

    // each group pushes (taggable colour flag, accumulated page transform)
    let mut stack: Vec<(Option<bool>, M)> = Vec::new();
    let taggable = |st: &[(Option<bool>, M)]| st.iter().rev().find_map(|x| x.0).unwrap_or(false);
    let curm = |st: &[(Option<bool>, M)]| st.last().map(|x| x.1).unwrap_or(ident);
    // transform a raw-coord bbox into page space (all four corners)
    let to_page = |m: &M, bb: (f64, f64, f64, f64)| -> (f64, f64, f64, f64) {
        let (x0, y0, x1, y1) = bb;
        let cs = [apply(m, x0, y0), apply(m, x1, y0), apply(m, x1, y1), apply(m, x0, y1)];
        let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
        for (x, y) in cs { mnx = mnx.min(x); mxx = mxx.max(x); mny = mny.min(y); mxy = mxy.max(y); }
        (mnx, mny, mxx, mxy)
    };
    let inject = |tag: &str, cref: &str| -> String {
        let t = tag.trim_end();
        if let Some(rest) = t.strip_suffix("/>") { format!(r#"{} data-cref="{}"/>"#, rest.trim_end(), cref) }
        else if let Some(rest) = t.strip_suffix('>') { format!(r#"{} data-cref="{}">"#, rest.trim_end(), cref) }
        else { tag.to_string() }
    };

    // raw-coord bbox of any single element tag
    let elem_bbox = |tag: &str| -> Option<(f64, f64, f64, f64)> {
        if tag.starts_with("<path") { path_d.captures(tag).and_then(|c| ebbox(&c[1])) }
        else if tag.starts_with("<rect") { rect_xywh.captures(tag).map(|c| { let (x, y, w, h) = (f2(&c[1]), f2(&c[2]), f2(&c[3]), f2(&c[4])); (x, y, x + w, y + h) }) }
        else if tag.starts_with("<circle") { circ_cxy.captures(tag).map(|c| { let (x, y) = (f2(&c[1]), f2(&c[2])); (x, y, x, y) }) }
        else { None }
    };

    let mut out = String::with_capacity(inner.len() + 4096);
    let mut last = 0;
    let bytes = inner.as_bytes();
    for m in tag_re.find_iter(inner) {
        if m.start() < last { continue; } // inside a block we already consumed
        out.push_str(&inner[last..m.start()]);
        last = m.end();
        let tag = m.as_str();
        if tag.starts_with("<g") {
            // A stroked-text group is ONE text string (a ref/value/pin-name/net-label).
            // Tag the WHOLE group by its combined position so a label is never split
            // across components and stray text can't be claimed letter-by-letter by a
            // big symbol like J3. Consume the whole group here.
            if tag.contains(r#"class="stroked-text""#) {
                let base = curm(&stack);
                let mat = tf_re.captures(tag).map(|c| parse_tf(&c[1], base)).unwrap_or(base);
                // find the matching </g> (stroked-text groups don't nest)
                let mut depth = 1i32; let mut i = m.end();
                while i < inner.len() && depth > 0 {
                    if inner[i..].starts_with("<g") { depth += 1; i += 2; }
                    else if inner[i..].starts_with("</g>") { depth -= 1; if depth == 0 { break; } i += 4; }
                    else { i += 1; }
                }
                let close_end = (i + 4).min(inner.len());
                let block = &inner[m.end()..i.min(inner.len())];
                // combined page-space bbox of every stroke in the group
                let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
                for em in tag_re.find_iter(block) {
                    if let Some(bb) = elem_bbox(em.as_str()) {
                        let (a, b, c, d) = to_page(&mat, bb);
                        mnx = mnx.min(a); mny = mny.min(b); mxx = mxx.max(c); mxy = mxy.max(d);
                    }
                }
                // A text group belongs to a symbol when a Reference/Value field
                // anchor sits inside it, OR when it lands on one of the symbol's pin
                // name/number anchors. But if it sits on a power/net LABEL anchor it's
                // a label (e.g. GND) — never tag it, even if a nearby pin anchor would
                // otherwise claim it.
                // `labels` are the ACTUAL label-text positions, so a tight radius
                // catches the label itself without swallowing a nearby pin number.
                let on_label = mnx.is_finite() && {
                    let (cx, cy) = ((mnx + mxx) / 2.0, (mny + mxy) / 2.0);
                    labels.iter().any(|&(lx, ly)| (cx - lx).abs() < 1.5 && (cy - ly).abs() < 1.5)
                };
                let cref = if mnx.is_finite() && !on_label {
                    field_at((mnx, mny, mxx, mxy)).or_else(|| pin_at((mnx, mny, mxx, mxy)))
                } else { None };
                out.push_str(tag);
                match cref {
                    Some(r) => { // tag every path in the group with the same cref
                        let mut bl = block.to_string();
                        // inject cref into each element tag
                        let mut o2 = String::with_capacity(bl.len() + 256); let mut l2 = 0;
                        for em in tag_re.find_iter(&bl) {
                            o2.push_str(&bl[l2..em.start()]); l2 = em.end();
                            let et = em.as_str();
                            if et.starts_with("<path") || et.starts_with("<rect") || et.starts_with("<circle") { o2.push_str(&inject(et, r)); }
                            else { o2.push_str(et); }
                        }
                        o2.push_str(&bl[l2..]); bl = o2;
                        out.push_str(&bl);
                    }
                    None => out.push_str(block),
                }
                out.push_str("</g>");
                last = close_end;
                let _ = bytes; // (kept for clarity)
                continue;
            }
            let flag = gcolor.captures(tag).map(|c| is_symbol_color(&c[1]));
            let base = curm(&stack);
            let mat = tf_re.captures(tag).map(|c| parse_tf(&c[1], base)).unwrap_or(base);
            stack.push((flag, mat));
            out.push_str(tag);
        } else if tag == "</g>" {
            stack.pop();
            out.push_str(tag);
        } else if !gcolor.captures(tag).map(|c| is_symbol_color(&c[1])).unwrap_or_else(|| taggable(&stack)) {
            // Taggable when the element carries its OWN symbol-colour stroke inline,
            // else fall back to the enclosing group's colour. KiCad emits some thick
            // symbol strokes (cap plates, ferrite bodies) as standalone
            // `<path style="stroke:#840000">` outside any coloured <g>; without the
            // inline-colour check those were skipped and never highlighted.
            out.push_str(tag);
        } else {
            // SYMBOL GRAPHIC: tag by GEOMETRY, not by region. Map the stroke's
            // segments to page space and match them to a symbol's exact segments —
            // a wire or another part's line never coincides, so it's never claimed.
            let m0 = curm(&stack);
            let mt = (m0.a, m0.b, m0.c, m0.d, m0.e, m0.f);
            let _ = &elem_bbox; // (bbox helper retained for the text branch)
            let cref = if tag.starts_with("<path") {
                path_d.captures(tag).and_then(|c| match_path_segments(&c[1], &mt, segs, circles))
            } else if tag.starts_with("<rect") {
                // KiCad draws symbol bodies as <rect> (fill + outline). Match its 4
                // edges to the symbol's segments exactly like a path — the page frame
                // and title-block rects span no symbol so they're never claimed.
                rect_xywh.captures(tag).and_then(|c| {
                    let (x, y, w, h) = (f2(&c[1]), f2(&c[2]), f2(&c[3]), f2(&c[4]));
                    let d = format!("M {x} {y} L {} {y} L {} {} L {x} {} L {x} {y}", x + w, x + w, y + h, y + h);
                    match_path_segments(&d, &mt, segs, circles)
                })
            } else if tag.starts_with("<circle") {
                // KiCad draws test-point / switch-contact circles as <circle cx cy r>.
                // Map the centre to page space and match against the symbol circles
                // (centre+radius). Without this, those circles never highlighted.
                circ_r.captures(tag).and_then(|c| {
                    let (cx, cy, r) = (f2(&c[1]), f2(&c[2]), f2(&c[3]));
                    let (px, py) = apply(&m0, cx, cy);
                    let q = |v: f64| (v * 20.0).round() as i32;
                    let mut found = None;
                    for dx in -1..=1 { for dy in -1..=1 { for dr in -1..=1 {
                        if let Some(rf) = circles.get(&(q(px) + dx, q(py) + dy, q(r) + dr)) { found = Some(rf.clone()); }
                    }}}
                    found
                })
            } else { None };
            match cref {
                Some(r) => out.push_str(&inject(tag, &r)),
                None => out.push_str(tag),
            }
        }
    }
    out.push_str(&inner[last..]);
    out
}
fn f2(s: &str) -> f64 { s.parse().unwrap_or(0.0) }

/// Extract the point sequence a path `d` visits (M/L/A endpoints), map to page
/// space, form segments, and return the component whose segments they match.
fn match_path_segments(d: &str, m: &(f64, f64, f64, f64, f64, f64),
                       segs: &HashMap<crate::kicad_self::SegKey, String>,
                       circles: &HashMap<(i32, i32, i32), String>) -> Option<String> {
    // Parse the path into the points it visits. KiCad's SVG export is NOT uniform:
    //   - thin lines:  "M177.0022 166.0072\nL285.0022 166.0072"  (glued cmd, spaces)
    //   - thick lines: "M 211.3280,52.8320 211.3280,48.7680"     (COMMAS + IMPLICIT
    //                   lineto — the 2nd pair has no `L`) ← caps/ferrite plates
    //   - arcs:        "M76.20 86.36 A0.635 0.635 0 0 0 74.93 86.36"
    // Handle all three: commas → spaces, split the command letter off its digits,
    // then walk a tiny state machine where a bare coordinate pair repeats the last
    // command (so implicit linetos after M/L are captured). Missing this is exactly
    // why cap plates and ferrite bodies never highlighted.
    let norm: String = d.chars().map(|c| if c == ',' { ' ' } else { c }).collect();
    let spaced: String = norm.chars().flat_map(|c| match c {
        'M' | 'L' | 'A' | 'Z' | 'H' | 'V' | 'C' | 'm' | 'l' | 'a' | 'z' | 'h' | 'v' | 'c' => vec![' ', c, ' '],
        _ => vec![c],
    }).collect();
    let toks: Vec<&str> = spaced.split_whitespace().collect();
    let numf = |t: &str| t.parse::<f64>().ok();
    let mut pts: Vec<(f64, f64)> = Vec::new();
    let mut cmd = 'M';
    let mut k = 0;
    while k < toks.len() {
        let t = toks[k];
        let ch = t.chars().next().unwrap_or(' ');
        if t.len() == 1 && ch.is_ascii_alphabetic() {
            cmd = ch;
            k += 1;
            if cmd == 'Z' || cmd == 'z' { if let Some(&p0) = pts.first() { pts.push(p0); } }
            continue;
        }
        match cmd {
            'M' | 'L' | 'm' | 'l' => {
                if let (Some(x), Some(y)) = (numf(t), toks.get(k + 1).and_then(|s| numf(s))) { pts.push((x, y)); }
                k += 2;
            }
            'A' | 'a' => { // rx ry rot large sweep x y → take the endpoint
                if let (Some(x), Some(y)) = (toks.get(k + 5).and_then(|s| numf(s)), toks.get(k + 6).and_then(|s| numf(s))) { pts.push((x, y)); }
                k += 7;
            }
            _ => { k += 1; } // H/V/C rare in symbol graphics — skip
        }
    }
    // page-space points
    let (a, b, c, d2, e, f) = *m;
    let page: Vec<(f64, f64)> = pts.iter().map(|&(x, y)| (a * x + c * y + e, b * x + d2 * y + f)).collect();
    // vote across this path's segments
    let mut votes: HashMap<&str, u32> = HashMap::new();
    for w in page.windows(2) {
        if let Some(r) = segs.get(&crate::kicad_self::seg_key_pub(w[0], w[1])) { *votes.entry(r.as_str()).or_insert(0) += 1; }
    }
    // an arc/circle stroke: match by its bbox centre + radius
    if votes.is_empty() && page.len() >= 2 {
        let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
        for (x, y) in &page { mnx = mnx.min(*x); mxx = mxx.max(*x); mny = mny.min(*y); mxy = mxy.max(*y); }
        let (cx, cy, r) = ((mnx + mxx) / 2.0, (mny + mxy) / 2.0, ((mxx - mnx) + (mxy - mny)) / 4.0);
        let q = |v: f64| (v * 20.0).round() as i32;
        for dx in -1..=1 { for dy in -1..=1 { for dr in -1..=1 {
            if let Some(r0) = circles.get(&(q(cx) + dx, q(cy) + dy, q(r) + dr)) { return Some(r0.clone()); }
        }}}
    }
    votes.into_iter().max_by_key(|(_, n)| *n).map(|(r, _)| r.to_string())
}

fn build_meta(sheet: &Sheet) -> RenderMeta {
    let (x, y, w, h) = sheet.content_bbox;
    RenderMeta { view_box: [x, y, w, h], comp_count: sheet.comps.len(), components: sheet.comps.clone(), sub_sheets: sheet.sub_sheets.clone() }
}

/// Top-level render: parse identity → fetch KiCad SVG → crop to the drawing →
/// overlay component hotspots. All share the page-mm coordinate system.
/// Bytes-level entry point. Altium `.SchDoc` is a BINARY compound file, so it
/// cannot come in through the text path; everything else is UTF-8 text.
pub fn render_sheet_bytes(bytes: &[u8]) -> Result<(String, RenderMeta)> { render_sheet_bytes_base(bytes, None) }

pub fn render_sheet_bytes_base(bytes: &[u8], base: Option<&str>) -> Result<(String, RenderMeta)> {
    if crate::altium_sch::looks_like_altium(bytes) {
        match crate::altium_sch::render_sheet(bytes) {
            Ok((svg, sheet)) => { let meta = build_meta(&sheet); return Ok((svg, meta)); }
            Err(e) => eprintln!("[adom-schematic] altium parse failed ({e})"),
        }
    }
    render_sheet_with_base(&String::from_utf8_lossy(bytes), base)
}

pub fn render_sheet(sch: &str) -> Result<(String, RenderMeta)> { render_sheet_with_base(sch, None) }

/// `base` is an AUTHENTIC pre-rendered KiCad SVG for this exact sheet (from the
/// wiki page's `render/sheet-NNN.svg`, produced by molecule-publish against the
/// FULL project). When present we use it verbatim as the visual base — real KiCad
/// fonts, and hierarchical roots render correctly — instead of asking service-kicad
/// (which can't render a lone hierarchical root). We NEVER self-draw text when a
/// base is available: the export must look exactly like the EDA.
pub fn render_sheet_with_base(sch: &str, base: Option<&str>) -> Result<(String, RenderMeta)> {
    // Fusion 360 Electronics / EAGLE `.sch`: no headless renderer exists, so we
    // draw the sheet ourselves (in EAGLE's own colours) and tag each primitive
    // with its component directly — no bbox inference needed.
    if crate::eagle_sch::looks_like_eagle(sch) {
        match crate::eagle_sch::render_sheet(sch) {
            Ok((svg, sheet)) => { let meta = build_meta(&sheet); return Ok((svg, meta)); }
            Err(e) => eprintln!("[adom-schematic] eagle/fusion parse failed ({e}); trying KiCad"),
        }
    }
    let sheet = sch_parse::parse(sch);
    let meta = build_meta(&sheet);
    // Prefer the authentic pre-rendered base; else the shared service-kicad export.
    let raw_base = match base {
        Some(b) if b.contains("<path") || b.contains("<text") => b.to_string(),
        _ => fetch_svg(sch).unwrap_or_default(),
    };
    // GROUND TRUTH for hover highlighting: each symbol's exact line segments, from
    // lib_symbols transformed to the instance placement. A stroke in the export
    // belongs to a component IFF it coincides with one of these segments — a wire
    // or another part's line never does. (Text is still matched by position.)
    let (segs, circles) = crate::kicad_self::symbol_segments(sch);
    // the only TEXT that is a symbol's own: its Reference + Value fields, at their
    // exact `(at)` positions. Everything else (pin names/numbers, net/power labels)
    // is standalone and stays untagged.
    let fields = crate::kicad_self::field_anchors(sch);
    // pin names (inside the body) + pin numbers (on the stubs) are symbol geometry
    // too, so tag any text landing on a pin's body-end / stub-midpoint anchor.
    let pins = crate::kicad_self::pin_anchors(sch);
    // power/net label positions — text sitting on one is a LABEL (e.g. GND), never a
    // symbol's own field/pin text, so it must not be tagged to a nearby component.
    let labels = crate::kicad_self::label_anchors(sch);
    let inner = tag_symbols(&extract_inner(&raw_base), &segs, &circles, &fields, &pins, &labels);
    // LAST RESORT ONLY (no authentic base AND service-kicad returned empty — e.g. a
    // hierarchical root on a page with no pre-render): self-draw so it isn't blank.
    let inner = if base.is_none() && inner.matches("stroke:").count() < 60 && (!sheet.sub_sheets.is_empty() || !sheet.comps.is_empty()) {
        let self_render = crate::kicad_self::render_inner(sch);
        if self_render.is_empty() { inner } else { format!("{}{}", self_render, sheet_nav_svg(&sheet)) }
    } else { inner };
    let (x, y, w, h) = sheet.content_bbox;

    let mut s = String::new();
    let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.3} {:.3} {:.3} {:.3}" preserveAspectRatio="xMidYMid meet">"##, x, y, w, h);
    let _ = write!(s, r##"<rect class="adom-sheet-bg" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="#fbfbf6"/>"##, x, y, w, h);
    let _ = write!(s, r##"<g class="adom-sheet">{}</g>"##, inner);
    s.push_str(&sch_parse::build_overlay(&sheet));
    s.push_str("</svg>");
    Ok((s, meta))
}

/// Self-drawn hierarchical navigation base: one KiCad-style sheet box per child
/// (border + sheet name inside the top edge + filename below), used when
/// service-kicad returns an empty root page. The clickable overlay sits on top.
fn sheet_nav_svg(sheet: &Sheet) -> String {
    let mut s = String::new();
    for sh in &sheet.sub_sheets {
        let [x0, y0, x1, y1] = sh.bbox;
        let (w, h) = ((x1 - x0).max(2.0), (y1 - y0).max(2.0));
        // KiCad sheet-symbol colours: brown/red border + name, blue file text.
        let _ = write!(s, r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="none" stroke="#843c39" stroke-width="0.15"/>"##, x0, y0, w, h);
        let _ = write!(s, r##"<text x="{:.3}" y="{:.3}" font-size="2.0" fill="#843c39" font-family="monospace">{}</text>"##, x0, y0 - 0.8, esc(&sh.name));
        let _ = write!(s, r##"<text x="{:.3}" y="{:.3}" font-size="1.5" fill="#0d6d6d" font-family="monospace">File: {}</text>"##, x0, y1 + 2.0, esc(&sh.file));
    }
    s
}

/// Meta-only (for callers that already have an SVG or want the component list).
pub fn meta_json(sch: &str) -> Result<String> {
    Ok(serde_json::to_string(&build_meta(&sch_parse::parse(sch)))?)
}

// small helper so the map type is available if needed later
#[allow(dead_code)]
type _M = HashMap<String, String>;
