#!/usr/bin/env python3
"""Generate the LED-nameplate routing visualiser HTML.

Uses the SAME `dijkstra_alg` module as `generate_nameplate.py`, so the
images here are exactly what would appear on the PCB.

Two ways to view:
  * Static file (all 36 glyphs precomputed + embedded):
        python3 route_visualizer.py [output.html]
  * Live server (adds the interactive Custom-Shape editor, which routes
    arbitrary 5x9 selections on demand via the shared Python router):
        python3 route_server.py
"""

import json
import os
import sys

# Allow `python3 route_visualizer.py` from any CWD
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)
PROJECT_DIR = os.path.dirname(SCRIPT_DIR)

import dijkstra_alg as da
import generate_nameplate as gn  # shared GND-keepout extraction


FONT_PATH = gn.FONT_PATH  # shared resolver (dev tree OR bundled-alongside)
DEFAULT_OUTPUT = os.path.join(SCRIPT_DIR, "route_visualizer.html")

_TEMPLATE_PCB = None


def _template_pcb():
    """Lazily load the blank-letter template PCB (for GND-keepout)."""
    global _TEMPLATE_PCB
    if _TEMPLATE_PCB is None:
        import zipfile
        with zipfile.ZipFile(gn.TEMPLATE_ZIP) as zf:
            _TEMPLATE_PCB = zf.read(
                'blank-letter-template/blank-letter-template.kicad_pcb').decode()
    return _TEMPLATE_PCB


def _gnd_keepout(surviving):
    """Same GND-copper keepout the PCB generator uses, so visualiser routes
    match the board exactly."""
    vias, segs = gn.kept_gnd_geometry(_template_pcb(), surviving)
    return da.gnd_keepout_nodes(vias, segs)


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


# ---------------------------------------------------------------------------
# Computation (shared by static glyphs and the live /api/route endpoint)
# ---------------------------------------------------------------------------

def _geometry(surviving):
    """Variant-independent scene geometry: bounds, grid nodes, LED placement,
    cap centres. Bounds are the full 5x9 tile (fixed), so every shape shares
    the same canvas."""
    x0, x1, y0, y1 = da.grid_bounds()
    grid_nodes = []
    yy = y0
    while yy <= y1 + 1e-6:
        xx = x0
        while xx <= x1 + 1e-6:
            grid_nodes.append([round(xx, 3), round(yy, 3)])
            xx += da.ROUTING_UNIT
        yy += da.ROUTING_UNIT

    caps = []
    leds = []
    for (r, c) in sorted(surviving):
        cx, cy = da.grid_pos(r, c)
        caps.append([round(cx + da.CAP_OFFSET[0], 3), round(cy + da.CAP_OFFSET[1], 3)])
        din_x, din_y = da.corner_for(r, c, 'DIN')
        dout_x, dout_y = da.corner_for(r, c, 'DOUT')
        leds.append({
            'r': r, 'c': c,
            'x': round(cx, 3), 'y': round(cy, 3),
            'rot': da.get_led_rotation(r),
            'din': [round(din_x, 3), round(din_y, 3)],
            'dout': [round(dout_x, 3), round(dout_y, 3)],
        })

    return {
        'bounds': [round(v, 3) for v in (x0, y0, x1, y1)],
        'grid_nodes': grid_nodes,
        'leds': leds,
        'caps': caps,
        'survivor_count': len(surviving),
    }


def _variant(surviving, inverted):
    """Routing result for one chain-order variant: chain indices, routes,
    final blocked set."""
    results, final_blocked = da.route_chain(
        surviving, inverted=inverted, extra_blocked=_gnd_keepout(surviving))
    surv_chain = [(r, c) for (r, c) in da.chain_order(inverted=inverted)
                  if (r, c) in surviving]
    chain_idx = {f'{r},{c}': i for i, (r, c) in enumerate(surv_chain)}
    routes = []
    for res in results:
        if res['path'] is None:
            routes.append({'i': res['i'], 'path': None,
                           'src': list(res['src']), 'dst': list(res['dst'])})
            continue
        waypoints = da.consolidate(res['path'])
        routes.append({
            'i': res['i'],
            'src': [round(res['src'][0], 3), round(res['src'][1], 3)],
            'dst': [round(res['dst'][0], 3), round(res['dst'][1], 3)],
            'path': [[round(x, 3), round(y, 3)] for (x, y) in waypoints],
        })
    return {
        'chain_idx': chain_idx,
        'routes': routes,
        'blocked': [[round(x, 3), round(y, 3)] for (x, y) in sorted(final_blocked)],
        'chain_count': len(surv_chain),
    }


def compute_shape(surviving, inverted=False):
    """Flat single-variant scene for the live API (custom editor)."""
    geo = _geometry(surviving)
    v = _variant(surviving, inverted)
    geo.update(routes=v['routes'], blocked=v['blocked'],
               chain_idx=v['chain_idx'], chain_count=v['chain_count'])
    return geo


def compute_letter_data(bitmap):
    """Both NORMAL and INVERTED variants for one embedded glyph (shared
    geometry + per-variant routes/blocked/chain_idx)."""
    surviving = set()
    for r in range(da.GRID_ROWS):
        for c in range(da.GRID_COLS):
            if bitmap[r][c]:
                surviving.add((r, c))
    data = _geometry(surviving)
    data['normal'] = _variant(surviving, inverted=False)
    data['inverted'] = _variant(surviving, inverted=True)
    return data


def build_meta():
    """Geometry constants the renderer needs to draw realistic footprints +
    keepout zones, pulled straight from dijkstra_alg so they never drift."""
    return {
        'grid_cols': da.GRID_COLS,
        'grid_rows': da.GRID_ROWS,
        'led_pitch': da.LED_PITCH,
        'routing_unit': da.ROUTING_UNIT,
        'led_body_half': da.LED_BODY_HALF,
        'pad_half': [da.PAD_HALF_X, da.PAD_HALF_Y],
        'pad_margin': da.PAD_MARGIN,
        'pad_clearance': da.PAD_CLEARANCE,
        'pad_local': {k: list(v) for k, v in da.PAD_LOCAL.items()},
        'cap_offset': list(da.CAP_OFFSET),
        'cap_half': [da.CAP_HALF_X, da.CAP_HALF_Y],
        'din_offset': list(da.DIN_OFFSET),
        'dout_offset': list(da.DOUT_OFFSET),
    }


def build_html(letters_data, glyph_order):
    payload = {
        'meta': build_meta(),
        'glyph_order': glyph_order,
        'letters': letters_data,
    }
    payload_json = json.dumps(payload, separators=(',', ':'))
    return _HTML_TEMPLATE.replace('__PAYLOAD__', payload_json)


def build_all_glyphs():
    """(glyph_order, letters_data) for A-Z, 0-9 with at least one lit cell."""
    font = load_font()
    candidates = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
    glyph_order, letters_data = [], {}
    for g in candidates:
        if g not in font:
            continue
        bitmap = font[g]
        if not any(any(row) for row in bitmap):
            continue
        glyph_order.append(g)
        letters_data[g] = compute_letter_data(bitmap)
    return glyph_order, letters_data


def render_full_html():
    glyph_order, letters_data = build_all_glyphs()
    return build_html(letters_data, glyph_order)


_HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>LED Nameplate Routing Visualiser</title>
<style>
  :root {
    --bg: #0c0f14;
    --panel: #141a22;
    --fg: #e6edf3;
    --dim: #6b7480;
    --accent: #4cc2ff;
    --led-body: #2b2f3a;
    --led-edge: #4a5260;
    --pad-copper: #c98a3c;
    --pad-copper-edge: #e6a85a;
    --pad-din: #6fff9c;
    --pad-dout: #ff7a6f;
    --pad-vdd: #ff5555;
    --pad-vss: #7f8a99;
    --cap-fill: #a85a16;
    --cap-stroke: #d97a2c;
    --keepout: #ff5577;
    --route: #ff8c1a;
    --grid-dot: #2a3340;
    --blocked-dot: #ff5577;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0; padding: 16px 24px;
    background: var(--bg); color: var(--fg);
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  }
  h1 { font-size: 18px; margin: 0 0 8px 0; font-weight: 600; }
  .sub { color: var(--dim); font-size: 12px; margin: 0 0 14px 0; }
  .layout { display: grid; grid-template-columns: minmax(280px, 340px) 1fr; gap: 18px; }
  .pane { background: var(--panel); border-radius: 8px; padding: 14px; }
  .modebar { display: flex; gap: 6px; margin-bottom: 12px; }
  .modebar button {
    flex: 1; appearance: none; background: #1c2532; color: var(--fg);
    border: 1px solid #25313f; border-radius: 6px; padding: 9px 0;
    font-size: 13px; font-weight: 600; cursor: pointer;
  }
  .modebar button.active { background: var(--accent); color: #06121b; border-color: var(--accent); }
  .glyphs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-top: 8px; }
  .glyphs button {
    appearance: none; background: #1c2532; color: var(--fg);
    border: 1px solid #25313f; border-radius: 6px; padding: 8px 0;
    font-family: ui-monospace, monospace; font-size: 14px; cursor: pointer;
    transition: background .12s, border-color .12s;
  }
  .glyphs button:hover { background: #28384d; }
  .glyphs button.active { background: var(--accent); color: #06121b; border-color: var(--accent); font-weight: 700; }

  .editor-wrap { margin-top: 8px; }
  .editor {
    display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px;
    width: 175px; margin: 8px auto;
    touch-action: none;
  }
  .editor .cell {
    aspect-ratio: 1; border-radius: 5px; cursor: pointer;
    background: #161d27; border: 1px solid #28323f;
    transition: background .06s;
  }
  .editor .cell.on {
    background: var(--pad-copper); border-color: var(--pad-copper-edge);
    box-shadow: 0 0 6px rgba(201,138,60,.5);
  }
  .editor-actions { display: flex; gap: 6px; margin-top: 4px; }
  .editor-actions button {
    flex: 1; appearance: none; background: #1c2532; color: var(--fg);
    border: 1px solid #25313f; border-radius: 6px; padding: 7px 0;
    font-size: 12px; cursor: pointer;
  }
  .editor-actions button:hover { background: #28384d; }
  .hint { font-size: 11px; color: var(--dim); margin-top: 6px; text-align: center; }

  .toggles {
    display: flex; flex-direction: column; gap: 8px; margin-top: 14px;
    padding-top: 14px; border-top: 1px solid #1f2937;
  }
  .toggles label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
  .stats {
    margin-top: 14px; padding-top: 14px; border-top: 1px solid #1f2937;
    font-size: 12px; color: var(--dim); line-height: 1.6;
  }
  .stats b { color: var(--fg); }
  svg { width: 100%; height: auto; background: #06090e; border-radius: 6px; display: block; }
  .legend { font-size: 11px; color: var(--dim); margin-top: 8px; display: flex; gap: 16px; flex-wrap: wrap; }
  .legend span::before { content: "■"; margin-right: 6px; }
  .legend .l-pad::before  { color: var(--pad-copper); }
  .legend .l-keep::before { color: var(--keepout); }
  .legend .l-cap::before  { color: var(--cap-fill); }
  .legend .l-route::before{ color: var(--route); }
  .legend .l-din::before  { color: var(--pad-din); }
  .legend .l-dout::before { color: var(--pad-dout); }
  .hidden { display: none !important; }
</style>
</head>
<body>

<h1>LED Nameplate Routing Visualiser</h1>
<p class="sub">
  Same Dijkstra router as <code>generate_nameplate.py</code> (shared
  <code>dijkstra_alg.py</code>). Pick a glyph, or switch to Custom Shape to
  draw any 5&times;9 pattern and route it live.
</p>

<div class="layout">
  <div class="pane">
    <div class="modebar">
      <button id="mode-glyph" class="active">Glyphs</button>
      <button id="mode-custom">Custom Shape</button>
    </div>

    <!-- Glyph picker -->
    <div id="glyph-section">
      <div><strong>Glyph</strong></div>
      <div class="glyphs" id="glyphs"></div>
    </div>

    <!-- Custom editor -->
    <div id="custom-section" class="editor-wrap hidden">
      <div><strong>Draw shape</strong> <span style="color:var(--dim);font-size:11px">(click / drag cells)</span></div>
      <div class="editor" id="editor"></div>
      <div class="editor-actions">
        <button id="ed-clear">Clear</button>
        <button id="ed-fill">Fill all</button>
        <button id="ed-invert">Invert</button>
      </div>
      <div class="hint" id="custom-hint"></div>
    </div>

    <div class="toggles">
      <label><input type="checkbox" id="t-inverted"> Inverted-numbered letter
        <span style="color:var(--dim);font-size:11px">(chain starts bottom-left, snakes up)</span>
      </label>
      <label><input type="checkbox" id="t-footprint" checked> Show footprint (pads + keepout)</label>
      <label><input type="checkbox" id="t-grid" checked> Show routing grid</label>
      <label><input type="checkbox" id="t-blocked"> Show blocked nodes</label>
      <label><input type="checkbox" id="t-cap" checked> Show decoupling caps</label>
      <label><input type="checkbox" id="t-numbers"> Number LEDs by chain order</label>
    </div>

    <div class="stats" id="stats"></div>
    <div class="legend">
      <span class="l-pad">Pad</span>
      <span class="l-din">DIN</span>
      <span class="l-dout">DOUT</span>
      <span class="l-keep">Keepout</span>
      <span class="l-cap">Cap</span>
      <span class="l-route">Trace</span>
    </div>
  </div>

  <div class="pane">
    <svg id="canvas" xmlns="http://www.w3.org/2000/svg"></svg>
  </div>
</div>

<script>
const DATA = __PAYLOAD__;
const META = DATA.meta;
const SCALE = 22;       // px per mm
const PAD = 12;

function svgEl(tag, attrs) {
  const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
  for (const k in attrs) el.setAttribute(k, attrs[k]);
  return el;
}
function rotLocal(lx, ly, rot) { return rot === 180 ? [-lx, -ly] : [lx, ly]; }

// ---- pad geometry (mirrors dijkstra_alg) ----
const PAD_ROLES = ['DOUT', 'VSS', 'DIN', 'VDD'];
const PAD_NUM   = { DOUT: '1', VSS: '2', DIN: '3', VDD: '4' };
const PAD_COLOR = {
  DOUT: 'var(--pad-dout)', DIN: 'var(--pad-din)',
  VDD:  'var(--pad-vdd)',  VSS: 'var(--pad-vss)',
};

function ledPads(led) {
  // returns [{role, cx, cy}] global pad centres
  const out = [];
  for (const role of PAD_ROLES) {
    const [lx, ly] = META.pad_local[role];
    const [rx, ry] = rotLocal(lx, ly, led.rot);
    out.push({ role, cx: led.x + rx, cy: led.y + ry });
  }
  return out;
}

let CURRENT = null;   // last rendered scene

function renderScene(scene, label) {
  CURRENT = { scene, label };
  const [x0, y0, x1, y1] = scene.bounds;
  const w = (x1 - x0) * SCALE + 2 * PAD;
  const h = (y1 - y0) * SCALE + 2 * PAD;
  const X = x => (x - x0) * SCALE + PAD;
  const Y = y => (y - y0) * SCALE + PAD;

  const svg = document.getElementById('canvas');
  svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
  svg.innerHTML = '';

  const defs = svgEl('defs', {});
  defs.innerHTML =
    '<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">' +
    '<feGaussianBlur stdDeviation="1.6" result="b"/>' +
    '<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>';
  svg.appendChild(defs);

  const showFp    = document.getElementById('t-footprint').checked;
  const showGrid  = document.getElementById('t-grid').checked;
  const showBlk   = document.getElementById('t-blocked').checked;
  const showCap   = document.getElementById('t-cap').checked;
  const showNums  = document.getElementById('t-numbers').checked;

  const ph = META.pad_half, m = META.pad_margin, bodyH = META.led_body_half;
  const capH = META.cap_half;

  // 1) routing grid
  if (showGrid) {
    const g = svgEl('g', { opacity: '0.5' });
    for (const [x, y] of scene.grid_nodes)
      g.appendChild(svgEl('circle', { cx: X(x), cy: Y(y), r: 0.7, fill: 'var(--grid-dot)' }));
    svg.appendChild(g);
  }

  // 2) keepout zones (single group opacity so overlaps read uniform)
  if (showFp) {
    const g = svgEl('g', { opacity: '0.16' });
    const rect = (cx, cy, hx, hy) => g.appendChild(svgEl('rect', {
      x: X(cx - hx), y: Y(cy - hy),
      width: 2 * hx * SCALE, height: 2 * hy * SCALE,
      fill: 'var(--keepout)',
    }));
    for (const led of scene.leds) {
      rect(led.x, led.y, bodyH, bodyH);                 // body
      for (const p of ledPads(led))                     // expanded pads
        rect(p.cx, p.cy, ph[0] + m, ph[1] + m);
    }
    for (const [cx, cy] of scene.caps)                  // cap
      rect(cx, cy + 0, capH[0] + m, capH[1] + m);
    svg.appendChild(g);
  }

  // 3) blocked nodes
  if (showBlk) {
    const g = svgEl('g', { opacity: '0.85' });
    for (const [x, y] of scene.blocked)
      g.appendChild(svgEl('circle', { cx: X(x), cy: Y(y), r: 1.3, fill: 'var(--blocked-dot)' }));
    svg.appendChild(g);
  }

  // 4) decoupling caps
  if (showCap) {
    const g = svgEl('g', {});
    for (const [cx, cy] of scene.caps)
      g.appendChild(svgEl('rect', {
        x: X(cx - capH[0]), y: Y(cy - capH[1]),
        width: 2 * capH[0] * SCALE, height: 2 * capH[1] * SCALE,
        rx: 1, ry: 1, fill: 'var(--cap-fill)', stroke: 'var(--cap-stroke)', 'stroke-width': 0.6,
      }));
    svg.appendChild(g);
  }

  // 5) LEDs — realistic footprint (body + 4 pads)
  const gL = svgEl('g', {});
  for (const led of scene.leds) {
    // body
    gL.appendChild(svgEl('rect', {
      x: X(led.x - bodyH), y: Y(led.y - bodyH),
      width: 2 * bodyH * SCALE, height: 2 * bodyH * SCALE,
      rx: 2, ry: 2, fill: 'var(--led-body)', stroke: 'var(--led-edge)', 'stroke-width': 0.8,
    }));
    if (showFp) {
      for (const p of ledPads(led)) {
        gL.appendChild(svgEl('rect', {
          x: X(p.cx - ph[0]), y: Y(p.cy - ph[1]),
          width: 2 * ph[0] * SCALE, height: 2 * ph[1] * SCALE,
          rx: 1.2, ry: 1.2,
          fill: 'var(--pad-copper)', stroke: PAD_COLOR[p.role], 'stroke-width': 1.4,
        }));
        gL.appendChild(svgEl('circle', {  // pin-1 / role dot in pad corner
          cx: X(p.cx), cy: Y(p.cy), r: 1.6, fill: PAD_COLOR[p.role], opacity: 0.9,
        }));
      }
    }
    if (showNums) {
      const idx = scene.chain_idx[`${led.r},${led.c}`];
      const t = svgEl('text', {
        x: X(led.x), y: Y(led.y) + 4, 'text-anchor': 'middle',
        'font-family': 'ui-monospace, monospace', 'font-size': 11, 'font-weight': 700,
        fill: '#e6edf3',
      });
      t.textContent = (idx === undefined) ? '' : idx + 1;
      gL.appendChild(t);
    }
  }
  svg.appendChild(gL);

  // 6) traces — single colour
  const gR = svgEl('g', { filter: 'url(#glow)' });
  for (const rt of scene.routes) {
    if (!rt.path) continue;
    const pts = rt.path.map(([x, y]) => `${X(x)},${Y(y)}`).join(' ');
    gR.appendChild(svgEl('polyline', {
      points: pts, fill: 'none', stroke: 'var(--route)', 'stroke-width': 2.2,
      'stroke-linecap': 'round', 'stroke-linejoin': 'round', opacity: 0.97,
    }));
  }
  svg.appendChild(gR);

  // stats
  const links = scene.routes.length;
  const failed = scene.routes.filter(r => !r.path).length;
  const inv = document.getElementById('t-inverted').checked;
  const variantLabel = inv
    ? '<span style="color:var(--accent)">INVERTED chain</span> &mdash; starts bottom-left, snakes up'
    : 'NORMAL chain &mdash; starts top-left, snakes down';
  document.getElementById('stats').innerHTML =
    `<b>${label}</b> &mdash; ${scene.survivor_count} LEDs &middot; ${links} links` +
    (failed ? ` (<span style="color:#ff5577">${failed} unrouted</span>)` : '') +
    `<br>${variantLabel}<br>` +
    `Routing grid: ${META.routing_unit} mm &middot; pad clearance: ${META.pad_clearance} mm`;
}

// =================== GLYPH MODE ===================
function renderGlyph(glyph) {
  const letter = DATA.letters[glyph];
  if (!letter) return;
  const inv = document.getElementById('t-inverted').checked;
  const v = inv ? letter.inverted : letter.normal;
  const scene = {
    bounds: letter.bounds, grid_nodes: letter.grid_nodes,
    leds: letter.leds, caps: letter.caps, survivor_count: letter.survivor_count,
    routes: v.routes, blocked: v.blocked, chain_idx: v.chain_idx,
  };
  renderScene(scene, glyph);
}

const pick = document.getElementById('glyphs');
for (const g of DATA.glyph_order) {
  const b = document.createElement('button');
  b.textContent = g; b.dataset.glyph = g;
  b.addEventListener('click', () => {
    document.querySelectorAll('.glyphs button').forEach(x => x.classList.remove('active'));
    b.classList.add('active');
    MODE = 'glyph'; ACTIVE_GLYPH = g; renderGlyph(g);
  });
  pick.appendChild(b);
}

// =================== CUSTOM MODE ===================
const COLS = META.grid_cols, ROWS = META.grid_rows;
const selected = new Set();          // "r,c"
let painting = false, paintVal = true;

const editor = document.getElementById('editor');
const cellEls = {};
for (let r = 0; r < ROWS; r++) {
  for (let c = 0; c < COLS; c++) {
    const d = document.createElement('div');
    d.className = 'cell'; d.dataset.r = r; d.dataset.c = c;
    const key = `${r},${c}`;
    cellEls[key] = d;
    d.addEventListener('pointerdown', (e) => {
      e.preventDefault();
      painting = true;
      paintVal = !selected.has(key);
      applyCell(key, paintVal);
    });
    d.addEventListener('pointerenter', () => { if (painting) applyCell(key, paintVal); });
    editor.appendChild(d);
  }
}
window.addEventListener('pointerup', () => {
  if (painting) { painting = false; scheduleRoute(); }
});

function applyCell(key, on) {
  if (on) { selected.add(key); cellEls[key].classList.add('on'); }
  else { selected.delete(key); cellEls[key].classList.remove('on'); }
  if (!painting) scheduleRoute();   // single click → route now; drag → on pointerup
}

document.getElementById('ed-clear').addEventListener('click', () => {
  selected.clear(); Object.values(cellEls).forEach(d => d.classList.remove('on')); scheduleRoute();
});
document.getElementById('ed-fill').addEventListener('click', () => {
  for (const k in cellEls) { selected.add(k); cellEls[k].classList.add('on'); } scheduleRoute();
});
document.getElementById('ed-invert').addEventListener('click', () => {
  for (const k in cellEls) {
    if (selected.has(k)) { selected.delete(k); cellEls[k].classList.remove('on'); }
    else { selected.add(k); cellEls[k].classList.add('on'); }
  }
  scheduleRoute();
});

let routeTimer = null;
function scheduleRoute() {
  if (MODE !== 'custom') return;
  clearTimeout(routeTimer);
  routeTimer = setTimeout(routeCustom, 90);
}

async function routeCustom() {
  const hint = document.getElementById('custom-hint');
  if (selected.size === 0) {
    document.getElementById('canvas').innerHTML = '';
    document.getElementById('stats').innerHTML = '<b>Custom</b> &mdash; select cells to route';
    hint.textContent = '';
    return;
  }
  const cells = Array.from(selected).join(';');
  const inv = document.getElementById('t-inverted').checked ? 1 : 0;
  const url = new URL('api/route', location.href);
  url.searchParams.set('cells', cells);
  url.searchParams.set('inverted', inv);
  hint.textContent = 'routing…';
  try {
    const resp = await fetch(url, { cache: 'no-store' });
    if (!resp.ok) throw new Error('HTTP ' + resp.status);
    const scene = await resp.json();
    renderScene(scene, 'Custom');
    hint.textContent = '';
  } catch (err) {
    hint.innerHTML = '<span style="color:#ff5577">live routing needs the server ' +
      '(run route_server.py). ' + err + '</span>';
  }
}

// =================== MODE SWITCH ===================
let MODE = 'glyph';
let ACTIVE_GLYPH = null;
const mGlyph = document.getElementById('mode-glyph');
const mCustom = document.getElementById('mode-custom');
function setMode(mode) {
  MODE = mode;
  mGlyph.classList.toggle('active', mode === 'glyph');
  mCustom.classList.toggle('active', mode === 'custom');
  document.getElementById('glyph-section').classList.toggle('hidden', mode !== 'glyph');
  document.getElementById('custom-section').classList.toggle('hidden', mode !== 'custom');
  if (mode === 'glyph') renderGlyph(ACTIVE_GLYPH || DATA.glyph_order[0]);
  else routeCustom();
}
mGlyph.addEventListener('click', () => setMode('glyph'));
mCustom.addEventListener('click', () => setMode('custom'));

// Re-render on toggle change (respect current mode)
for (const id of ['t-inverted', 't-footprint', 't-grid', 't-blocked', 't-cap', 't-numbers']) {
  document.getElementById(id).addEventListener('change', () => {
    if (MODE === 'glyph') { if (ACTIVE_GLYPH) renderGlyph(ACTIVE_GLYPH); }
    else routeCustom();
  });
}

// init
const def = DATA.glyph_order.includes('A') ? 'A' : DATA.glyph_order[0];
ACTIVE_GLYPH = def;
document.querySelector(`.glyphs button[data-glyph="${def}"]`).classList.add('active');
renderGlyph(def);
</script>

</body>
</html>
"""


def main():
    output = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_OUTPUT
    glyph_order, letters_data = build_all_glyphs()
    html = build_html(letters_data, glyph_order)
    with open(output, 'w') as f:
        f.write(html)
    print(f"Wrote {output}")
    print(f"  Glyphs: {len(glyph_order)}")
    for variant in ('normal', 'inverted'):
        total = sum(len(d[variant]['routes']) for d in letters_data.values())
        failed = sum(sum(1 for r in d[variant]['routes'] if not r['path'])
                     for d in letters_data.values())
        print(f"  [{variant}] {total} chain links, {failed} unrouted")


if __name__ == "__main__":
    main()
