#!/usr/bin/env python3
"""
LED Nameplate Generator v2

Takes a name string and generates a KiCad project by:
  1. Loading the blank-letter-template (pre-routed 5x9 WS2812B tile)
  2. For each letter, removing LEDs/caps/vias/segments at empty bitmap positions
  3. Adding bridge traces where the chain has gaps from removed LEDs
  4. Tiling letters side-by-side with proper coordinate offsets
  5. Each letter is an independent LED string (separate data net)

Usage:
    python3 generate_nameplate.py ABCDE
"""

import heapq
import json
import math
import os
import re
import subprocess
import sys
import uuid
import zipfile
from collections import defaultdict

DESKTOP_DEST_BASE = "C:\\Users\\noah\\KiCAD Workspace\\nameplates"

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.dirname(SCRIPT_DIR) if SCRIPT_DIR.endswith("led-nameplate") else SCRIPT_DIR


def _find_asset(rel_parts, alt_name=None):
    """Resolve a data file by checking, in order: PROJECT_DIR, SCRIPT_DIR, and
    SCRIPT_DIR/assets. Lets the tool run both from the dev tree (assets in the
    parent) and as a self-contained installed app (assets bundled alongside)."""
    candidates = [os.path.join(PROJECT_DIR, *rel_parts),
                  os.path.join(SCRIPT_DIR, *rel_parts),
                  os.path.join(SCRIPT_DIR, 'assets', rel_parts[-1])]
    if alt_name:
        candidates += [os.path.join(SCRIPT_DIR, alt_name),
                       os.path.join(SCRIPT_DIR, 'assets', alt_name)]
    for p in candidates:
        if os.path.exists(p):
            return p
    return candidates[0]  # fall back to the dev-tree path for a clear error


FONT_PATH = _find_asset(["dot-font", "font-data.json"])
TEMPLATE_ZIP = _find_asset(["blank-letter-template.zip"])

# Grid / routing constants live in the shared module so the PCB generator and
# the routing visualiser always agree on geometry + algorithm.
from dijkstra_alg import (
    GRID_COLS, GRID_ROWS, LED_PITCH, GRID_ORIGIN_X, GRID_ORIGIN_Y,
    LETTER_WIDTH, LETTER_HEIGHT,
    ROUTING_UNIT, SQRT2, DIN_OFFSET, DOUT_OFFSET,
    grid_pos, get_led_rotation, chain_order,
    corner_for as _corner_for,
    grid_bounds as _grid_bounds,
    dijkstra as _dijkstra,
    consolidate as _consolidate,
    initial_blocked_set,
    route_chain,
    gnd_keepout_nodes,
)

LETTER_GAP = 8.0
# Board margins around the letter array
MARGIN = 3.0


def uid():
    return str(uuid.uuid4())


def load_font():
    with open(FONT_PATH) as f:
        return json.load(f)


def ref_num(row, col):
    """D-ref number for grid position (201-245)."""
    return 201 + row * GRID_COLS + col


# ---------------------------------------------------------------------------
# Template Parser
# ---------------------------------------------------------------------------

def parse_top_level_blocks(pcb_text):
    """
    Parse the PCB text into a header (nets, setup, etc.) and a list of
    top-level blocks (footprints, segments, vias, zones, gr_* elements).
    Returns (header_text, blocks) where each block is (start, end, type, text).
    """
    blocks = []
    # Find all top-level elements (indented with single tab)
    idx = 0
    header_end = None

    while idx < len(pcb_text):
        # Look for top-level s-expressions starting with \t(
        nl = pcb_text.find('\n\t(', idx)
        if nl == -1:
            break
        start = nl + 1  # skip the \n

        # Determine the type
        type_m = re.match(r'\t\((\w+)', pcb_text[start:])
        if not type_m:
            idx = start + 1
            continue
        block_type = type_m.group(1)

        # Skip net definitions, layers, setup etc. — they're part of the header
        if block_type in ('version', 'generator', 'generator_version', 'general',
                          'paper', 'layers', 'setup', 'net'):
            if header_end is None or start > header_end:
                # Find end of this block
                depth = 0
                for i in range(start, len(pcb_text)):
                    if pcb_text[i] == '(':
                        depth += 1
                    elif pcb_text[i] == ')':
                        depth -= 1
                        if depth == 0:
                            header_end = i + 1
                            break
            idx = header_end if header_end else start + 1
            continue

        # Find end of block
        depth = 0
        end = start
        for i in range(start, len(pcb_text)):
            if pcb_text[i] == '(':
                depth += 1
            elif pcb_text[i] == ')':
                depth -= 1
                if depth == 0:
                    end = i + 1
                    break

        block_text = pcb_text[start:end]
        blocks.append((start, end, block_type, block_text))
        idx = end

    return blocks


def classify_blocks(blocks):
    """Classify blocks into footprints, segments, vias, zones, and other."""
    footprints = []  # (text, ref, fp_type, x, y, rot)
    segments = []    # (text, net_id, sx, sy, ex, ey)
    vias = []        # (text, net_id, x, y)
    zones = []       # (text, net_name)
    other = []       # (text,)

    for start, end, btype, text in blocks:
        if btype == 'footprint':
            ref_m = re.search(r'property "Reference" "([^"]+)"', text)
            at_m = re.search(r'^\t\t\(at ([\d.]+) ([\d.]+)(?: ([\d.]+))?\)', text, re.MULTILINE)
            ref = ref_m.group(1) if ref_m else '?'
            x = float(at_m.group(1)) if at_m else 0
            y = float(at_m.group(2)) if at_m else 0
            rot = float(at_m.group(3)) if at_m and at_m.group(3) else 0
            fp_m = re.search(r'\(footprint "([^"]+)"', text)
            fp_type = fp_m.group(1) if fp_m else '?'
            footprints.append((text, ref, fp_type, x, y, rot))
        elif btype == 'segment':
            net_m = re.search(r'\(net (\d+)\)', text)
            start_m = re.search(r'\(start ([\d.]+) ([\d.]+)\)', text)
            end_m = re.search(r'\(end ([\d.]+) ([\d.]+)\)', text)
            net_id = int(net_m.group(1)) if net_m else 0
            sx = float(start_m.group(1)) if start_m else 0
            sy = float(start_m.group(2)) if start_m else 0
            ex = float(end_m.group(1)) if end_m else 0
            ey = float(end_m.group(2)) if end_m else 0
            segments.append((text, net_id, sx, sy, ex, ey))
        elif btype == 'via':
            net_m = re.search(r'\(net (\d+)\)', text)
            at_m = re.search(r'\(at ([\d.]+) ([\d.]+)\)', text)
            net_id = int(net_m.group(1)) if net_m else 0
            x = float(at_m.group(1)) if at_m else 0
            y = float(at_m.group(2)) if at_m else 0
            vias.append((text, net_id, x, y))
        elif btype == 'zone':
            net_m = re.search(r'\(net_name "([^"]+)"\)', text)
            net_name = net_m.group(1) if net_m else '?'
            zones.append((text, net_name))
        else:
            other.append((text,))

    return footprints, segments, vias, zones, other


# ---------------------------------------------------------------------------
# Net name mapping
# ---------------------------------------------------------------------------

def parse_template_nets(pcb_text):
    """Parse net definitions from template. Returns {net_id: net_name}."""
    return dict((int(k), v) for k, v in re.findall(r'\t\(net (\d+) "([^"]+)"\)\n', pcb_text))


class NetAllocator:
    """Allocates unique net IDs across the whole assembled PCB."""
    def __init__(self, start_id=3):
        self.next_id = start_id
        self.nets = {0: '', 1: '+5V', 2: 'GND'}

    def alloc(self, name):
        nid = self.next_id
        self.next_id += 1
        self.nets[nid] = name
        return nid


def build_template_pad_net_map(footprints):
    """For each template LED (row, col), return (din_net_id, dout_net_id)
    by parsing pad nets out of the footprint text."""
    result = {}
    for text, ref, fp_type, x, y, rot in footprints:
        if not (ref.startswith('D') and ref[1:].isdigit()):
            continue
        num = int(ref[1:])
        if not (201 <= num <= 245):
            continue
        idx = num - 201
        row, col = idx // GRID_COLS, idx % GRID_COLS
        pads = {}
        for m in re.finditer(
            r'\(pad "(\d+)" smd[^()]*(?:\([^)]*\)[^()]*)*?\(net (\d+) "[^"]*"\)',
            text, re.DOTALL
        ):
            pads[m.group(1)] = int(m.group(2))
        # WS2812B pad 3 = DIN, pad 1 = DOUT
        result[(row, col)] = (pads.get('3', 0), pads.get('1', 0))
    return result


# ---------------------------------------------------------------------------
# Footprint offsetting
# ---------------------------------------------------------------------------

def offset_footprint(fp_text, dx, dy, new_ref_prefix, pad_nets, allocator, old_ref_prefix='2'):
    """
    Offset a footprint's global position, rename its reference, and
    re-assign pad nets BY PAD NUMBER.

    `pad_nets` is either None (leave pad nets unchanged — used for caps,
    whose template nets are already the global +5V/GND) or a dict
    {pad_number_str: net_id}. Assigning by pad number (not by template
    net id) is essential: the template's net ids encode the NORMAL
    serpentine wiring, so a given template net id is shared by multiple
    cells. Keying a remap on it clobbers under inverted chain order.
    Pad-number assignment is unambiguous per footprint.

    Only changes the TOP-LEVEL (at X Y) — not pad-local coordinates.
    """
    # Change the top-level position: first (at after (uuid
    lines = fp_text.split('\n')
    new_lines = []
    at_done = False
    for line in lines:
        if not at_done and re.match(r'\t\t\(at [\d.]+ [\d.]+', line):
            m = re.match(r'(\t\t\(at )([\d.]+) ([\d.]+)(.*)', line)
            if m:
                x = float(m.group(2)) + dx
                y = float(m.group(3)) + dy
                line = f'{m.group(1)}{x:.3f} {y:.3f}{m.group(4)}'
                at_done = True
        new_lines.append(line)
    text = '\n'.join(new_lines)

    # Rename references: D2XX -> D{prefix}XX, C2XX -> C{prefix}XX
    text = re.sub(
        rf'"([DC]){old_ref_prefix}(\d{{2}})"',
        lambda m: f'"{m.group(1)}{new_ref_prefix}{m.group(2)}"',
        text
    )

    # Re-assign pad nets by pad number (see docstring). Each pad block is
    # `(pad "N" ... (net OLD "NAME") (uuid ...))`; the lazy span from the pad
    # number to its first `(net ` reaches exactly that pad's net token.
    if pad_nets is not None:
        pad_net_pat = re.compile(r'(\(pad "(\d+)"[\s\S]*?\(net )(\d+)( ")([^"]*)("\))')

        def assign_pad_net(m):
            padnum = m.group(2)
            if padnum in pad_nets:
                nid = pad_nets[padnum]
                name = allocator.nets.get(nid, m.group(5))
                return f'{m.group(1)}{nid}{m.group(4)}{name}{m.group(6)}'
            return m.group(0)
        text = pad_net_pat.sub(assign_pad_net, text)

    # Regenerate UUIDs
    text = re.sub(
        r'"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"',
        lambda m: f'"{uid()}"',
        text
    )
    return text


def offset_segment(seg_text, dx, dy, remap=None):
    """Offset a segment's start/end coordinates and remap its net ID."""
    def shift_coord(match):
        tag = match.group(1)
        x = float(match.group(2)) + dx
        y = float(match.group(3)) + dy
        return f'({tag} {x:.3f} {y:.3f})'

    text = re.sub(r'\((start|end) ([\d.]+) ([\d.]+)\)', shift_coord, seg_text)
    if remap is not None:
        def remap_net(m):
            old_id = int(m.group(1))
            return f'(net {remap.get(old_id, old_id)})'
        text = re.sub(r'\(net (\d+)\)', remap_net, text)
    text = re.sub(
        r'"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"',
        lambda m: f'"{uid()}"',
        text
    )
    return text


def offset_via(via_text, dx, dy, remap=None):
    """Offset a via's position and remap its net ID."""
    def shift_at(match):
        x = float(match.group(1)) + dx
        y = float(match.group(2)) + dy
        return f'(at {x:.3f} {y:.3f})'

    text = re.sub(r'\(at ([\d.]+) ([\d.]+)\)', shift_at, via_text)
    if remap is not None:
        def remap_net(m):
            old_id = int(m.group(1))
            return f'(net {remap.get(old_id, old_id)})'
        text = re.sub(r'\(net (\d+)\)', remap_net, text)
    text = re.sub(
        r'"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"',
        lambda m: f'"{uid()}"',
        text
    )
    return text


def offset_zone(zone_text, dx, dy, remap=None, priority=None):
    """Offset a copper zone (e.g. the user's +5V fork fill and GND fill) by
    (dx, dy), strip the cached (filled_polygon ...) blocks so KiCad refills on
    load, remap the net id, set a (priority N), and regenerate the uuid.

    Stripping the fill cache leaves only the DEFINING polygon's (xy ...)
    coordinates, so a blanket xy-offset positions the zone correctly.

    `priority` is set per-letter-per-net so that tiled zones (which overlap
    where letter pitch < zone width) always have DISTINCT priorities — KiCad
    requires intersecting zones to differ in priority.
    """
    # Drop cached filled_polygon blocks (balanced-paren removal).
    out = []
    i = 0
    while True:
        j = zone_text.find('(filled_polygon', i)
        if j == -1:
            out.append(zone_text[i:])
            break
        out.append(zone_text[i:j])
        depth = 0
        k = j
        while k < len(zone_text):
            if zone_text[k] == '(':
                depth += 1
            elif zone_text[k] == ')':
                depth -= 1
                if depth == 0:
                    k += 1
                    break
            k += 1
        i = k
    text = ''.join(out)

    # Offset every (xy x y) in the defining polygon.
    def shift_xy(m):
        return f'(xy {float(m.group(1)) + dx:.4f} {float(m.group(2)) + dy:.4f})'
    text = re.sub(r'\(xy ([\d.-]+) ([\d.-]+)\)', shift_xy, text)

    # Remap net id (first form: (net N) — zones use (net N) then (net_name "..")).
    if remap is not None:
        text = re.sub(r'\(net (\d+)\)',
                      lambda m: f'(net {remap.get(int(m.group(1)), int(m.group(1)))})',
                      text, count=1)

    # Set / replace priority so tiled (overlapping) zones differ.
    if priority is not None:
        if '(priority ' in text:
            text = re.sub(r'\(priority \d+\)', f'(priority {priority})', text, count=1)
        else:
            # insert before (connect_pads ...), matching zone-field indentation
            text = text.replace('\t\t(connect_pads',
                                f'\t\t(priority {priority})\n\t\t(connect_pads', 1)

    text = re.sub(
        r'"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"',
        lambda m: f'"{uid()}"',
        text
    )
    return text


# ---------------------------------------------------------------------------
# Determine which segments/vias to keep or remove
# ---------------------------------------------------------------------------

def near_any(x, y, centers, radius=2.0):
    """Check if (x, y) is within radius of any center.
    Uses 2.0mm (half the LED pitch) so each point is unambiguously associated
    with at most one LED — prevents segments from being misclassified."""
    for cx, cy in centers:
        if abs(x - cx) < radius and abs(y - cy) < radius:
            return True
    return False


def segment_touches_removed(sx, sy, ex, ey, removed_centers, surviving_centers):
    """
    Determine if a segment should be removed.
    Remove if either endpoint is near a removed LED and NOT near a surviving LED.
    """
    s_near_removed = near_any(sx, sy, removed_centers)
    e_near_removed = near_any(ex, ey, removed_centers)
    s_near_surviving = near_any(sx, sy, surviving_centers)
    e_near_surviving = near_any(ex, ey, surviving_centers)

    # Remove if any endpoint is near removed and that endpoint is not near surviving
    if (s_near_removed and not s_near_surviving) or (e_near_removed and not e_near_surviving):
        return True
    return False


def kept_gnd_geometry(template_pcb, surviving):
    """Return (gnd_via_centers, gnd_segments) in TEMPLATE-LOCAL mm for the
    net-2 (GND) template copper that survives the removed-LED filter.

    The router uses this to build keepout so chain bridges don't short to the
    user's GND vias/traces. Shared by the PCB generator and the visualiser so
    both route around the same GND copper.
    """
    # Cache the (net-2 vias, net-2 segments) extraction — the template is
    # constant, so this avoids re-parsing the 600 KB PCB for every letter /
    # every visualiser variant.
    cache = kept_gnd_geometry.__dict__.setdefault('_cache', {})
    key = len(template_pcb)
    if key not in cache:
        blocks = parse_top_level_blocks(template_pcb)
        _, segs, vias, _, _ = classify_blocks(blocks)
        cache[key] = ([(x, y) for _t, n, x, y in vias if n == 2],
                      [(sx, sy, ex, ey) for _t, n, sx, sy, ex, ey in segs if n == 2])
    all_gnd_vias, all_gnd_segs = cache[key]

    removed_centers, surviving_centers = [], []
    for r in range(GRID_ROWS):
        for c in range(GRID_COLS):
            (surviving_centers if (r, c) in surviving else removed_centers
             ).append(grid_pos(r, c))
    via_centers = []
    for (x, y) in all_gnd_vias:
        if near_any(x, y, removed_centers) and not near_any(x, y, surviving_centers):
            continue
        via_centers.append((x, y))
    seg_list = []
    for (sx, sy, ex, ey) in all_gnd_segs:
        if segment_touches_removed(sx, sy, ex, ey, removed_centers, surviving_centers):
            continue
        seg_list.append((sx, sy, ex, ey))
    return via_centers, seg_list


# ---------------------------------------------------------------------------
# Bridge trace generation for chain gaps
# ---------------------------------------------------------------------------

def pad_global_pos(row, col, pad_name):
    """Get global (x,y) of a WS2812B pad. pad_name: 'DIN', 'DOUT', 'GND', or 'VDD'."""
    cx, cy = grid_pos(row, col)
    local = {
        'DOUT': (-0.92, -0.55),
        'GND':  (-0.92,  0.55),
        'DIN':  ( 0.92,  0.55),
        'VDD':  ( 0.92, -0.55),
    }
    lx, ly = local[pad_name]
    rot = get_led_rotation(row)
    if rot == 180:
        return (cx - lx, cy - ly)
    else:
        return (cx + lx, cy + ly)


def make_via_text(x, y, net_id, size=0.6, drill=0.3):
    """Generate a KiCad through-via s-expression."""
    return (
        f'\t(via\n'
        f'\t\t(at {x:.3f} {y:.3f})\n'
        f'\t\t(size {size})\n'
        f'\t\t(drill {drill})\n'
        f'\t\t(layers "F.Cu" "B.Cu")\n'
        f'\t\t(net {net_id})\n'
        f'\t\t(uuid "{uid()}")\n'
        f'\t)'
    )


def make_segment_text(x1, y1, x2, y2, net_id, width=0.2, layer="F.Cu"):
    """Generate a KiCad segment s-expression."""
    return (
        f'\t(segment\n'
        f'\t\t(start {x1:.3f} {y1:.3f})\n'
        f'\t\t(end {x2:.3f} {y2:.3f})\n'
        f'\t\t(width {width})\n'
        f'\t\t(layer "{layer}")\n'
        f'\t\t(net {net_id})\n'
        f'\t\t(uuid "{uid()}")\n'
        f'\t)'
    )


# ---------------------------------------------------------------------------
# Dijkstra-based bridge router (adapted from routing-grid skill)
#
# Builds a dense 8-direction graph at 1mm UNIT spacing over the letter tile.
# Each surviving LED blocks its 3x3 block of nodes EXCEPT its DIN/DOUT
# corners (those stay open as routing endpoints). Routed paths block their
# intermediate nodes AND the two anti-diagonal corners of any diagonal edge
# (prevents visual X-crossings with later routes).
# ---------------------------------------------------------------------------

def generate_bridge_traces(surviving, inverted, link_nets, extra_blocked=None):
    """
    Generate bridge segments for EVERY chain link.

    Delegates routing to the shared `route_chain` (same code the visualiser
    runs) so the PCB and the visualiser are guaranteed identical. The
    template no longer ships any inter-LED chain traces, so every
    consecutive pair in the surviving chain must be bridged here.

    Args:
        surviving:  set/iterable of (row, col) surviving LED cells.
        inverted:   chain-order flag (False = normal top-left start,
                    True = bottom-left start snaking up).
        link_nets:  per-link net IDs; link_nets[i] is the net for the
                    link from chain position i to i+1 (same indexing as
                    route_chain's result['i']).
        extra_blocked: routing-grid nodes to avoid (GND-copper keepout).

    Returns:
        list of (x1, y1, x2, y2, net_id) segment tuples in template-local
        coords (caller applies the per-letter (dx, dy) offset).
    """
    results, _ = route_chain(surviving, inverted=inverted, extra_blocked=extra_blocked)

    bridges = []
    for res in results:
        i = res['i']
        net_id = link_nets[i]
        pad_src = pad_global_pos(res['r1'], res['c1'], 'DOUT')
        pad_dst = pad_global_pos(res['r2'], res['c2'], 'DIN')

        if res['path'] is None:
            # Last-resort direct stub (shouldn't happen — all 36 glyphs
            # route cleanly in both orientations). Keeps the build alive.
            bridges.append((pad_src[0], pad_src[1],
                            pad_dst[0], pad_dst[1], net_id))
            continue

        src, dst = res['src'], res['dst']
        # Pad → snap-corner stub, the routed waypoints, then snap-corner → pad.
        bridges.append((pad_src[0], pad_src[1], src[0], src[1], net_id))
        waypoints = _consolidate(res['path'])
        for j in range(len(waypoints) - 1):
            x1, y1 = waypoints[j]
            x2, y2 = waypoints[j + 1]
            bridges.append((x1, y1, x2, y2, net_id))
        bridges.append((dst[0], dst[1], pad_dst[0], pad_dst[1], net_id))

    return bridges


# ---------------------------------------------------------------------------
# Main assembly
# ---------------------------------------------------------------------------

def process_letter(template_pcb, font_bitmap, letter_idx, inverted, allocator):
    """
    Process one letter: filter template elements, offset, rename, and
    remap every net reference to the per-letter output net IDs allocated
    by `allocator`.

    `inverted` controls the LED chain visitation order, NOT the visual
    orientation of the letter. The bitmap is always read top-up so every
    glyph renders right-side up; when inverted=True the chain starts at
    (row 8, col 4) and snakes UP to (row 0, col 0), so data enters from
    the bottom-right instead of the top-left.

    Returns list of text blocks to include in the output PCB.
    """
    bitmap = font_bitmap  # Always upright — no vertical mirror

    surviving = set()
    removed = set()
    for row in range(GRID_ROWS):
        for col in range(GRID_COLS):
            if bitmap[row][col]:
                surviving.add((row, col))
            else:
                removed.add((row, col))

    surviving_centers = [grid_pos(r, c) for r, c in surviving]
    removed_centers = [grid_pos(r, c) for r, c in removed]

    # Parse template
    blocks = parse_top_level_blocks(template_pcb)
    footprints, segments, vias, zones, other = classify_blocks(blocks)
    template_pad_map = build_template_pad_net_map(footprints)

    # Surviving chain order (normal or reversed depending on `inverted`)
    surv_chain = [(r, c) for r, c in chain_order(inverted=inverted)
                  if (r, c) in surviving]

    # --- Allocate per-letter output nets ---
    # data_in_net = signal into this letter (first LED's DIN)
    # link_nets[i] = signal between surv_chain[i] and surv_chain[i+1]
    # data_out_net = signal out of this letter (last LED's DOUT, unused but reserved)
    data_in_net = allocator.alloc(f"LED_DATA_{letter_idx}")
    link_nets = []
    for i in range(len(surv_chain) - 1):
        r, c = surv_chain[i]
        out_ref = (letter_idx + 1) * 100 + (ref_num(r, c) - 200)
        link_nets.append(allocator.alloc(f"Net-(D{out_ref}-DOUT)"))
    data_out_net = allocator.alloc(f"LED_DATA_{letter_idx}_OUT")

    # --- Build per-cell pad-net assignment ---
    # For each surviving LED at chain position i:
    #   pad 1 (DOUT) -> out_net (link to NEXT LED, or data_out for the last)
    #   pad 3 (DIN)  -> in_net  (link from PREV LED, or data_in for the first)
    #   pad 2 (VSS)  -> GND (net 2)
    #   pad 4 (VDD)  -> +5V (net 1)
    # Keyed by CELL, so there is no template-net-id collision under any
    # chain order (this is the fix for the inverted-letter ratsnest bug).
    cell_pad_nets = {}
    for i, pos in enumerate(surv_chain):
        in_net = data_in_net if i == 0 else link_nets[i - 1]
        out_net = data_out_net if i == len(surv_chain) - 1 else link_nets[i]
        cell_pad_nets[pos] = {'1': out_net, '2': 2, '3': in_net, '4': 1}

    # Power net pass-through for template segments/vias (only ever net 1/2/0).
    power_remap = {0: 0, 1: 1, 2: 2}

    # Calculate offset for this letter's tile position
    tile_width = LETTER_WIDTH + LETTER_GAP
    dx = letter_idx * tile_width + (MARGIN + 2 - GRID_ORIGIN_X)
    dy = MARGIN + 4 - GRID_ORIGIN_Y

    ref_prefix = str(letter_idx + 1)
    output_blocks = []

    # --- Footprints ---
    for text, ref, fp_type, x, y, rot in footprints:
        pad_nets = None  # caps keep their template +5V/GND nets
        if ref.startswith('D') and ref[1:].isdigit():
            num = int(ref[1:])
            if 201 <= num <= 245:
                r, c = (num - 201) // GRID_COLS, (num - 201) % GRID_COLS
                if (r, c) in removed:
                    continue
                pad_nets = cell_pad_nets.get((r, c))
        elif ref.startswith('C') and ref[1:].isdigit():
            num = int(ref[1:])
            if 201 <= num <= 245:
                r, c = (num - 201) // GRID_COLS, (num - 201) % GRID_COLS
                if (r, c) in removed:
                    continue
        output_blocks.append(
            offset_footprint(text, dx, dy, ref_prefix, pad_nets, allocator)
        )

    # --- Segments: keep ONLY power nets (+5V=1, GND=2) from the template.
    #     The template's GND traces + +5V distribution come through here; any
    #     stray chain-net segment (net > 2, e.g. a leftover DOUT stub) is
    #     dropped because the router regenerates ALL chain traces itself.
    for text, net_id, sx, sy, ex, ey in segments:
        if net_id not in (1, 2):
            continue
        if segment_touches_removed(sx, sy, ex, ey, removed_centers, surviving_centers):
            continue
        output_blocks.append(offset_segment(text, dx, dy, power_remap))

    # --- Vias: keep BOTH the template's +5V vias and its GND stitch vias.
    #     (User added GND vias to the template, so no more 2mm-grid stitching.)
    for text, net_id, x, y in vias:
        if near_any(x, y, removed_centers) and not near_any(x, y, surviving_centers):
            continue
        output_blocks.append(offset_via(text, dx, dy, power_remap))

    # --- Bridge traces for chain gaps (router-generated) ---
    #     Feed the template's surviving GND copper in as keepout so chain
    #     bridges never short to the user's GND vias/traces.
    gnd_vias, gnd_segs = kept_gnd_geometry(template_pcb, surviving)
    gnd_block = gnd_keepout_nodes(gnd_vias, gnd_segs)
    bridges = generate_bridge_traces(surviving, inverted, link_nets,
                                     extra_blocked=gnd_block)
    for sx, sy, ex, ey, net_id in bridges:
        output_blocks.append(
            make_segment_text(sx + dx, sy + dy, ex + dx, ey + dy, net_id)
        )

    # --- Zones: tile the template's GND fill + the user's +5V fork fill onto
    #     this letter (offset, cached fills stripped so KiCad refills). This
    #     carries the user's exact 5V fill pattern per letter.
    #     Distinct per-letter priorities so overlapping tiled zones are legal:
    #     GND uses letter_idx; +5V uses 100+letter_idx (so 5V still carves GND).
    for ztext, zname in zones:
        prio = (100 + letter_idx) if zname == '+5V' else letter_idx
        output_blocks.append(offset_zone(ztext, dx, dy, power_remap, priority=prio))

    return output_blocks


# ---------------------------------------------------------------------------
# GND via stitching — 2mm grid pass that skips pads, traces, existing vias
# ---------------------------------------------------------------------------

STITCH_GRID = 2.0     # mm — spacing of GND stitch grid
VIA_RADIUS = 0.3      # mm — half of via outer diameter (0.6mm)
TRACE_HALF_W = 0.1    # mm — half of trace width (0.2mm)
CLEARANCE = 0.2       # mm — DRC clearance budget

# LED keepout: 2x2mm body + 0.875x0.95 pads at corners (±0.92, ±0.55).
# Furthest pad copper: x = 0.92 + 0.4375 = 1.358, y = 0.55 + 0.475 = 1.025.
# Add via radius + clearance so via copper-to-pad copper ≥ CLEARANCE.
LED_KEEPOUT_HX = 1.358 + VIA_RADIUS + CLEARANCE  # ~1.86mm
LED_KEEPOUT_HY = 1.025 + VIA_RADIUS + CLEARANCE  # ~1.53mm

# 0402 cap: pads at ±0.51 X, size 0.54x0.64. Furthest copper at x=0.78, y=0.32.
CAP_KEEPOUT_HX = 0.78 + VIA_RADIUS + CLEARANCE   # ~1.28mm
CAP_KEEPOUT_HY = 0.32 + VIA_RADIUS + CLEARANCE   # ~0.82mm

# Trace exclusion: via center must be > VIA_RADIUS + TRACE_HALF_W + CLEARANCE
# away from any segment center-line.
TRACE_EXCLUSION = VIA_RADIUS + TRACE_HALF_W + CLEARANCE  # 0.6mm

# Via-to-via center distance: 2 * VIA_RADIUS + CLEARANCE.
VIA_VIA_MIN = 2 * VIA_RADIUS + CLEARANCE  # 0.8mm


def _point_segment_distance(px, py, x1, y1, x2, y2):
    """Perpendicular distance from (px, py) to segment [(x1,y1),(x2,y2)]."""
    dx, dy = x2 - x1, y2 - y1
    L2 = dx * dx + dy * dy
    if L2 < 1e-9:
        return math.hypot(px - x1, py - y1)
    t = ((px - x1) * dx + (py - y1) * dy) / L2
    t = max(0.0, min(1.0, t))
    cx, cy = x1 + t * dx, y1 + t * dy
    return math.hypot(px - cx, py - cy)


def stitch_gnd_vias(metas, board_w, board_h, margin=1.5):
    """Place GND vias on a 2mm grid across the board, skipping any point
    that's too close to an LED pad, cap pad, existing via, or any trace.

    Returns list of via s-expression text blocks (net 2 = GND).
    """
    # Aggregate all obstacles
    led_centers = []
    cap_centers = []
    segments = []
    existing_vias = []
    for m in metas:
        led_centers.extend(m['led_centers'])
        cap_centers.extend(m['cap_centers'])
        segments.extend(m['all_segments'])
        existing_vias.extend(m['existing_vias'])

    # Iterate the 2mm grid
    placed = []
    x = margin
    while x <= board_w - margin:
        y = margin
        while y <= board_h - margin:
            if _point_clear(x, y, led_centers, cap_centers, segments, existing_vias + placed):
                placed.append((x, y))
            y += STITCH_GRID
        x += STITCH_GRID

    return [make_via_text(x, y, net_id=2) for (x, y) in placed]


def _point_clear(x, y, led_centers, cap_centers, segments, vias):
    """Return True if a GND via can sit at (x, y) without clearance violations."""
    # 1. LED keepout rects
    for (cx, cy) in led_centers:
        if abs(x - cx) < LED_KEEPOUT_HX and abs(y - cy) < LED_KEEPOUT_HY:
            return False
    # 2. Cap keepout rects
    for (cx, cy) in cap_centers:
        if abs(x - cx) < CAP_KEEPOUT_HX and abs(y - cy) < CAP_KEEPOUT_HY:
            return False
    # 3. Trace exclusion
    for (sx, sy, ex, ey) in segments:
        if _point_segment_distance(x, y, sx, sy, ex, ey) < TRACE_EXCLUSION:
            return False
    # 4. Existing via clearance
    for (vx, vy) in vias:
        if math.hypot(x - vx, y - vy) < VIA_VIA_MIN:
            return False
    return True


# ---------------------------------------------------------------------------
# PCB header and zones
# ---------------------------------------------------------------------------

def build_pcb_header(name, allocator_nets):
    """Build PCB file header with layers, setup, nets.
    allocator_nets is {id: name} from NetAllocator (already includes 0/1/2)."""
    header = f'''(kicad_pcb
\t(version 20241229)
\t(generator "nameplate_generator")
\t(generator_version "2.0")
\t(general
\t\t(thickness 1.6)
\t\t(legacy_teardrops no)
\t)
\t(paper "A4")
\t(layers
\t\t(0 "F.Cu" signal)
\t\t(2 "B.Cu" signal)
\t\t(9 "F.Adhes" user "F.Adhesive")
\t\t(11 "B.Adhes" user "B.Adhesive")
\t\t(13 "F.Paste" user)
\t\t(15 "B.Paste" user)
\t\t(5 "F.SilkS" user "F.Silkscreen")
\t\t(7 "B.SilkS" user "B.Silkscreen")
\t\t(1 "F.Mask" user)
\t\t(3 "B.Mask" user)
\t\t(17 "Dwgs.User" user "User.Drawings")
\t\t(19 "Cmts.User" user "User.Comments")
\t\t(21 "Eco1.User" user "User.Eco1")
\t\t(23 "Eco2.User" user "User.Eco2")
\t\t(25 "Edge.Cuts" user)
\t\t(27 "Margin" user)
\t\t(31 "F.CrtYd" user "F.Courtyard")
\t\t(29 "B.CrtYd" user "B.Courtyard")
\t\t(35 "F.Fab" user)
\t\t(33 "B.Fab" user)
\t)
\t(setup
\t\t(pad_to_mask_clearance 0)
\t\t(allow_soldermask_bridges_in_footprints no)
\t\t(tenting front back)
\t\t(pcbplotparams
\t\t\t(layerselection 0x00000000_00000000_55555555_5755f5ff)
\t\t\t(plot_on_all_layers_selection 0x00000000_00000000_00000000_00000000)
\t\t\t(disableapertmacros no)
\t\t\t(usegerberextensions no)
\t\t\t(usegerberattributes yes)
\t\t\t(usegerberadvancedattributes yes)
\t\t\t(creategerberjobfile yes)
\t\t\t(dashed_line_dash_ratio 12.000000)
\t\t\t(dashed_line_gap_ratio 3.000000)
\t\t\t(svgprecision 4)
\t\t\t(plotframeref no)
\t\t\t(mode 1)
\t\t\t(useauxorigin no)
\t\t\t(hpglpennumber 1)
\t\t\t(hpglpenspeed 20)
\t\t\t(hpglpendiameter 15.000000)
\t\t\t(pdf_front_fp_property_popups yes)
\t\t\t(pdf_back_fp_property_popups yes)
\t\t\t(pdf_metadata yes)
\t\t\t(pdf_single_document no)
\t\t\t(dxfpolygonmode yes)
\t\t\t(dxfimperialunits yes)
\t\t\t(dxfusepcbnewfont yes)
\t\t\t(psnegative no)
\t\t\t(psa4output no)
\t\t\t(plot_black_and_white yes)
\t\t\t(sketchpadsonfab no)
\t\t\t(plotpadnumbers no)
\t\t\t(hidednponfab no)
\t\t\t(sketchdnponfab yes)
\t\t\t(crossoutdnponfab yes)
\t\t\t(subtractmaskfromsilk no)
\t\t\t(outputformat 1)
\t\t\t(mirror no)
\t\t\t(drillshape 1)
\t\t\t(scaleselection 1)
\t\t\t(outputdirectory "")
\t\t)
\t)
\t(net 0 "")
\t(net 1 "+5V")
\t(net 2 "GND")
'''
    # Add all other nets allocated by the per-letter NetAllocator
    for net_id, net_name in sorted(allocator_nets.items()):
        if net_id > 2:
            header += f'\t(net {net_id} "{net_name}")\n'
    return header


def build_board_outline(w, h, r=1.5):
    """Closed rounded-rectangle board outline on Edge.Cuts.

    Every arc's start/mid/end lie ON the arc and share exact endpoints with
    the adjacent edge segments, so the outline is a single closed,
    non-self-intersecting loop (KiCad's invalid_outline checks pass).
    """
    import math
    mf = r * (1 - math.cos(math.pi / 4))   # corner-bulge offset (~0.293 r)

    def line(x1, y1, x2, y2):
        return (f'\t(gr_line (start {x1:.4f} {y1:.4f}) (end {x2:.4f} {y2:.4f}) '
                f'(stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "{uid()}"))')

    def arc(sx, sy, mx, my, ex, ey):
        return (f'\t(gr_arc (start {sx:.4f} {sy:.4f}) (mid {mx:.4f} {my:.4f}) '
                f'(end {ex:.4f} {ey:.4f}) (stroke (width 0.1) (type default)) '
                f'(layer "Edge.Cuts") (uuid "{uid()}"))')

    return '\n'.join([
        # edges
        line(r, 0, w - r, 0),            # top
        line(w, r, w, h - r),            # right
        line(w - r, h, r, h),            # bottom
        line(0, h - r, 0, r),            # left
        # corner arcs (endpoints exactly match the edges above)
        arc(r, 0, mf, mf, 0, r),                       # top-left
        arc(w - r, 0, w - mf, mf, w, r),               # top-right
        arc(w, h - r, w - mf, h - mf, w - r, h),       # bottom-right
        arc(0, h - r, mf, h - mf, r, h),               # bottom-left
    ])


def build_zones(w, h, margin=0.5):
    """GND zone (both layers) + 5V zone (B.Cu, priority 10)."""
    gnd = (
        f'\t(zone\n\t\t(net 2)\n\t\t(net_name "GND")\n\t\t(layers "F.Cu" "B.Cu")\n'
        f'\t\t(uuid "{uid()}")\n\t\t(hatch edge 0.5)\n'
        f'\t\t(connect_pads yes\n\t\t\t(clearance 0.2)\n\t\t)\n'
        f'\t\t(min_thickness 0.2)\n\t\t(filled_areas_thickness no)\n'
        f'\t\t(fill yes (thermal_gap 0.3) (thermal_bridge_width 0.3))\n'
        f'\t\t(polygon\n\t\t\t(pts\n'
        f'\t\t\t\t(xy {margin} {margin}) (xy {w-margin} {margin}) '
        f'(xy {w-margin} {h-margin}) (xy {margin} {h-margin})\n'
        f'\t\t\t)\n\t\t)\n\t)'
    )
    v5 = (
        f'\t(zone\n\t\t(net 1)\n\t\t(net_name "+5V")\n\t\t(layer "B.Cu")\n'
        f'\t\t(uuid "{uid()}")\n\t\t(hatch edge 0.5)\n\t\t(priority 10)\n'
        f'\t\t(connect_pads yes\n\t\t\t(clearance 0.2)\n\t\t)\n'
        f'\t\t(min_thickness 0.2)\n\t\t(filled_areas_thickness no)\n'
        f'\t\t(fill yes (thermal_gap 0.3) (thermal_bridge_width 0.3))\n'
        f'\t\t(polygon\n\t\t\t(pts\n'
        f'\t\t\t\t(xy {margin} {margin}) (xy {w-margin} {margin}) '
        f'(xy {w-margin} {h-margin}) (xy {margin} {h-margin})\n'
        f'\t\t\t)\n\t\t)\n\t)'
    )
    return gnd + '\n' + v5


# ---------------------------------------------------------------------------
# Schematic + project
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Flat schematic generation (matches the PCB)
# ---------------------------------------------------------------------------
#
# A single root sheet that places ONLY the surviving LEDs + caps, arranged in
# the letter shape, with each pin carrying the exact PCB net (power symbols
# for +5V/GND, global labels for the DIN/DOUT chain nets). Because the net
# NAMES are identical to the PCB's, the schematic netlist matches the board.

# WS2812B-2020 symbol pin offsets (library frame). Sheet pin = (X+px, Y-py)
# for a rot-0 instance (verified empirically against the template).
WS_PINS = {'DIN': (-7.62, 0.0), 'VDD': (0.0, 7.62), 'VSS': (0.0, -7.62), 'DOUT': (7.62, 0.0)}
C_PINS = {'1': (0.0, 3.81), '2': (0.0, -3.81)}

SCH_CELL = 25.4          # mm between LEDs in the schematic letter grid
SCH_LETTER_GAP = 25.4    # extra gap between letters
SCH_BASE_X = 40.0
SCH_BASE_Y = 40.0


def _sch_uuid_re_sub(text):
    return re.sub(
        r'"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"',
        lambda m: f'"{uid()}"', text)


def _place_symbol(block, tx, ty, proj, root_uuid, new_ref=None):
    """Relocate a real KiCad symbol-instance block to (tx, ty): shift every
    (at ...) by the delta from its current main position, rename its
    reference, refresh uuids, and rewrite the instances path to this flat
    root sheet."""
    m = re.search(r'\(at ([\d.-]+) ([\d.-]+)(?: ([\d.-]+))?\)', block)
    ox, oy = float(m.group(1)), float(m.group(2))
    dx, dy = tx - ox, ty - oy

    def shift_at(mm):
        x = float(mm.group(1)) + dx
        y = float(mm.group(2)) + dy
        rest = f' {mm.group(3)}' if mm.group(3) else ''
        return f'(at {x:.4f} {y:.4f}{rest})'
    block = re.sub(r'\(at ([\d.-]+) ([\d.-]+)(?: ([\d.-]+))?\)', shift_at, block)

    if new_ref is not None:
        block = re.sub(r'(\(property "Reference" ")[^"]*(")',
                       lambda mm: f'{mm.group(1)}{new_ref}{mm.group(2)}', block, count=1)
        block = re.sub(r'(\(reference ")[^"]*(")',
                       lambda mm: f'{mm.group(1)}{new_ref}{mm.group(2)}', block, count=1)

    # Rewrite the instances project + path → flat root sheet.
    block = re.sub(r'\(project "[^"]*"\s*\(path "[^"]*"',
                   f'(project "{proj}"\n\t\t\t\t(path "/{root_uuid}"', block, count=1)
    return _sch_uuid_re_sub(block)


def _global_label(net, x, y, angle):
    return (f'\t(global_label "{net}"\n'
            f'\t\t(shape input)\n'
            f'\t\t(at {x:.4f} {y:.4f} {angle})\n'
            f'\t\t(effects\n\t\t\t(font (size 1.27 1.27))\n\t\t\t(justify left)\n\t\t)\n'
            f'\t\t(uuid "{uid()}")\n'
            f'\t)')


def letter_link_net_name(letter_idx, r, c):
    """Net name for the DOUT→DIN link leaving the LED at template cell (r,c)
    in letter `letter_idx`. Identical to the name the PCB allocator uses."""
    out_ref = (letter_idx + 1) * 100 + (ref_num(r, c) - 200)
    return f"Net-(D{out_ref}-DOUT)"


def letter_pin_nets(letter_idx, surviving, inverted):
    """Return (surv_chain, {(r,c): {'din','dout','ref_num'}}) with the exact
    PCB net names on each surviving LED's DIN/DOUT pins."""
    surv_chain = [(r, c) for (r, c) in chain_order(inverted=inverted)
                  if (r, c) in surviving]
    data_in = f"LED_DATA_{letter_idx}"
    data_out = f"LED_DATA_{letter_idx}_OUT"
    link = [letter_link_net_name(letter_idx, r, c) for (r, c) in surv_chain[:-1]]
    pins = {}
    n = len(surv_chain)
    for k, (r, c) in enumerate(surv_chain):
        pins[(r, c)] = {
            'din': data_in if k == 0 else link[k - 1],
            'dout': data_out if k == n - 1 else link[k],
            'ref': 1 + r * GRID_COLS + c,  # 2-digit D/C suffix
        }
    return surv_chain, pins


def build_flat_schematic(name, font, template_zip):
    """Build a flat root-sheet schematic that matches the PCB."""
    import zipfile
    with zipfile.ZipFile(template_zip) as zf:
        tsch = zf.read('blank-letter-template/odd_letter.kicad_sch').decode()

    def balanced(start):
        depth = 0
        for k in range(start, len(tsch)):
            if tsch[k] == '(':
                depth += 1
            elif tsch[k] == ')':
                depth -= 1
                if depth == 0:
                    return tsch[start:k + 1]
        return None

    def first_instance(libid):
        i = 0
        while True:
            i = tsch.find('(symbol', i)
            if i == -1:
                return None
            b = balanced(i)
            if f'(lib_id "{libid}")' in b:
                return b
            i += 1

    lib_symbols = balanced(tsch.find('(lib_symbols'))
    tpl = {
        'led': first_instance('LED:WS2812B-2020'),
        'cap': first_instance('Device:C'),
        'v5':  first_instance('power:+5V'),
        'gnd': first_instance('power:GND'),
    }

    proj = f"{name.lower()}-nameplate"
    root_uuid = uid()
    pwr_seq = [0]

    def pwr_ref():
        pwr_seq[0] += 1
        return f"#PWR{pwr_seq[0]:04d}"

    body = []

    def power(kind, x, y):
        body.append(_place_symbol(tpl['v5' if kind == '+5V' else 'gnd'],
                                  x, y, proj, root_uuid, new_ref=pwr_ref()))

    cap_targets = []  # (cap_ref) collected for separate placement

    # --- LEDs in letter-shape grid ---
    for i, ch in enumerate(name):
        bitmap = font[ch]
        inverted = (i % 2 == 1)
        surviving = {(r, c) for r in range(GRID_ROWS) for c in range(GRID_COLS)
                     if bitmap[r][c]}
        _, pins = letter_pin_nets(i, surviving, inverted)
        ox = SCH_BASE_X + i * (GRID_COLS * SCH_CELL + SCH_LETTER_GAP)
        for (r, c), info in pins.items():
            X = ox + c * SCH_CELL
            Y = SCH_BASE_Y + r * SCH_CELL
            ref = f"D{i + 1}{info['ref']:02d}"
            body.append(_place_symbol(tpl['led'], X, Y, proj, root_uuid, new_ref=ref))
            # rails
            power('+5V', X + WS_PINS['VDD'][0], Y - WS_PINS['VDD'][1])
            power('GND', X + WS_PINS['VSS'][0], Y - WS_PINS['VSS'][1])
            # chain labels
            din = (X + WS_PINS['DIN'][0], Y - WS_PINS['DIN'][1])
            dout = (X + WS_PINS['DOUT'][0], Y - WS_PINS['DOUT'][1])
            body.append(_global_label(info['din'], din[0], din[1], 180))
            body.append(_global_label(info['dout'], dout[0], dout[1], 0))
            cap_targets.append(f"C{i + 1}{info['ref']:02d}")

    # --- caps in a separate grid on the right (decoupling: +5V / GND) ---
    cap_x0 = SCH_BASE_X + len(name) * (GRID_COLS * SCH_CELL + SCH_LETTER_GAP) + 20
    CAP_DX, CAP_DY, CAP_PERCOL = 17.78, 15.24, GRID_ROWS
    for n, cref in enumerate(cap_targets):
        col = n // CAP_PERCOL
        row = n % CAP_PERCOL
        X = cap_x0 + col * CAP_DX
        Y = SCH_BASE_Y + row * CAP_DY
        body.append(_place_symbol(tpl['cap'], X, Y, proj, root_uuid, new_ref=cref))
        power('+5V', X + C_PINS['1'][0], Y - C_PINS['1'][1])
        power('GND', X + C_PINS['2'][0], Y - C_PINS['2'][1])

    header = (
        '(kicad_sch\n'
        '\t(version 20250114)\n'
        '\t(generator "nameplate_generator")\n'
        '\t(generator_version "1.0")\n'
        f'\t(uuid "{root_uuid}")\n'
        '\t(paper "A2")\n'
        f'\t{lib_symbols}\n'
    )
    footer = (
        '\t(sheet_instances\n\t\t(path "/"\n\t\t\t(page "1")\n\t\t)\n\t)\n'
        '\t(embedded_fonts no)\n)\n'
    )
    return header + '\n'.join(body) + '\n' + footer


def build_kicad_pro(name):
    return json.dumps({
        "board": {"3dviewports": [], "design_settings": {"defaults": {},
            "rules": {"min_clearance": 0.2, "min_track_width": 0.15}},
            "layers": {"0": {"name": "F.Cu"}, "2": {"name": "B.Cu"}}},
        "meta": {"filename": f"{name.lower()}-nameplate.kicad_pro", "version": 1},
        "sheets": [["", ""]],
    }, indent=2)


# ---------------------------------------------------------------------------
# Collect all net names needed
# ---------------------------------------------------------------------------

def collect_all_nets(template_pcb, name, font):
    """
    Collect all net names that will appear in the output PCB.
    We reuse the template's net names but prefixed per letter.
    """
    template_nets = parse_template_nets(template_pcb)
    all_nets = {0: '', 1: '+5V', 2: 'GND'}
    next_id = 3

    for i, ch in enumerate(name):
        bitmap = font[ch]
        if i % 2 == 1:
            bitmap = list(reversed(bitmap))

        # For each letter, we need the same chain nets as the template
        # but the template net names reference D2XX — we keep them as-is
        # since the footprints reference them by net ID
        for net_id, net_name in template_nets.items():
            if net_id <= 2:
                continue
            # We reuse the same net names — they're unique per letter because
            # the ref numbers differ (D1XX vs D2XX vs D3XX etc.)
            new_name = net_name.replace('D2', f'D{i+1}')
            new_name = new_name.replace('/odd_letter/', f'/letter_{i}/')
            new_name = new_name.replace('/even_letter/', f'/letter_{i}/')
            all_nets[next_id] = new_name
            next_id += 1

    return all_nets


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    if len(sys.argv) < 2:
        print("Usage: python3 generate_nameplate.py NAME")
        sys.exit(1)

    name = sys.argv[1].upper()
    if len(name) > 10:
        print(f"Error: max 10 characters, got {len(name)}")
        sys.exit(1)

    font = load_font()
    for ch in name:
        if ch not in font:
            print(f"Error: character '{ch}' not in font")
            sys.exit(1)

    # Load template PCB (schematic is now generated flat from build_flat_schematic)
    with zipfile.ZipFile(TEMPLATE_ZIP) as zf:
        template_pcb = zf.read('blank-letter-template/blank-letter-template.kicad_pcb').decode()

    print(f"Generating nameplate: {name}")

    # Allocate per-letter nets as we process each letter
    allocator = NetAllocator(start_id=3)

    all_blocks = []
    total_leds = 0

    for i, ch in enumerate(name):
        bitmap = font[ch]
        # Even-position letters (1, 3, 5, ...) use the inverted chain order
        # so data can enter from the bottom-left; the letter still reads
        # upright because we no longer mirror its bitmap.
        inverted = (i % 2 == 1)
        led_count = sum(sum(row) for row in bitmap)
        total_leds += led_count
        print(f"  [{i}] '{ch}': {led_count} LEDs "
              f"{'(inverted chain)' if inverted else ''}")

        all_blocks.extend(
            process_letter(template_pcb, bitmap, i, inverted, allocator)
        )

    # Board dimensions
    num_letters = len(name)
    tile_width = LETTER_WIDTH + LETTER_GAP
    letter_array_width = num_letters * tile_width - LETTER_GAP + 4  # +4 for edge clearance
    board_width = MARGIN * 2 + letter_array_width
    board_height = MARGIN * 2 + LETTER_HEIGHT + 8

    print(f"\n  Total: {total_leds} LEDs")
    print(f"  Board: {board_width:.1f} x {board_height:.1f} mm")
    print(f"  Power: template GND/+5V vias, GND traces, and per-letter "
          f"GND + 5V-fork zones (no 2mm stitch pass)")

    # Assemble PCB. Power infrastructure (GND/+5V vias, GND traces, and the
    # tiled GND + 5V-fork zones) all come from the template per letter.
    pcb = build_pcb_header(name, allocator.nets)
    pcb += build_board_outline(board_width, board_height) + '\n'
    pcb += f'\t(gr_text "{name}" (at {board_width/2:.3f} {board_height + 3:.3f}) (layer "F.SilkS") (uuid "{uid()}")\n\t\t(effects (font (size 1.5 1.5) (thickness 0.15)))\n\t)\n'

    for block in all_blocks:
        pcb += block + '\n'

    pcb += ')\n'

    # Output
    out_dir = os.path.join(SCRIPT_DIR, f"{name.lower()}-nameplate")
    os.makedirs(out_dir, exist_ok=True)

    pcb_path = os.path.join(out_dir, f"{name.lower()}-nameplate.kicad_pcb")
    with open(pcb_path, 'w') as f:
        f.write(pcb)
    print(f"\n  -> {pcb_path}")

    # Flat schematic that matches the PCB (no more hierarchical blank-letter
    # sheets). Surviving LEDs/caps only, in the letter shape, nets = PCB nets.
    sch_path = os.path.join(out_dir, f"{name.lower()}-nameplate.kicad_sch")
    with open(sch_path, 'w') as f:
        f.write(build_flat_schematic(name, font, TEMPLATE_ZIP))
    with open(os.path.join(out_dir, f"{name.lower()}-nameplate.kicad_pro"), 'w') as f:
        f.write(build_kicad_pro(name))
    print(f"  -> {sch_path}")

    # Firmware config
    cfg_path = os.path.join(out_dir, "nameplate_config.h")
    with open(cfg_path, 'w') as f:
        f.write(f'// Auto-generated for "{name}"\n#pragma once\n\n')
        f.write(f'#define NAMEPLATE_NAME "{name}"\n')
        f.write(f'#define NAMEPLATE_LETTER_COUNT {num_letters}\n')
        f.write(f'#define NAMEPLATE_TOTAL_LEDS {total_leds}\n\n')
        counts = [sum(sum(r) for r in font[c]) for c in name]
        f.write(f'static const uint8_t LEDS_PER_LETTER[{num_letters}] = {{{", ".join(str(c) for c in counts)}}};\n')
    print(f"  -> {cfg_path}")

    # --- Optional: ship to desktop with auto-versioned name ---
    if "--no-desktop" not in sys.argv:
        try:
            ship_to_desktop(name, out_dir)
        except Exception as e:
            print(f"\n  [warn] desktop ship failed: {e}")

    print(f"\nDone!")


def ship_to_desktop(name, src_dir):
    """Send the generated project to the desktop with an auto-incremented version suffix."""
    base_name = f"{name.lower()}-nameplate"

    # Find next version number by listing existing DIRECTORIES via PowerShell
    # (desktop_list_files returns files only — we need to ask via shell).
    version = 1
    try:
        list_script = (
            f'Get-ChildItem -Path "{DESKTOP_DEST_BASE}" -Directory -Force '
            f'-ErrorAction SilentlyContinue | '
            f'Where-Object {{ $_.Name -like "{base_name}-*" }} | '
            f'ForEach-Object {{ $_.Name }}'
        )
        list_script_path = "/tmp/_list_versions.ps1"
        with open(list_script_path, "w") as f:
            f.write(list_script + "\n")
        subprocess.run(
            ["adom-desktop", "send_files",
             json.dumps({"filePaths": [list_script_path], "targetApp": "general",
                         "destinationFolder": "scripts"})],
            capture_output=True, text=True, timeout=30
        )
        result = subprocess.run(
            ["adom-desktop", "shell_execute",
             json.dumps({"command":
                         "powershell -ExecutionPolicy Bypass -File "
                         "C:\\Users\\noah\\Downloads\\scripts\\_list_versions.ps1"})],
            capture_output=True, text=True, timeout=30
        )
        data = json.loads(result.stdout)
        names = [ln.strip() for ln in data.get("stdout", "").splitlines() if ln.strip()]
        while f"{base_name}-{version}" in names:
            version += 1
    except Exception:
        version = 1

    versioned_name = f"{base_name}-{version}"
    print(f"\n  Shipping to desktop as: {versioned_name}")

    # Send each file individually (large PCB triggers 413 if batched).
    # Use a unique staging folder per version so an old leftover doesn't cause nesting.
    files_to_send = [
        f"{base_name}.kicad_pcb",
        f"{base_name}.kicad_pro",
        f"{base_name}.kicad_sch",
        "nameplate_config.h",
    ]
    rel_dest = f"nameplate-staging/{versioned_name}"
    for fname in files_to_send:
        fpath = os.path.join(src_dir, fname)
        if not os.path.exists(fpath):
            continue
        subprocess.run(
            ["adom-desktop", "send_files",
             json.dumps({"filePaths": [fpath], "targetApp": "kicad",
                         "destinationFolder": rel_dest})],
            capture_output=True, text=True, timeout=60
        )

    # Move staged files into the final destination.
    # IMPORTANT: clear destination first then copy CONTENTS (not the folder itself)
    # to avoid Move-Item's "place source inside existing destination" behavior.
    staging = f"C:\\Users\\noah\\Downloads\\{rel_dest.replace('/', chr(92))}"
    dst = f"{DESKTOP_DEST_BASE}\\{versioned_name}"
    script = (
        f'$staging = "{staging}"\n'
        f'$dst = "{dst}"\n'
        f'if (Test-Path $dst) {{ Remove-Item -Recurse -Force $dst }}\n'
        f'New-Item -ItemType Directory -Force -Path $dst | Out-Null\n'
        f'Get-ChildItem -Path $staging -File | Move-Item -Destination $dst -Force\n'
        f'Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue\n'
        f'$count = (Get-ChildItem $dst -File | Measure-Object).Count\n'
        f'Write-Output "Moved $count files to $dst"\n'
    )
    script_path = "/tmp/_ship_nameplate.ps1"
    with open(script_path, "w") as f:
        f.write(script)
    subprocess.run(
        ["adom-desktop", "send_files",
         json.dumps({"filePaths": [script_path], "targetApp": "general",
                     "destinationFolder": "scripts"})],
        capture_output=True, text=True, timeout=30
    )
    result = subprocess.run(
        ["adom-desktop", "shell_execute",
         json.dumps({"command":
                     "powershell -ExecutionPolicy Bypass -File "
                     "C:\\Users\\noah\\Downloads\\scripts\\_ship_nameplate.ps1"})],
        capture_output=True, text=True, timeout=60
    )
    try:
        data = json.loads(result.stdout)
        if data.get("success"):
            print(f"  -> {dst}\\  ({data.get('stdout','').strip()})")
        else:
            print(f"  [warn] move failed: {data.get('stderr', '')[:200]}")
    except Exception:
        print(f"  [warn] could not parse shell_execute result")


if __name__ == "__main__":
    main()
