//! `.kicad_mod` generator — faithful Rust port of the Node `generateKicadMod`.
//! Pure string-building from explicit pad/body/courtyard data; output is
//! byte-identical to the Node generator (verified by the golden parity test).
//! No gallia, no Node.

pub struct Pad {
    pub number: String,
    pub x: f64,
    pub y: f64,
    pub dx: f64,
    pub dy: f64,
    pub shape: Option<String>,
    pub angle: Option<f64>,
    pub layers: Option<String>,
    /// Mount type: `smd` (default) | `thru_hole` | `np_thru_hole`. Drives the pad
    /// token and the default copper/mask layers.
    pub mount: Option<String>,
    /// Drill diameter (mm) for thru-hole pads. Emits `(drill D)` — without it a
    /// thru-hole pad is meaningless and adom-lbr can only make an EAGLE `<smd>`.
    pub drill: Option<f64>,
}

pub struct Rect {
    pub x1: f64,
    pub y1: f64,
    pub x2: f64,
    pub y2: f64,
}

pub struct FpInput {
    pub name: String,
    pub pads: Vec<Pad>,
    pub body: Option<Rect>,
    pub courtyard: Option<Rect>,
    pub description: String,
    pub tags: String,
    /// Body/silk outline shape: `rect` (default) | `circle`. For round parts,
    /// `circle` emits `fp_circle` on F.Fab + F.SilkS (real round silk) instead of
    /// a bounding-box rectangle — adom-lbr transcribes it to an EAGLE `<circle>`.
    pub body_shape: Option<String>,
}

/// Print an f64 the way JS `${x}` / Number.toString does (shortest round-trip,
/// integers without a trailing ".0"). Rust's `{}` already matches JS here,
/// including artifacts like `-0.5700000000000001`.
fn n(x: f64) -> String {
    format!("{x}")
}

/// Round to 2 decimals — keeps derived text anchors clean (no `-1.4699999…`
/// float artifacts) and on a sensible KiCad grid.
fn round2(x: f64) -> f64 {
    (x * 100.0).round() / 100.0
}

/// Render a rotation suffix for a `(at x y[ rot])` token (omitted when 0).
fn rot(r: f64) -> String {
    if r != 0.0 { format!(" {}", n(r)) } else { String::new() }
}

/// One placed text item (RefDes or Value). Y is footprint-down (KiCad convention).
#[derive(serde::Serialize, Clone)]
pub struct TextItem {
    pub x: f64,
    pub y: f64,
    pub size: f64,
    pub rotation: f64,
    pub anchor: String,
    pub layer: String,
}

/// The keep-out the RefDes/Value text must stay OUTSIDE — the full footprint
/// extent (pads ∪ courtyard ∪ body). Named `courtyard` to match what every
/// downstream exporter validates against.
#[derive(serde::Serialize, Clone)]
pub struct KeepoutRect {
    pub top: f64,
    pub bottom: f64,
    pub left: f64,
    pub right: f64,
}

/// Explicit RefDes/Value placement INTENT, so exporters (adom-lbr → Fusion/.lbr,
/// Altium, OrCAD) validate against it instead of re-deriving from pads. This is
/// the SINGLE placement — exporters handle per-tool syntax (Y-flip, layer
/// numbers, anchor), there is NO per-tool switch here. Serializes to the
/// `textPlacement` block of `<mpn>-footprint.extracted.json`.
///
/// Invariant (Y-down): `reference.y <= courtyard.top` AND `value.y >= courtyard.bottom`.
#[derive(serde::Serialize, Clone)]
pub struct TextPlacement {
    pub units: String,
    #[serde(rename = "yAxis")]
    pub y_axis: String,
    #[serde(rename = "originRelative")]
    pub origin_relative: String,
    pub reference: TextItem,
    pub value: TextItem,
    pub courtyard: KeepoutRect,
}

/// Clearance (mm) the text sits beyond the full footprint extent.
const TEXT_CLEARANCE_MM: f64 = 1.0;

/// Text height (mm). 1 mm (KiCad default) for normal parts; scaled down so the
/// label is never larger than a tiny part. NOTE: adom-lbr's .lbr path currently
/// hardcodes 1.27 mm and drops rotation, so this size only reaches
/// KiCad/Altium/OrCAD — keep parts that must read well in Fusion at ~1 mm.
fn text_size(input: &FpInput) -> f64 {
    let (xmin, ymin, xmax, ymax) = bbox(input);
    let max_extent = (xmax - xmin).max(ymax - ymin);
    let s = (max_extent / 2.5).clamp(0.5, 1.0);
    (s * 10.0).round() / 10.0
}

/// The canonical RefDes/Value placement for a footprint — the one source of
/// truth used both to emit the `.kicad_mod` fp_text and the `textPlacement`
/// metadata. Centered on X; RefDes ~1 mm beyond the top extent, Value ~1 mm
/// beyond the bottom extent, off the full pads ∪ courtyard ∪ body bbox.
pub fn text_placement(input: &FpInput) -> TextPlacement {
    let (xmin, ymin, xmax, ymax) = bbox(input);
    let size = text_size(input);
    TextPlacement {
        units: "mm".into(),
        y_axis: "down".into(),
        origin_relative: "footprint-center".into(),
        reference: TextItem {
            x: 0.0,
            y: round2(ymin - TEXT_CLEARANCE_MM),
            size,
            rotation: 0.0,
            anchor: "center".into(),
            layer: "F.SilkS".into(),
        },
        value: TextItem {
            x: 0.0,
            y: round2(ymax + TEXT_CLEARANCE_MM),
            size,
            rotation: 0.0,
            anchor: "center".into(),
            layer: "F.Fab".into(),
        },
        courtyard: KeepoutRect {
            top: round2(ymin),
            bottom: round2(ymax),
            left: round2(xmin),
            right: round2(xmax),
        },
    }
}

pub fn generate_kicad_mod(input: &FpInput) -> String {
    let mut s = format!("(footprint \"{}\"\n", input.name);
    s += "  (version 20240108)\n  (generator \"adom-footprint\")\n  (layer \"F.Cu\")\n";
    if !input.description.is_empty() {
        s += &format!("  (descr \"{}\")\n", input.description);
    }
    if !input.tags.is_empty() {
        s += &format!("  (tags \"{}\")\n", input.tags);
    }
    s += "  (attr smd)\n";

    // Courtyard
    if let Some(c) = &input.courtyard {
        s += &format!("  (fp_rect (start {} {}) (end {} {})\n", n(c.x1), n(c.y1), n(c.x2), n(c.y2));
        s += "    (stroke (width 0.05) (type default)) (fill none) (layer \"F.CrtYd\"))\n";
    }

    // For round parts, the body/fab/silk outline is a circle, not a rect — so a
    // machine pin / round part carries real round silk (adom-lbr → EAGLE <circle>)
    // instead of a bounding box. Centre + radius derive from the body rect.
    let round = input.body_shape.as_deref() == Some("circle");
    let circle_of = |b: &Rect, grow: f64| -> (f64, f64, f64) {
        let (cx, cy) = ((b.x1 + b.x2) / 2.0, (b.y1 + b.y2) / 2.0);
        let r = (b.x2 - b.x1).abs().max((b.y2 - b.y1).abs()) / 2.0 + grow;
        (cx, cy, r)
    };

    // Fab outline
    if let Some(b) = &input.body {
        if round {
            let (cx, cy, r) = circle_of(b, 0.0);
            s += &format!("  (fp_circle (center {} {}) (end {} {})\n", n(cx), n(cy), n(cx + r), n(cy));
            s += "    (stroke (width 0.1) (type default)) (fill none) (layer \"F.Fab\"))\n";
        } else {
            s += &format!("  (fp_rect (start {} {}) (end {} {})\n", n(b.x1), n(b.y1), n(b.x2), n(b.y2));
            s += "    (stroke (width 0.1) (type default)) (fill none) (layer \"F.Fab\"))\n";
        }
    }

    // Silkscreen outline (slightly outside fab)
    if let Some(b) = &input.body {
        if round {
            let (cx, cy, r) = circle_of(b, 0.12);
            s += &format!("  (fp_circle (center {} {}) (end {} {})\n", n(cx), n(cy), n(cx + r), n(cy));
            s += "    (stroke (width 0.12) (type default)) (fill none) (layer \"F.SilkS\"))\n";
        } else {
            let m = 0.12;
            s += &format!("  (fp_rect (start {} {}) (end {} {})\n", n(b.x1 - m), n(b.y1 - m), n(b.x2 + m), n(b.y2 + m));
            s += "    (stroke (width 0.12) (type default)) (fill none) (layer \"F.SilkS\"))\n";
        }
    }

    // Pin 1 marker — a corner chevron only makes sense for a rectangular body.
    if !input.pads.is_empty() && !round {
        if let Some(b) = &input.body {
            s += &format!("  (fp_line (start {} {}) (end {} {})\n", n(b.x1 - 0.3), n(b.y1 - 0.3), n(b.x1 + 0.3), n(b.y1 - 0.3));
            s += "    (stroke (width 0.12) (type default)) (layer \"F.SilkS\"))\n";
        }
    }

    // Reference and Value — placed CLEAR of the WHOLE footprint (pads ∪ courtyard
    // ∪ body) via text_placement(), the single source of truth shared with the
    // exported `textPlacement` metadata. adom-lbr carries this placement through to
    // the Fusion .lbr (and KiCad/Altium/OrCAD) verbatim — it no longer invents its
    // own — so RefDes/Value that sit on pad copper here show up smeared across the
    // part in every tool. RefDes goes ~1 mm above the top extent, Value ~1 mm below
    // the bottom (KiCad Y is down, so above = -Y); both stay user-visible/editable.
    // (adom-lbr's `name-value-overlap` lint flags it if text ever lands on copper.)
    let tp = text_placement(input);
    let emit_text = |s: &mut String, kind: &str, label: &str, t: &TextItem| {
        let th = round2(t.size * 0.15);
        *s += &format!(
            "  (fp_text {} \"{}\" (at {} {}{})\n",
            kind, label, n(t.x), n(t.y), rot(t.rotation)
        );
        *s += &format!(
            "    (layer \"{}\") (effects (font (size {} {}) (thickness {}))))\n",
            t.layer, n(t.size), n(t.size), n(th)
        );
    };
    emit_text(&mut s, "reference", "REF**", &tp.reference);
    emit_text(&mut s, "value", &input.name, &tp.value);

    // Pads
    for pad in &input.pads {
        let mount = pad.mount.clone().unwrap_or_else(|| "smd".to_string());
        let is_th = mount == "thru_hole" || mount == "np_thru_hole";
        let shape = pad.shape.clone().unwrap_or_else(|| "roundrect".to_string());
        // THT pads plate through every copper layer + mask (no paste); SMD pads
        // are front copper + paste + mask.
        let layers = pad.layers.clone().unwrap_or_else(|| {
            if is_th { "\"*.Cu\" \"*.Mask\"".to_string() } else { "\"F.Cu\" \"F.Paste\" \"F.Mask\"".to_string() }
        });
        let angle = match pad.angle {
            Some(a) if a != 0.0 => format!(" {}", n(a)),
            _ => String::new(),
        };
        // Emit the drill right after size, e.g. (drill 1.1) — required for a THT
        // pad to be real; without it adom-lbr can only make an EAGLE <smd>.
        let drill = match pad.drill {
            Some(d) if d > 0.0 => format!(" (drill {})", n(d)),
            _ => String::new(),
        };
        s += &format!("  (pad \"{}\" {} {} (at {} {}{}) (size {} {}){}\n", pad.number, mount, shape, n(pad.x), n(pad.y), angle, n(pad.dx), n(pad.dy), drill);
        s += &format!("    (layers {})", layers);
        if shape == "roundrect" {
            s += " (roundrect_rratio 0.25)";
        }
        s += ")\n";
    }

    s += ")\n";
    s
}

/// In-plane bounding box of the footprint (courtyard ∪ body ∪ pad extents),
/// in footprint-local mm — used to crop the rendered SVG.
pub fn bbox(input: &FpInput) -> (f64, f64, f64, f64) {
    let mut xs = Vec::new();
    let mut ys = Vec::new();
    for r in [input.courtyard.as_ref(), input.body.as_ref()].into_iter().flatten() {
        xs.push(r.x1);
        xs.push(r.x2);
        ys.push(r.y1);
        ys.push(r.y2);
    }
    for p in &input.pads {
        xs.push(p.x - p.dx / 2.0);
        xs.push(p.x + p.dx / 2.0);
        ys.push(p.y - p.dy / 2.0);
        ys.push(p.y + p.dy / 2.0);
    }
    if xs.is_empty() {
        return (-2.0, -2.0, 2.0, 2.0);
    }
    (
        xs.iter().cloned().fold(f64::INFINITY, f64::min),
        ys.iter().cloned().fold(f64::INFINITY, f64::min),
        xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
        ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn r0402_matches_golden() {
        let input = FpInput {
            name: "R_0402_1005Metric".into(),
            pads: vec![
                Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },
                Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },
            ],
            body: Some(Rect { x1: -0.5, y1: -0.27, x2: 0.5, y2: 0.27 }),
            courtyard: Some(Rect { x1: -0.93, y1: -0.47, x2: 0.93, y2: 0.47 }),
            description: "0402 (1005 metric) resistor".into(),
            tags: "resistor 0402".into(),
            body_shape: None,
        };
        let got = generate_kicad_mod(&input);
        let golden = include_str!("../tests/golden/R_0402.kicad_mod");
        assert_eq!(got, golden, "must be byte-identical to the Node generator");
    }

    #[test]
    fn text_clears_pads_outside_courtyard() {
        // LQFP-style: pads reach well past a too-tight courtyard. RefDes/Value
        // must follow the PAD extent (+1 mm), not the courtyard — otherwise they
        // smear across the pad copper in Fusion/KiCad/Altium/OrCAD once adom-lbr
        // carries the placement through verbatim.
        let input = FpInput {
            name: "LQFP_TEST".into(),
            pads: vec![
                // bottom-row pad: outer edge at y = 5.9 + 1.5/2 = 6.65
                Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },
                // top-row pad: outer edge at y = -5.9 - 1.5/2 = -6.65
                Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },
            ],
            // Deliberately too-tight: courtyard/body only ±5.0 while pads reach ±6.65.
            body: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),
            courtyard: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),
            description: String::new(),
            tags: String::new(),
            body_shape: None,
        };
        let got = generate_kicad_mod(&input);
        // pad outer extent ±6.65, +1 mm margin -> ±7.65
        assert!(got.contains("(fp_text reference \"REF**\" (at 0 -7.65)"), "ref must clear pads, got:\n{got}");
        assert!(got.contains("(fp_text value \"LQFP_TEST\" (at 0 7.65)"), "value must clear pads, got:\n{got}");
        // And it must NOT fall back to the old courtyard-only placement (±5.8).
        assert!(!got.contains("(at 0 5.8)") && !got.contains("(at 0 -5.8)"), "must not use courtyard-only placement");
    }

    #[test]
    fn text_placement_holds_invariant() {
        // reference.y <= courtyard.top AND value.y >= courtyard.bottom (Y-down),
        // for both a large part and a tiny one, and the .kicad_mod fp_text must
        // agree with the metadata (single source of truth).
        let big = FpInput {
            name: "LQFP".into(),
            pads: vec![
                Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None, mount: None, drill: None },
                Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None, mount: None, drill: None },
            ],
            body: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),
            courtyard: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),
            description: String::new(),
            tags: String::new(),
            body_shape: None,
        };
        let tp = text_placement(&big);
        assert_eq!((tp.reference.x, tp.value.x), (0.0, 0.0), "centered on X");
        assert!(tp.reference.y <= tp.courtyard.top, "ref clears top keep-out");
        assert!(tp.value.y >= tp.courtyard.bottom, "value clears bottom keep-out");
        assert_eq!(tp.reference.rotation, 0.0, "horizontal (rotation dropped in .lbr)");
        assert_eq!(text_size(&big), 1.0, "normal part keeps 1 mm");
        // .kicad_mod agrees with metadata
        let km = generate_kicad_mod(&big);
        assert!(km.contains(&format!("(at 0 {})", n(tp.reference.y))), "kicad_mod ref y matches metadata");
        assert!(km.contains(&format!("(at 0 {})", n(tp.value.y))), "kicad_mod value y matches metadata");

        // tiny part scales text down (but invariant still holds)
        let tiny = FpInput {
            name: "R_0402".into(),
            pads: vec![
                Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None, mount: None, drill: None },
                Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None, mount: None, drill: None },
            ],
            body: Some(Rect { x1: -0.5, y1: -0.27, x2: 0.5, y2: 0.27 }),
            courtyard: Some(Rect { x1: -0.93, y1: -0.47, x2: 0.93, y2: 0.47 }),
            description: String::new(),
            tags: String::new(),
            body_shape: None,
        };
        let tt = text_placement(&tiny);
        assert!(tt.reference.size < 1.0, "tiny part scales text below 1 mm, got {}", tt.reference.size);
        assert!(tt.reference.y <= tt.courtyard.top && tt.value.y >= tt.courtyard.bottom, "invariant holds for tiny part");
    }

    #[test]
    fn thru_hole_pad_emits_drill_and_all_copper() {
        // The machine-pin case from issue #102: a round Ø1.6 pad with a 1.1 mm
        // drill must come out as a real thru_hole pad with (drill) + *.Cu *.Mask.
        let input = FpInput {
            name: "MachinePin".into(),
            pads: vec![Pad {
                number: "1".into(), x: 0.0, y: 0.0, dx: 1.6, dy: 1.6,
                shape: Some("circle".into()), angle: None, layers: None,
                mount: Some("thru_hole".into()), drill: Some(1.1),
            }],
            body: Some(Rect { x1: -1.0, y1: -1.0, x2: 1.0, y2: 1.0 }),
            courtyard: Some(Rect { x1: -1.2, y1: -1.2, x2: 1.2, y2: 1.2 }),
            description: String::new(), tags: String::new(),
            body_shape: Some("circle".into()),
        };
        let km = generate_kicad_mod(&input);
        assert!(km.contains("(pad \"1\" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.1)"),
            "thru-hole pad must carry mount + drill, got:\n{km}");
        assert!(km.contains("(layers \"*.Cu\" \"*.Mask\")"), "THT pad plates all copper, got:\n{km}");
        // and NOT an smd pad
        assert!(!km.contains("(pad \"1\" smd"), "must not fall back to smd, got:\n{km}");
    }

    #[test]
    fn round_body_emits_fp_circle_silk_not_rect() {
        // Round parts get real round silk (fp_circle) on F.SilkS + F.Fab, not a box.
        let input = FpInput {
            name: "RoundPart".into(),
            pads: vec![Pad {
                number: "1".into(), x: 0.0, y: 0.0, dx: 1.6, dy: 1.6,
                shape: Some("circle".into()), angle: None, layers: None,
                mount: Some("thru_hole".into()), drill: Some(1.1),
            }],
            body: Some(Rect { x1: -1.0, y1: -1.0, x2: 1.0, y2: 1.0 }),
            courtyard: None,
            description: String::new(), tags: String::new(),
            body_shape: Some("circle".into()),
        };
        let km = generate_kicad_mod(&input);
        assert!(km.contains("(fp_circle (center 0 0)") && km.contains("(layer \"F.SilkS\"))"),
            "round part must emit fp_circle silk, got:\n{km}");
        assert!(km.contains("(layer \"F.Fab\"))"), "round part must emit fp_circle fab, got:\n{km}");
        // silk radius (1.0 + 0.12) sits just outside the fab radius (1.0)
        assert!(km.contains("(center 0 0) (end 1.12 0)"), "silk circle grows 0.12 beyond body, got:\n{km}");
        // no rectangular silk/fab box for a round part
        assert!(!km.contains("fp_rect") || input.courtyard.is_some(), "no fab/silk rect for round part, got:\n{km}");
    }
}