/**
 * PHANTOM ZENITH ORIGIN — OPERATIONS TOOL: STAGE MAP (PROCGEN)
 * assets/js/components/OpsTool_StageMap.jsx
 * ─────────────────────────────────────────────────────────────
 * Procedural stage layout generator. Visualises room placement,
 * spine/branch/loop connections, and per-stage tilesets for all
 * five defined stages.
 *
 * Adapted from standalone artifact → embeddable PZO tool.
 * Key changes from the original:
 *   - ES module import replaced with global React destructure
 *   - export default removed; component registered on window
 *   - Outer container changed from 100vh to embedded height
 *   - Font-family simplified (inherits from page CSS)
 *
 * Registration:
 *   window.OpsTool_StageMap = ProceduralLevelGenerator
 *
 * Depends on: React 18 (global), tokens.css (via CSS vars)
 * ─────────────────────────────────────────────────────────────
 */

(function () {
'use strict';

const { useState, useCallback, useRef, useEffect, useMemo } = React;

// ============================================================
// PHANTOM ZENITH ORIGIN — DESIGN TOKENS
// Mirrored from assets/css/tokens.css
// ============================================================

const T = {
  bgDeep:    "#080d14",
  bgPanel:   "#0f1923",
  bgSurface: "#1a2535",
  border:    "#1e3a4a",
  borderDim: "#162030",
  cyan:      "#22d3ee",
  cyanDim:   "#0e7490",
  cyanGlow:  "rgba(34,211,238,0.12)",
  teal:      "#5eead4",
  orange:    "#fdba74",
  fuchsia:   "#f0abfc",
  textHi:    "#e2e8f0",
  textMid:   "#94a3b8",
  textLo:    "#475569",
};

// Unified theme derived from tokens — all stages share this
const THEME = {
  bg: T.bgDeep,
  accent: T.cyan,
  gridColor: "rgba(34,211,238,0.04)",
  gridColorLg: "rgba(34,211,238,0.07)",
  spineColor: T.teal,
  branchColor: T.orange,
  loopColor: T.fuchsia,
  panelBg: T.bgPanel + "dd",
  panelBorder: T.border,
  startColor: T.teal,
  exitColor: T.fuchsia,
};

// ============================================================
// GEOMETRY UTILITIES
// ============================================================

const Vec2 = {
  add: (a, b) => ({ x: a.x + b.x, y: a.y + b.y }),
  sub: (a, b) => ({ x: a.x - b.x, y: a.y - b.y }),
  scale: (v, s) => ({ x: v.x * s, y: v.y * s }),
  rotate: (v, angle) => ({
    x: v.x * Math.cos(angle) - v.y * Math.sin(angle),
    y: v.x * Math.sin(angle) + v.y * Math.cos(angle),
  }),
  dot: (a, b) => a.x * b.x + a.y * b.y,
  cross: (a, b) => a.x * b.y - a.y * b.x,
  length: (v) => Math.sqrt(v.x * v.x + v.y * v.y),
  normalize: (v) => {
    const l = Vec2.length(v);
    return l > 0 ? { x: v.x / l, y: v.y / l } : { x: 0, y: 0 };
  },
  dist: (a, b) => Vec2.length(Vec2.sub(a, b)),
  lerp: (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }),
};

function transformRoom(room, angle, offset) {
  const verts = room.vertices.map((v) => Vec2.add(Vec2.rotate(v, angle), offset));
  const sockets = room.sockets.map((s) => ({
    ...s,
    pos: Vec2.add(Vec2.rotate(s.pos, angle), offset),
    normal: Vec2.rotate(s.normal, angle),
  }));
  return { ...room, vertices: verts, sockets, rotation: angle, position: offset };
}

function nudgeRoom(room, delta) {
  return {
    ...room,
    vertices: room.vertices.map((v) => Vec2.add(v, delta)),
    sockets: room.sockets.map((s) => ({ ...s, pos: Vec2.add(s.pos, delta) })),
    position: Vec2.add(room.position || { x: 0, y: 0 }, delta),
  };
}

// ============================================================
// COLLISION — Direct edge-based detection
// ============================================================

function getCentroid(verts) {
  const n = verts.length;
  return { x: verts.reduce((s, v) => s + v.x, 0) / n, y: verts.reduce((s, v) => s + v.y, 0) / n };
}

function getBBox(verts) {
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
  for (const v of verts) {
    if (v.x < minX) minX = v.x; if (v.x > maxX) maxX = v.x;
    if (v.y < minY) minY = v.y; if (v.y > maxY) maxY = v.y;
  }
  return { minX, maxX, minY, maxY };
}

function pointInPolygon(pt, verts) {
  let inside = false;
  for (let i = 0, j = verts.length - 1; i < verts.length; j = i++) {
    const xi = verts[i].x, yi = verts[i].y, xj = verts[j].x, yj = verts[j].y;
    if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi))
      inside = !inside;
  }
  return inside;
}

function segmentsIntersect(p1, p2, p3, p4) {
  const d1 = Vec2.sub(p2, p1), d2 = Vec2.sub(p4, p3);
  const denom = Vec2.cross(d1, d2);
  if (Math.abs(denom) < 1e-10) return false;
  const d3 = Vec2.sub(p3, p1);
  const t = Vec2.cross(d3, d2) / denom;
  const u = Vec2.cross(d3, d1) / denom;
  return t > 0 && t < 1 && u > 0 && u < 1;
}

function distPointToSeg(p, a, b) {
  const ab = Vec2.sub(b, a), ap = Vec2.sub(p, a);
  const lenSq = Vec2.dot(ab, ab);
  if (lenSq < 1e-10) return Vec2.dist(p, a);
  const t = Math.max(0, Math.min(1, Vec2.dot(ap, ab) / lenSq));
  return Vec2.dist(p, Vec2.add(a, Vec2.scale(ab, t)));
}

function distSegToSeg(a1, a2, b1, b2) {
  if (segmentsIntersect(a1, a2, b1, b2)) return 0;
  return Math.min(distPointToSeg(a1, b1, b2), distPointToSeg(a2, b1, b2),
    distPointToSeg(b1, a1, a2), distPointToSeg(b2, a1, a2));
}

function roomsCollide(a, b, margin = 16) {
  const ba = getBBox(a.vertices), bb = getBBox(b.vertices);
  if (ba.maxX + margin < bb.minX || bb.maxX + margin < ba.minX ||
      ba.maxY + margin < bb.minY || bb.maxY + margin < ba.minY) return false;
  const av = a.vertices, bv = b.vertices;
  for (const v of av) if (pointInPolygon(v, bv)) return true;
  for (const v of bv) if (pointInPolygon(v, av)) return true;
  for (let i = 0; i < av.length; i++) {
    const a1 = av[i], a2 = av[(i + 1) % av.length];
    for (let j = 0; j < bv.length; j++) {
      if (distSegToSeg(a1, a2, bv[j], bv[(j + 1) % bv.length]) < margin) return true;
    }
  }
  return false;
}

function roomsCollideLight(a, b) { return roomsCollide(a, b, 4); }

function pathCrossesRoom(from, to, room) {
  const v = room.vertices;
  for (let i = 0; i < v.length; i++)
    if (segmentsIntersect(from, to, v[i], v[(i + 1) % v.length])) return true;
  return false;
}

// ============================================================
// HELPER: seeded RNG + organic blob generator
// ============================================================

function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Convert any string to a 32-bit integer seed via DJB2 hash
function hashSeed(str) {
  let h = 5381;
  for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
  return h >>> 0;
}

function makeOrganicBlob(baseRadius, vertCount, jitter, seed) {
  const rng = mulberry32(seed);
  return Array.from({ length: vertCount }, (_, i) => {
    const a = (Math.PI * 2 * i) / vertCount;
    const r = baseRadius + (rng() - 0.5) * jitter * 2;
    return { x: r * Math.cos(a), y: r * Math.sin(a) };
  });
}

// ============================================================
// TILESET DEFINITIONS — Phantom Zenith Origin Stages
//
// Room fill colors use muted blues within the token palette:
//   large:    #1e3545  (brightest — draws the eye)
//   medium:   #1a2d3d  (default surface tone)
//   small:    #162535  (recedes slightly)
//   corridor: #112030  (darkest — reads as passage)
// ============================================================

function makeSocket(pos, normal, width = 30, id) {
  return { pos, normal: Vec2.normalize(normal), width, id, connected: false, isLoop: false };
}

// ---- STAGE 1: Infrastructure ----
const S1_ROOMS = [
  { name: "Pump Room", type: "small",
    vertices: [{ x: -40, y: -40 }, { x: 40, y: -40 }, { x: 40, y: 40 }, { x: -40, y: 40 }],
    sockets: [makeSocket({ x: 0, y: -40 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 40, y: 0 }, { x: 1, y: 0 }, 30, "e"),
      makeSocket({ x: 0, y: 40 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -40, y: 0 }, { x: -1, y: 0 }, 30, "w")],
    color: "#162535" },
  { name: "Maintenance Bay", type: "medium",
    vertices: [{ x: -60, y: -40 }, { x: 60, y: -40 }, { x: 60, y: 40 }, { x: -60, y: 40 }],
    sockets: [makeSocket({ x: -20, y: -40 }, { x: 0, y: -1 }, 30, "n1"), makeSocket({ x: 30, y: -40 }, { x: 0, y: -1 }, 30, "n2"),
      makeSocket({ x: 60, y: 0 }, { x: 1, y: 0 }, 30, "e"), makeSocket({ x: 0, y: 40 }, { x: 0, y: 1 }, 30, "s"),
      makeSocket({ x: -60, y: 0 }, { x: -1, y: 0 }, 30, "w")],
    color: "#1a2d3d" },
  { name: "Cistern", type: "large",
    vertices: [{ x: -80, y: -60 }, { x: 80, y: -60 }, { x: 80, y: 60 }, { x: -80, y: 60 }],
    sockets: [makeSocket({ x: -30, y: -60 }, { x: 0, y: -1 }, 30, "n1"), makeSocket({ x: 30, y: -60 }, { x: 0, y: -1 }, 30, "n2"),
      makeSocket({ x: 80, y: -20 }, { x: 1, y: 0 }, 30, "e1"), makeSocket({ x: 80, y: 20 }, { x: 1, y: 0 }, 30, "e2"),
      makeSocket({ x: 0, y: 60 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -80, y: 0 }, { x: -1, y: 0 }, 30, "w")],
    color: "#1e3545" },
  { name: "Drain Corridor", type: "corridor",
    vertices: [{ x: -70, y: -18 }, { x: 70, y: -18 }, { x: 70, y: 18 }, { x: -70, y: 18 }],
    sockets: [makeSocket({ x: -70, y: 0 }, { x: -1, y: 0 }, 28, "w"), makeSocket({ x: 70, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: -18 }, { x: 0, y: -1 }, 28, "n")],
    color: "#112030" },
  { name: "L-Junction", type: "medium",
    vertices: [{ x: -50, y: -50 }, { x: 20, y: -50 }, { x: 20, y: -10 }, { x: 50, y: -10 }, { x: 50, y: 50 }, { x: -50, y: 50 }],
    sockets: [makeSocket({ x: -15, y: -50 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 50, y: 20 }, { x: 1, y: 0 }, 30, "e"),
      makeSocket({ x: 0, y: 50 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -50, y: 0 }, { x: -1, y: 0 }, 30, "w")],
    color: "#1a2d3d" },
  { name: "T-Junction", type: "medium",
    vertices: [{ x: -60, y: -20 }, { x: 60, y: -20 }, { x: 60, y: 20 }, { x: 20, y: 20 }, { x: 20, y: 60 }, { x: -20, y: 60 }, { x: -20, y: 20 }, { x: -60, y: 20 }],
    sockets: [makeSocket({ x: -60, y: 0 }, { x: -1, y: 0 }, 28, "w"), makeSocket({ x: 60, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: 60 }, { x: 0, y: 1 }, 28, "s")],
    color: "#112030" },
];

// ---- STAGE 2: Dereliction ----
const S2_ROOMS = [
  { name: "Equipment Yard", type: "large",
    vertices: [{ x: -85, y: -50 }, { x: 85, y: -55 }, { x: 80, y: 55 }, { x: -80, y: 50 }],
    sockets: [makeSocket({ x: -30, y: -52 }, { x: 0, y: -1 }, 30, "n1"), makeSocket({ x: 30, y: -54 }, { x: 0, y: -1 }, 30, "n2"),
      makeSocket({ x: 83, y: 0 }, { x: 1, y: 0 }, 30, "e"), makeSocket({ x: 0, y: 53 }, { x: 0, y: 1 }, 30, "s"),
      makeSocket({ x: -83, y: 0 }, { x: -1, y: 0 }, 30, "w")],
    color: "#1e3545" },
  { name: "Processing Ruin", type: "medium",
    vertices: [{ x: -55, y: -38 }, { x: 50, y: -42 }, { x: 55, y: -10 }, { x: 48, y: 40 }, { x: -50, y: 38 }],
    sockets: [makeSocket({ x: 0, y: -40 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 52, y: -26 }, { x: 1, y: -0.3 }, 28, "ne"),
      makeSocket({ x: 0, y: 39 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -53, y: 0 }, { x: -1, y: 0 }, 28, "w")],
    color: "#1a2d3d" },
  { name: "Extraction Pit", type: "large",
    vertices: [{ x: -70, y: -55 }, { x: -20, y: -65 }, { x: 40, y: -55 }, { x: 70, y: -30 },
      { x: 65, y: 30 }, { x: 40, y: 60 }, { x: -30, y: 55 }, { x: -65, y: 25 }],
    sockets: [makeSocket({ x: 10, y: -60 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 68, y: 0 }, { x: 1, y: 0 }, 30, "e"),
      makeSocket({ x: 5, y: 58 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -68, y: -15 }, { x: -1, y: 0 }, 30, "w")],
    color: "#1e3545" },
  { name: "Dry Riverbed", type: "corridor",
    vertices: [{ x: -75, y: -14 }, { x: -40, y: -20 }, { x: 0, y: -12 }, { x: 40, y: -18 },
      { x: 75, y: -10 }, { x: 78, y: 6 }, { x: 40, y: 18 }, { x: 0, y: 12 },
      { x: -40, y: 20 }, { x: -75, y: 14 }],
    sockets: [makeSocket({ x: -75, y: 0 }, { x: -1, y: 0 }, 24, "w"), makeSocket({ x: 77, y: -2 }, { x: 1, y: 0 }, 24, "e")],
    color: "#112030" },
  { name: "Dust Hollow", type: "small",
    vertices: makeOrganicBlob(36, 8, 10, 2001),
    sockets: [makeSocket({ x: 0, y: -35 }, { x: 0, y: -1 }, 26, "n"), makeSocket({ x: 35, y: 0 }, { x: 1, y: 0 }, 26, "e"),
      makeSocket({ x: 0, y: 35 }, { x: 0, y: 1 }, 26, "s")],
    color: "#162535" },
  { name: "Canyon Passage", type: "corridor",
    vertices: [{ x: -65, y: -22 }, { x: -30, y: -16 }, { x: 30, y: -28 }, { x: 65, y: -20 },
      { x: 68, y: 0 }, { x: 30, y: 18 }, { x: -30, y: 24 }, { x: -65, y: 18 }],
    sockets: [makeSocket({ x: -65, y: -2 }, { x: -1, y: 0 }, 26, "w"), makeSocket({ x: 67, y: -10 }, { x: 1, y: 0 }, 26, "e"),
      makeSocket({ x: 0, y: -22 }, { x: 0, y: -1 }, 22, "n")],
    color: "#112030" },
];

// ---- STAGE 3: The Chasm ----
const S3_ROOMS = [
  { name: "Grand Cavern", type: "large", vertices: makeOrganicBlob(70, 14, 20, 42),
    sockets: [makeSocket({ x: 0, y: -68 }, { x: 0, y: -1 }, 32, "n"), makeSocket({ x: 65, y: 10 }, { x: 1, y: 0 }, 32, "e"),
      makeSocket({ x: -10, y: 65 }, { x: 0, y: 1 }, 32, "s"), makeSocket({ x: -65, y: -5 }, { x: -1, y: 0 }, 32, "w")],
    color: "#1e3545" },
  { name: "Grotto", type: "small", vertices: makeOrganicBlob(35, 10, 12, 77),
    sockets: [makeSocket({ x: 0, y: -34 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 34, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: 34 }, { x: 0, y: 1 }, 28, "s")],
    color: "#162535" },
  { name: "Luminous Chamber", type: "medium", vertices: makeOrganicBlob(52, 12, 16, 123),
    sockets: [makeSocket({ x: 5, y: -50 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 50, y: 5 }, { x: 1, y: 0 }, 30, "e"),
      makeSocket({ x: -5, y: 50 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -50, y: -5 }, { x: -1, y: 0 }, 30, "w")],
    color: "#1a2d3d" },
  { name: "Winding Descent", type: "corridor",
    vertices: [{ x: -65, y: -16 }, { x: -30, y: -20 }, { x: 0, y: -14 }, { x: 30, y: -18 }, { x: 65, y: -14 }, { x: 68, y: 0 },
      { x: 65, y: 14 }, { x: 30, y: 18 }, { x: 0, y: 14 }, { x: -30, y: 20 }, { x: -65, y: 16 }, { x: -68, y: 0 }],
    sockets: [makeSocket({ x: -68, y: 0 }, { x: -1, y: 0 }, 26, "w"), makeSocket({ x: 68, y: 0 }, { x: 1, y: 0 }, 26, "e")],
    color: "#112030" },
  { name: "Deep Crevice", type: "medium", vertices: makeOrganicBlob(48, 16, 18, 256),
    sockets: [makeSocket({ x: 0, y: -46 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 46, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: 46 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -46, y: 0 }, { x: -1, y: 0 }, 28, "w")],
    color: "#1a2d3d" },
  { name: "Spore Den", type: "small", vertices: makeOrganicBlob(38, 11, 14, 512),
    sockets: [makeSocket({ x: 0, y: -36 }, { x: 0, y: -1 }, 26, "n"), makeSocket({ x: 0, y: 36 }, { x: 0, y: 1 }, 26, "s"),
      makeSocket({ x: 36, y: 0 }, { x: 1, y: 0 }, 26, "e")],
    color: "#162535" },
];

// ---- STAGE 4: Mines (The Connective Tissue) ----
const S4_ROOMS = [
  { name: "Foundation Vault", type: "large",
    vertices: (() => {
      const r = 68;
      return Array.from({ length: 6 }, (_, i) => {
        const a = (Math.PI / 3) * i - Math.PI / 6;
        const wobble = i % 2 === 0 ? 0 : -8;
        return { x: (r + wobble) * Math.cos(a), y: (r + wobble) * Math.sin(a) };
      });
    })(),
    sockets: [makeSocket({ x: 0, y: -64 }, { x: 0, y: -1 }, 30, "n"),
      makeSocket({ x: 58, y: -30 }, Vec2.normalize({ x: 1, y: -0.5 }), 30, "ne"),
      makeSocket({ x: 58, y: 30 }, Vec2.normalize({ x: 1, y: 0.5 }), 30, "se"),
      makeSocket({ x: 0, y: 64 }, { x: 0, y: 1 }, 30, "s"),
      makeSocket({ x: -58, y: 30 }, Vec2.normalize({ x: -1, y: 0.5 }), 30, "sw"),
      makeSocket({ x: -58, y: -30 }, Vec2.normalize({ x: -1, y: -0.5 }), 30, "nw")],
    color: "#1e3545" },
  { name: "Carved Chamber", type: "medium",
    vertices: [{ x: -45, y: -50 }, { x: 45, y: -50 }, { x: 55, y: 0 }, { x: 45, y: 50 }, { x: -45, y: 50 }, { x: -55, y: 0 }],
    sockets: [makeSocket({ x: 0, y: -50 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 55, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: 50 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -55, y: 0 }, { x: -1, y: 0 }, 28, "w")],
    color: "#1a2d3d" },
  { name: "Inscription Hall", type: "large",
    vertices: [{ x: -85, y: -35 }, { x: 85, y: -35 }, { x: 80, y: 35 }, { x: -80, y: 35 }],
    sockets: [makeSocket({ x: -30, y: -35 }, { x: 0, y: -1 }, 28, "n1"), makeSocket({ x: 30, y: -35 }, { x: 0, y: -1 }, 28, "n2"),
      makeSocket({ x: 83, y: 0 }, { x: 1, y: 0 }, 28, "e"), makeSocket({ x: 0, y: 35 }, { x: 0, y: 1 }, 28, "s"),
      makeSocket({ x: -83, y: 0 }, { x: -1, y: 0 }, 28, "w")],
    color: "#1e3545" },
  { name: "Ancient Passage", type: "corridor",
    vertices: [{ x: -72, y: -16 }, { x: -40, y: -20 }, { x: 0, y: -15 }, { x: 40, y: -20 },
      { x: 72, y: -16 }, { x: 72, y: 16 }, { x: 40, y: 20 }, { x: 0, y: 15 },
      { x: -40, y: 20 }, { x: -72, y: 16 }],
    sockets: [makeSocket({ x: -72, y: 0 }, { x: -1, y: 0 }, 26, "w"), makeSocket({ x: 72, y: 0 }, { x: 1, y: 0 }, 26, "e"),
      makeSocket({ x: 0, y: -15 }, { x: 0, y: -1 }, 22, "n")],
    color: "#112030" },
  { name: "Contaminant Pool", type: "medium", vertices: makeOrganicBlob(46, 12, 15, 3001),
    sockets: [makeSocket({ x: 0, y: -44 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 44, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: 44 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -44, y: 0 }, { x: -1, y: 0 }, 28, "w")],
    color: "#1a2d3d" },
  { name: "Seep Node", type: "small", vertices: makeOrganicBlob(34, 9, 12, 3333),
    sockets: [makeSocket({ x: 0, y: -32 }, { x: 0, y: -1 }, 26, "n"),
      makeSocket({ x: 32, y: 0 }, { x: 1, y: 0 }, 26, "e"),
      makeSocket({ x: 0, y: 32 }, { x: 0, y: 1 }, 26, "s")],
    color: "#162535" },
];

// ---- STAGE 5: The Heart ----
const S5_ROOMS = [
  { name: "Core Nexus", type: "large",
    vertices: (() => { const r = 65; return Array.from({ length: 8 }, (_, i) => ({ x: r * Math.cos((Math.PI / 4) * i - Math.PI / 8), y: r * Math.sin((Math.PI / 4) * i - Math.PI / 8) })); })(),
    sockets: [makeSocket({ x: 0, y: -63 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 45, y: -45 }, Vec2.normalize({ x: 1, y: -1 }), 30, "ne"),
      makeSocket({ x: 63, y: 0 }, { x: 1, y: 0 }, 30, "e"), makeSocket({ x: 45, y: 45 }, Vec2.normalize({ x: 1, y: 1 }), 30, "se"),
      makeSocket({ x: 0, y: 63 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -45, y: 45 }, Vec2.normalize({ x: -1, y: 1 }), 30, "sw"),
      makeSocket({ x: -63, y: 0 }, { x: -1, y: 0 }, 30, "w"), makeSocket({ x: -45, y: -45 }, Vec2.normalize({ x: -1, y: -1 }), 30, "nw")],
    color: "#1e3545" },
  { name: "Wound Node", type: "small",
    vertices: [{ x: -20, y: -35 }, { x: 20, y: -35 }, { x: 38, y: 0 }, { x: 20, y: 35 }, { x: -20, y: 35 }, { x: -38, y: 0 }],
    sockets: [makeSocket({ x: 0, y: -35 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 38, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: 35 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -38, y: 0 }, { x: -1, y: 0 }, 28, "w")],
    color: "#162535" },
  { name: "Impact Chamber", type: "medium",
    vertices: [{ x: 0, y: -55 }, { x: 55, y: 0 }, { x: 0, y: 55 }, { x: -55, y: 0 }],
    sockets: [makeSocket({ x: 0, y: -55 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 55, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: 55 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -55, y: 0 }, { x: -1, y: 0 }, 28, "w")],
    color: "#1a2d3d" },
  { name: "Scar Bridge", type: "corridor",
    vertices: [{ x: -80, y: -25 }, { x: -55, y: -12 }, { x: 55, y: -12 }, { x: 80, y: -25 }, { x: 80, y: 25 }, { x: 55, y: 12 }, { x: -55, y: 12 }, { x: -80, y: 25 }],
    sockets: [makeSocket({ x: -80, y: 0 }, { x: -1, y: 0 }, 28, "w"), makeSocket({ x: 80, y: 0 }, { x: 1, y: 0 }, 28, "e")],
    color: "#112030" },
  { name: "Resonance Node", type: "medium",
    vertices: (() => { const r = 45; return Array.from({ length: 8 }, (_, i) => ({ x: r * Math.cos((Math.PI / 4) * i - Math.PI / 8), y: r * Math.sin((Math.PI / 4) * i - Math.PI / 8) })); })(),
    sockets: [makeSocket({ x: 0, y: -43 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 43, y: 0 }, { x: 1, y: 0 }, 28, "e"),
      makeSocket({ x: 0, y: 43 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -43, y: 0 }, { x: -1, y: 0 }, 28, "w")],
    color: "#1a2d3d" },
  { name: "Fracture Path", type: "corridor",
    vertices: [{ x: -60, y: -15 }, { x: -10, y: -15 }, { x: 60, y: -50 }, { x: 68, y: -38 }, { x: 10, y: 2 }, { x: -60, y: 2 }],
    sockets: [makeSocket({ x: -60, y: -7 }, { x: -1, y: 0 }, 15, "w"), makeSocket({ x: 64, y: -44 }, Vec2.normalize({ x: 1, y: -1 }), 15, "ne")],
    color: "#112030" },
];

// ---- STAGE REGISTRY ----
const STAGES = [
  { key: "s1", number: "01", name: "Infrastructure", subtitle: "Sewers · Maintenance · Drainage", rooms: S1_ROOMS },
  { key: "s2", number: "02", name: "Dereliction",    subtitle: "Dust Flats · Canyons · Ruins",    rooms: S2_ROOMS },
  { key: "s3", number: "03", name: "The Chasm",      subtitle: "Canyon Rim · Flooded Depths",     rooms: S3_ROOMS },
  { key: "s4", number: "04", name: "Mines",           subtitle: "Sub-Geological · Pre-City",       rooms: S4_ROOMS },
  { key: "s5", number: "05", name: "The Heart",       subtitle: "Impact Site · The Wound",         rooms: S5_ROOMS },
];

const STAGE_MAP = {};
STAGES.forEach((s) => { STAGE_MAP[s.key] = s; });

// ============================================================
// LEVEL GENERATOR
// ============================================================

function generateLevel(stage, roomCount, seed, pathsToExit = 1, weights = null) {
  const rng = mulberry32(seed);
  const templates = stage.rooms;
  const placed = [];
  const allSockets = [];
  const connections = [];

  const pick = (arr) => arr[Math.floor(rng() * arr.length)];

  const w = weights || templates.map(() => 1);
  function pickTemplate() {
    const total = w.reduce((s, v) => s + v, 0);
    if (total <= 0) return pick(templates);
    let r = rng() * total;
    for (let i = 0; i < templates.length; i++) { r -= w[i]; if (r <= 0) return templates[i]; }
    return templates[templates.length - 1];
  }
  function pickTemplateExcluding(type) {
    const filtered = templates.map((t, i) => ({ t, w: w[i] })).filter((x) => x.t.type !== type);
    const total = filtered.reduce((s, x) => s + x.w, 0);
    if (total <= 0) return pick(templates.filter((t) => t.type !== type));
    let r = rng() * total;
    for (const x of filtered) { r -= x.w; if (r <= 0) return x.t; }
    return filtered[filtered.length - 1].t;
  }

  const spineAngle = (rng() - 0.5) * Math.PI * 0.6;
  const spineDir = { x: Math.cos(spineAngle), y: Math.sin(spineAngle) };

  function tryPlaceRoom(srcEntry, roleTag) {
    const srcSocket = srcEntry.socket;
    if (srcSocket.connected) return null;

    // Determine if corridors are blocked for this placement.
    // Rule: no chain of 3+ corridors. If the parent is a corridor
    // that already connects to another corridor, block corridors here.
    let blockCorridor = false;
    const parentRoom = placed[srcEntry.roomIndex];
    if (parentRoom && parentRoom.type === "corridor") {
      for (const conn of connections) {
        const neighborIdx = conn.fromRoom === srcEntry.roomIndex ? conn.toRoom :
                            conn.toRoom === srcEntry.roomIndex ? conn.fromRoom : -1;
        if (neighborIdx >= 0 && placed[neighborIdx] && placed[neighborIdx].type === "corridor") {
          blockCorridor = true; break;
        }
      }
    }

    for (let attempt = 0; attempt < 20; attempt++) {
      const template = blockCorridor ? pickTemplateExcluding("corridor") : pickTemplate();
      const newRoom = { ...template, vertices: [...template.vertices], sockets: template.sockets.map((s) => ({ ...s })) };
      const tgtIdx = Math.floor(rng() * newRoom.sockets.length);
      const tgtSocket = newRoom.sockets[tgtIdx];
      const srcA = Math.atan2(srcSocket.normal.y, srcSocket.normal.x);
      const tgtA = Math.atan2(tgtSocket.normal.y, tgtSocket.normal.x);
      let rotation = srcA - tgtA + Math.PI + (rng() - 0.5) * 0.07;
      const rotTgt = Vec2.rotate(tgtSocket.pos, rotation);
      const offset = Vec2.add(Vec2.sub(srcSocket.pos, rotTgt), Vec2.scale(srcSocket.normal, 14));
      const transformed = transformRoom(newRoom, rotation, offset);

      let collides = false;
      for (const ex of placed) {
        if (ex.id === srcEntry.roomIndex) continue;
        if (roomsCollide(transformed, ex)) { collides = true; break; }
      }

      if (!collides) {
        transformed.id = placed.length;
        transformed.templateName = template.name;
        transformed.role = roleTag;
        placed.push(transformed);
        srcSocket.connected = true;
        transformed.sockets[tgtIdx].connected = true;
        connections.push({ fromRoom: srcEntry.roomIndex, toRoom: transformed.id,
          fromSocketPos: { ...srcSocket.pos }, toSocketPos: { ...transformed.sockets[tgtIdx].pos },
          isLoop: false, role: roleTag });
        if (!srcSocket.connectedTo) srcSocket.connectedTo = [];
        srcSocket.connectedTo.push({ roomId: transformed.id });
        transformed.sockets[tgtIdx].connectedTo = [{ roomId: srcEntry.roomIndex }];
        transformed.sockets.forEach((s, i) => allSockets.push({ roomIndex: transformed.id, socketIndex: i, socket: s }));
        return transformed;
      }
    }
    return null;
  }

  function bestSocketForDir(roomIdx, dir) {
    const cands = allSockets.filter((s) => s.roomIndex === roomIdx && !s.socket.connected);
    if (cands.length === 0) return null;
    const scores = cands.map((c) => Vec2.dot(c.socket.normal, dir));
    let best = 0;
    for (let i = 1; i < scores.length; i++) if (scores[i] > scores[best]) best = i;
    if (cands.length > 1 && rng() < 0.25) best = Math.floor(rng() * cands.length);
    return cands[best];
  }

  // Phase 1: Spine
  const spineTarget = Math.max(4, Math.floor(roomCount * 0.4));
  const startTpl = pickTemplateExcluding("corridor");
  const startRoom = transformRoom({ ...startTpl, sockets: startTpl.sockets.map((s) => ({ ...s })) }, 0, { x: 0, y: 0 });
  startRoom.id = 0; startRoom.templateName = startTpl.name; startRoom.role = "start";
  placed.push(startRoom);
  startRoom.sockets.forEach((s, i) => allSockets.push({ roomIndex: 0, socketIndex: i, socket: s }));

  let spineRoomIds = [0], curSpine = 0;
  for (let i = 1; i < spineTarget; i++) {
    const se = bestSocketForDir(curSpine, spineDir);
    if (!se) break;
    const nr = tryPlaceRoom(se, "spine");
    if (nr) { spineRoomIds.push(nr.id); curSpine = nr.id; }
    else { const fb = allSockets.find((s) => s.roomIndex === curSpine && !s.socket.connected);
      if (fb) { const r2 = tryPlaceRoom(fb, "spine"); if (r2) { spineRoomIds.push(r2.id); curSpine = r2.id; } else break; } else break; }
  }

  let exitRoomId = spineRoomIds[spineRoomIds.length - 1];

  // Rule: Exit room cannot be a corridor. If it is, try to extend
  // the spine by one more non-corridor room. If that fails, walk
  // backward through the spine to find the last non-corridor.
  if (placed[exitRoomId].type === "corridor") {
    // Try extending from the corridor's open sockets
    const extSocket = allSockets.find((s) => s.roomIndex === exitRoomId && !s.socket.connected);
    let extended = false;
    if (extSocket) {
      // Temporarily force non-corridor selection
      const origWeights = [...w];
      templates.forEach((t, i) => { if (t.type === "corridor") w[i] = 0; });
      const ext = tryPlaceRoom(extSocket, "spine");
      templates.forEach((_, i) => { w[i] = origWeights[i]; });
      if (ext) {
        spineRoomIds.push(ext.id);
        exitRoomId = ext.id;
        extended = true;
      }
    }
    // If extension failed, walk back to last non-corridor on spine
    if (!extended) {
      for (let i = spineRoomIds.length - 1; i >= 0; i--) {
        if (placed[spineRoomIds[i]].type !== "corridor") {
          exitRoomId = spineRoomIds[i]; break;
        }
      }
    }
  }

  placed[exitRoomId].role = "exit";
  const exitPos = getCentroid(placed[exitRoomId].vertices);

  // Phase 2: Branch paths
  const numBranches = Math.max(0, pathsToExit - 1);
  const branchPaths = [];
  for (let b = 0; b < numBranches; b++) {
    const minI = Math.max(1, Math.floor(spineRoomIds.length * 0.25));
    const maxI = Math.max(minI + 1, Math.floor(spineRoomIds.length * 0.75));
    const oIdx = minI + Math.floor(rng() * (maxI - minI));
    const oRoom = spineRoomIds[Math.min(oIdx, spineRoomIds.length - 1)];
    const budget = Math.max(3, Math.floor((roomCount - placed.length) * 0.4 / Math.max(1, numBranches - b)));
    const bIds = [];
    let cur = oRoom;
    for (let i = 0; i < budget; i++) {
      const cp = getCentroid(placed[cur].vertices);
      const toEx = Vec2.normalize(Vec2.sub(exitPos, cp));
      const bDir = Vec2.normalize(Vec2.add(Vec2.scale(toEx, 0.7), Vec2.scale(spineDir, 0.3)));
      const se = bestSocketForDir(cur, bDir);
      if (!se) break;
      const nr = tryPlaceRoom(se, "branch");
      if (nr) { bIds.push(nr.id); cur = nr.id; } else break;
    }
    branchPaths.push({ originRoomId: oRoom, roomIds: bIds });
  }

  // Phase 3: Fill
  let fillAtt = 0;
  const maxFill = (roomCount - placed.length) * 60;
  while (placed.length < roomCount && fillAtt < maxFill) {
    fillAtt++;
    const open = allSockets.filter((s) => !s.socket.connected);
    if (open.length === 0) break;
    tryPlaceRoom(pick(open), "fill");
  }

  // Phase 3.5: Remove orphan corridors (iterative)
  // Corridors are passages, not destinations. A corridor with only
  // one connection is a dead end — structurally wrong. Remove them
  // and free the parent socket so something else can use it.
  // Iterate because removing one corridor can orphan the next one
  // in a chain (e.g., Room → Corridor A → Corridor B: removing B
  // makes A a new orphan).
  let prunedCount = 0;
  {
    let changed = true;
    while (changed) {
      changed = false;
      const removeIds = new Set();
      for (let i = 0; i < placed.length; i++) {
        const room = placed[i];
        if (room.type !== "corridor") continue;
        if (room.role === "start" || room.role === "exit") continue;
        const connCount = room.sockets.filter((s) => s.connected).length;
        if (connCount <= 1) removeIds.add(i);
      }

      if (removeIds.size === 0) break;
      changed = true;
      prunedCount += removeIds.size;

      // Disconnect parent sockets that link to removed rooms
      for (const rid of removeIds) {
        for (const room of placed) {
          if (!room || removeIds.has(room.id)) continue;
          for (const s of room.sockets) {
            if (s.connectedTo && s.connectedTo.some((ct) => ct.roomId === rid)) {
              s.connectedTo = s.connectedTo.filter((ct) => ct.roomId !== rid);
              if (s.connectedTo.length === 0) { s.connected = false; s.isLoop = false; }
            }
          }
        }
      }

      // Build remap: old ID → new ID
      const idMap = {};
      let newIdx = 0;
      for (let i = 0; i < placed.length; i++) {
        if (!removeIds.has(i)) { idMap[i] = newIdx; newIdx++; }
      }

      // Compact placed array
      const kept = placed.filter((_, i) => !removeIds.has(i));
      kept.forEach((r, i) => { r.id = i; });
      placed.length = 0; placed.push(...kept);

      // Remap connections
      const keptConns = connections.filter((c) => !removeIds.has(c.fromRoom) && !removeIds.has(c.toRoom));
      keptConns.forEach((c) => { c.fromRoom = idMap[c.fromRoom]; c.toRoom = idMap[c.toRoom]; });
      connections.length = 0; connections.push(...keptConns);

      // Remap allSockets
      const keptSocks = allSockets.filter((s) => !removeIds.has(s.roomIndex));
      keptSocks.forEach((s) => { s.roomIndex = idMap[s.roomIndex]; });
      allSockets.length = 0; allSockets.push(...keptSocks);

      // Remap connectedTo references inside room sockets
      for (const room of placed) {
        for (const s of room.sockets) {
          if (s.connectedTo) {
            s.connectedTo = s.connectedTo.map((ct) => ({ ...ct, roomId: idMap[ct.roomId] }));
          }
        }
      }

      // Remap spine and branch tracking
      spineRoomIds = spineRoomIds.filter((id) => !removeIds.has(id)).map((id) => idMap[id]);
      exitRoomId = idMap[exitRoomId] ?? exitRoomId;
      for (const bp of branchPaths) {
        bp.originRoomId = idMap[bp.originRoomId] ?? bp.originRoomId;
        bp.roomIds = bp.roomIds.filter((id) => !removeIds.has(id)).map((id) => idMap[id]);
      }
    }
  }

  // Ensure exit role is set after potential removals
  placed[exitRoomId].role = "exit";

  // Phase 4: Loop connections
  const MAX_LOOP_DIST = 45, NUDGE_DIST = 40, NORMAL_THRESH = -0.5, MAX_NUDGE = 15;
  const loopCands = allSockets.filter((s) => !s.socket.connected);
  const pairs = [];
  const usedSocks = new Set(), loopPairRooms = new Set();

  for (let i = 0; i < loopCands.length; i++) {
    for (let j = i + 1; j < loopCands.length; j++) {
      const a = loopCands[i], b = loopCands[j];
      if (a.roomIndex === b.roomIndex) continue;
      const dist = Vec2.dist(a.socket.pos, b.socket.pos);
      if (dist > NUDGE_DIST) continue;
      const nd = Vec2.dot(a.socket.normal, b.socket.normal);
      if (nd > NORMAL_THRESH) continue;
      const ds = 1 - dist / NUDGE_DIST;
      const ns = (-nd - 0.5) / 0.5;
      pairs.push({ a, b, dist, quality: ds * 0.4 + Math.max(0, ns) * 0.6 });
    }
  }
  pairs.sort((x, y) => y.quality - x.quality);

  for (const pair of pairs) {
    const { a, b } = pair;
    const aK = `${a.roomIndex}:${a.socketIndex}`, bK = `${b.roomIndex}:${b.socketIndex}`;
    if (usedSocks.has(aK) || usedSocks.has(bK) || a.socket.connected || b.socket.connected) continue;
    const rpK = Math.min(a.roomIndex, b.roomIndex) + ":" + Math.max(a.roomIndex, b.roomIndex);
    if (loopPairRooms.has(rpK)) continue;

    let dist = Vec2.dist(a.socket.pos, b.socket.pos);
    if (dist > MAX_LOOP_DIST) {
      const aCn = placed[a.roomIndex].sockets.filter((s) => s.connected).length;
      const bCn = placed[b.roomIndex].sockets.filter((s) => s.connected).length;
      const aOk = placed[a.roomIndex].role !== "start" && placed[a.roomIndex].role !== "exit";
      const bOk = placed[b.roomIndex].role !== "start" && placed[b.roomIndex].role !== "exit";
      let mover = -1;
      if (aOk && bOk) mover = aCn <= bCn ? a.roomIndex : b.roomIndex;
      else if (aOk) mover = a.roomIndex; else if (bOk) mover = b.roomIndex; else continue;
      const target = mover === a.roomIndex ? b.socket.pos : a.socket.pos;
      const mSock = mover === a.roomIndex ? a.socket : b.socket;
      const gap = Vec2.sub(target, mSock.pos);
      const needed = Math.max(0, Vec2.length(gap) - 8);
      const delta = Vec2.scale(Vec2.normalize(gap), Math.min(needed, MAX_NUDGE));
      const nudged = nudgeRoom(placed[mover], delta);
      let bad = false;
      for (let r = 0; r < placed.length; r++) { if (r === mover) continue; if (roomsCollideLight(nudged, placed[r])) { bad = true; break; } }
      if (bad) continue;
      placed[mover] = nudged;
      for (const se of allSockets) { if (se.roomIndex === mover) se.socket = nudged.sockets[se.socketIndex]; }
      for (const cn of connections) { if (cn.fromRoom === mover) cn.fromSocketPos = Vec2.add(cn.fromSocketPos, delta); if (cn.toRoom === mover) cn.toSocketPos = Vec2.add(cn.toSocketPos, delta); }
    }

    const pA = a.socket.pos, pB = b.socket.pos;
    if (Vec2.dist(pA, pB) > MAX_LOOP_DIST) continue;
    let blocked = false;
    for (let r = 0; r < placed.length; r++) { if (r === a.roomIndex || r === b.roomIndex) continue; if (pathCrossesRoom(pA, pB, placed[r])) { blocked = true; break; } }
    if (blocked) continue;
    if (pointInPolygon(pA, placed[b.roomIndex].vertices) || pointInPolygon(pB, placed[a.roomIndex].vertices)) continue;

    a.socket.connected = true; a.socket.isLoop = true;
    b.socket.connected = true; b.socket.isLoop = true;
    if (!a.socket.connectedTo) a.socket.connectedTo = [];
    if (!b.socket.connectedTo) b.socket.connectedTo = [];
    a.socket.connectedTo.push({ roomId: b.roomIndex, isLoop: true });
    b.socket.connectedTo.push({ roomId: a.roomIndex, isLoop: true });
    connections.push({ fromRoom: a.roomIndex, toRoom: b.roomIndex,
      fromSocketPos: { ...pA }, toSocketPos: { ...pB }, isLoop: true, role: "loop" });
    usedSocks.add(aK); usedSocks.add(bK); loopPairRooms.add(rpK);
  }

  return { rooms: placed, connections, spineRoomIds, exitRoomId, branchPaths, spineDir, prunedCount };
}

// ============================================================
// RENDERING
// ============================================================

function RoomPolygon({ room, isHovered, onHover, onLeave }) {
  const points = room.vertices.map((v) => `${v.x},${v.y}`).join(" ");
  const center = getCentroid(room.vertices);
  const isStart = room.role === "start", isExit = room.role === "exit";
  const isSpine = room.role === "spine" || isStart || isExit, isBranch = room.role === "branch";

  let fill = room.color, stroke = T.cyan, fOp = 0.45, sW = 1.2;
  if (isHovered) { fill = T.cyan; fOp = 0.5; stroke = T.textHi; sW = 2; }
  else if (isStart) { fill = T.teal; fOp = 0.3; stroke = T.teal; sW = 2; }
  else if (isExit) { fill = T.fuchsia; fOp = 0.25; stroke = T.fuchsia; sW = 2; }
  else if (isSpine) { fill = T.teal; fOp = 0.15; stroke = T.teal; sW = 1.5; }
  else if (isBranch) { fill = T.orange; fOp = 0.12; stroke = T.orange; sW = 1.2; }
  else { stroke = T.cyanDim; }

  return (
    <g onMouseEnter={() => onHover(room.id)} onMouseLeave={onLeave}>
      <polygon points={points} fill={fill} fillOpacity={fOp} stroke={stroke} strokeWidth={sW}
        strokeOpacity={isHovered ? 1 : 0.7} style={{ transition: "all 0.15s ease", cursor: "pointer" }} />
      <polygon points={points} fill="none" stroke={T.cyanDim} strokeWidth={0.5} strokeOpacity={0.08} strokeDasharray="4 4" />
      {(isStart || isExit) && <>
        <circle cx={center.x} cy={center.y} r={isHovered ? 14 : 12} fill="none"
          stroke={isStart ? T.teal : T.fuchsia} strokeWidth={2} opacity={0.5} strokeDasharray="4 3" />
        <text x={center.x} y={center.y - 16} textAnchor="middle" fill={isStart ? T.teal : T.fuchsia}
          fontSize={8} fontWeight={700} fontFamily="inherit" style={{ pointerEvents: "none" }}>
          {isStart ? "▶ START" : "◼ EXIT"}</text>
      </>}
      <text x={center.x} y={center.y - (isStart || isExit ? 0 : 6)} textAnchor="middle"
        fill={isHovered ? T.textHi : T.textMid} fillOpacity={isHovered ? 1 : 0.7}
        fontSize={isHovered ? 9 : 7} fontFamily="inherit" fontWeight={isHovered ? 700 : 400}
        style={{ transition: "all 0.15s ease", pointerEvents: "none" }}>{room.templateName}</text>
      <text x={center.x} y={center.y + (isStart || isExit ? 8 : 6)} textAnchor="middle"
        fill={T.textLo} fillOpacity={isHovered ? 0.8 : 0.4} fontSize={6}
        fontFamily="inherit" style={{ pointerEvents: "none" }}>#{room.id}</text>
      {room.sockets.map((s, i) => (
        <g key={i}>
          <circle cx={s.pos.x} cy={s.pos.y} r={s.connected ? 3.5 : 2.5}
            fill={s.isLoop ? T.fuchsia : s.connected ? T.cyan : T.textLo}
            stroke={s.isLoop ? T.textHi : s.connected ? T.textHi : T.textLo}
            strokeWidth={s.isLoop ? 1 : 0.6} opacity={isHovered ? 1 : 0.6} />
          {isHovered && !s.connected && <line x1={s.pos.x} y1={s.pos.y}
            x2={s.pos.x + s.normal.x * 12} y2={s.pos.y + s.normal.y * 12}
            stroke={T.orange} strokeWidth={1} opacity={0.6} strokeDasharray="2 2" />}
          {s.isLoop && <circle cx={s.pos.x} cy={s.pos.y} r={6} fill="none"
            stroke={T.fuchsia} strokeWidth={0.8} opacity={0.5} strokeDasharray="3 2" />}
        </g>
      ))}
    </g>
  );
}

function ConnectionLines({ connections }) {
  return (<>{connections.map((c, i) => {
    if (c.isLoop) {
      const mid = Vec2.lerp(c.fromSocketPos, c.toSocketPos, 0.5);
      const dir = Vec2.sub(c.toSocketPos, c.fromSocketPos);
      const perp = { x: -dir.y * 0.15, y: dir.x * 0.15 };
      const ctrl = Vec2.add(mid, perp);
      const d = `M ${c.fromSocketPos.x},${c.fromSocketPos.y} Q ${ctrl.x},${ctrl.y} ${c.toSocketPos.x},${c.toSocketPos.y}`;
      return (<g key={`l-${i}`}>
        <path d={d} fill="none" stroke={T.fuchsia} strokeWidth={4} opacity={0.1} />
        <path d={d} fill="none" stroke={T.fuchsia} strokeWidth={1.8} strokeDasharray="6 4" opacity={0.6} />
      </g>);
    }
    const isSp = c.role === "spine", isBr = c.role === "branch", isMrg = c.role === "branch-merge";
    const col = isMrg ? T.fuchsia : isSp ? T.teal : isBr ? T.orange : T.cyanDim;
    const w = isSp ? 2.5 : isBr ? 1.8 : isMrg ? 2 : 1;
    const op = isSp ? 0.45 : isBr ? 0.3 : isMrg ? 0.5 : 0.15;
    return (<g key={`t-${i}`}>
      {(isSp || isMrg) && <line x1={c.fromSocketPos.x} y1={c.fromSocketPos.y} x2={c.toSocketPos.x} y2={c.toSocketPos.y}
        stroke={col} strokeWidth={w + 3} opacity={0.06} />}
      <line x1={c.fromSocketPos.x} y1={c.fromSocketPos.y} x2={c.toSocketPos.x} y2={c.toSocketPos.y}
        stroke={col} strokeWidth={w} opacity={op} strokeDasharray={isMrg ? "8 4" : "none"} />
    </g>);
  })}</>);
}

// ============================================================
// STAGE SELECTOR
// ============================================================

function StageSelector({ stageKey, onSelect }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 3 }}>
      {STAGES.map((s) => {
        const active = stageKey === s.key;
        return (
          <button key={s.key} onClick={() => { if (window.PZO.sound) window.PZO.sound.click(); onSelect(s.key); }}
            title={`${s.name} — ${s.subtitle}`}
            style={{
              padding: "4px 8px", fontSize: 9, fontFamily: "inherit",
              background: active ? T.cyan + "18" : "transparent",
              border: `1px solid ${active ? T.border : T.borderDim}`,
              color: active ? T.cyan : T.textLo,
              borderRadius: 3, cursor: "pointer", letterSpacing: 1,
              fontWeight: active ? 700 : 400,
              transition: "all 0.2s",
              display: "flex", alignItems: "center", gap: 5,
            }}>
            <span style={{
              fontSize: 8, opacity: active ? 1 : 0.5,
              background: active ? T.cyan + "18" : "transparent",
              padding: "1px 3px", borderRadius: 2,
            }}>{s.number}</span>
            {active && <span style={{ fontSize: 9, textTransform: "uppercase" }}>{s.name}</span>}
          </button>
        );
      })}
    </div>
  );
}

// ============================================================
// MAIN APP
// ============================================================

function ProceduralLevelGenerator() {
  const [stageKey, setStageKey] = useState("s1");
  const [roomCount, setRoomCount] = useState(22);
  const [seed, setSeed] = useState("D212000");
  const [pathsToExit, setPathsToExit] = useState(2);
  const [roomWeights, setRoomWeights] = useState({});
  const [hoveredRoom, setHoveredRoom] = useState(null);
  const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
  const [isPanning, setIsPanning] = useState(false);
  const [panStart, setPanStart] = useState({ x: 0, y: 0 });
  const containerRef = useRef(null);

  const stage = STAGE_MAP[stageKey];
  const seedNum = useMemo(() => hashSeed(String(seed)), [seed]);

  const weights = useMemo(() =>
    stage.rooms.map((r, i) => roomWeights[`${stageKey}:${i}`] ?? 1),
    [stageKey, roomWeights, stage.rooms]
  );

  const setWeight = useCallback((idx, val) => {
    setRoomWeights((prev) => ({ ...prev, [`${stageKey}:${idx}`]: val }));
  }, [stageKey]);

  const level = useMemo(() => generateLevel(stage, roomCount, seedNum, pathsToExit, weights), [stageKey, roomCount, seedNum, pathsToExit, weights]);
  const { rooms, connections } = level;

  useEffect(() => {
    if (rooms.length === 0) return;
    const bbox = getBBox(rooms.flatMap((r) => r.vertices));
    const c = containerRef.current; if (!c) return;
    const cw = c.clientWidth, ch = c.clientHeight;
    const lw = bbox.maxX - bbox.minX + 120, lh = bbox.maxY - bbox.minY + 120;
    const s = Math.min(cw / lw, ch / lh, 2.5) * 0.85;
    setTransform({ x: cw / 2 - ((bbox.minX + bbox.maxX) / 2) * s, y: ch / 2 - ((bbox.minY + bbox.maxY) / 2) * s, scale: s });
  }, [rooms]);

  const handleMouseDown = useCallback((e) => { if (e.button === 0) { setIsPanning(true); setPanStart({ x: e.clientX - transform.x, y: e.clientY - transform.y }); } }, [transform]);
  const handleMouseMove = useCallback((e) => { if (isPanning) setTransform((t) => ({ ...t, x: e.clientX - panStart.x, y: e.clientY - panStart.y })); }, [isPanning, panStart]);
  const handleMouseUp = useCallback(() => setIsPanning(false), []);
  const handleWheel = useCallback((e) => {
    e.preventDefault(); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return;
    const mx = e.clientX - rect.left, my = e.clientY - rect.top, f = e.deltaY > 0 ? 0.9 : 1.1;
    setTransform((t) => { const ns = Math.max(0.1, Math.min(8, t.scale * f)), r = ns / t.scale; return { scale: ns, x: mx - (mx - t.x) * r, y: my - (my - t.y) * r }; });
  }, []);
  useEffect(() => { const el = containerRef.current; if (el) { el.addEventListener("wheel", handleWheel, { passive: false }); return () => el.removeEventListener("wheel", handleWheel); } }, [handleWheel]);

  const hRoom = hoveredRoom !== null ? rooms[hoveredRoom] : null;

  // Shared panel style
  const panelStyle = {
    background: THEME.panelBg,
    border: `1px solid ${T.border}`,
    borderRadius: 4,
    backdropFilter: "blur(6px)",
  };

  return (
    <div style={{ width: "100%", height: "min(72vh, 720px)", background: T.bgDeep, display: "flex", flexDirection: "column",
      fontFamily: "var(--mono), monospace", color: T.textHi, overflow: "hidden", userSelect: "none",
      border: "1px solid " + T.border, borderRadius: 12 }}>

      {/* ── Topbar ────────────────────────────────────────────── */}
      <div style={{ padding: "10px 16px", display: "flex", alignItems: "center", gap: 14,
        borderBottom: `1px solid ${T.borderDim}`, background: T.bgPanel + "ee", backdropFilter: "blur(8px)", zIndex: 10, flexWrap: "wrap" }}>

        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <div style={{ width: 7, height: 7, borderRadius: "50%", background: T.cyan,
            boxShadow: `0 0 8px ${T.cyan}88` }} />
          <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: 2, textTransform: "uppercase",
            color: T.cyan }}>Contract Map Simulator</span>
        </div>

        <StageSelector stageKey={stageKey} onSelect={setStageKey} />

        <button onClick={() => { if (window.PZO.sound) window.PZO.sound.click(); setSeed(Math.random().toString(36).substring(2, 8)); }} style={{ padding: "4px 12px", fontSize: 9, fontFamily: "inherit",
          background: T.cyan + "14", border: `1px solid ${T.border}`, color: T.cyan, borderRadius: 3,
          cursor: "pointer", letterSpacing: 2, textTransform: "uppercase", fontWeight: 700 }}
          onMouseEnter={(e) => { e.target.style.background = T.cyan + "28"; }}
          onMouseLeave={(e) => { e.target.style.background = T.cyan + "14"; }}>⟳ Generate</button>

        <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
          <span style={{ fontSize: 8, color: T.textLo, letterSpacing: 1, textTransform: "uppercase" }}>Seed</span>
          <input type="text" value={seed} onChange={(e) => setSeed(e.target.value)}
            spellCheck={false} autoComplete="off"
            style={{ width: 80, padding: "2px 6px", fontSize: 10, fontFamily: "inherit",
              background: T.bgPanel, border: `1px solid ${T.borderDim}`, color: T.cyan,
              borderRadius: 3, outline: "none", letterSpacing: 1 }}
            onFocus={(e) => { e.target.style.borderColor = T.cyan; }}
            onBlur={(e) => { e.target.style.borderColor = T.borderDim; }} />
        </div>
      </div>

      {/* ── Viewport ──────────────────────────────────────────── */}
      <div ref={containerRef} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}
        style={{ flex: 1, position: "relative", cursor: isPanning ? "grabbing" : "grab", overflow: "hidden" }}>
        <svg width="100%" height="100%" style={{ position: "absolute", top: 0, left: 0 }}>
          <defs>
            <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse" patternTransform={`translate(${transform.x},${transform.y}) scale(${transform.scale})`}>
              <path d="M 40 0 L 0 0 0 40" fill="none" stroke={THEME.gridColor} strokeWidth={1 / transform.scale} /></pattern>
            <pattern id="gridLg" width="200" height="200" patternUnits="userSpaceOnUse" patternTransform={`translate(${transform.x},${transform.y}) scale(${transform.scale})`}>
              <path d="M 200 0 L 0 0 0 200" fill="none" stroke={THEME.gridColorLg} strokeWidth={2 / transform.scale} /></pattern>
          </defs>
          <rect width="100%" height="100%" fill="url(#grid)" /><rect width="100%" height="100%" fill="url(#gridLg)" />
          <g transform={`translate(${transform.x},${transform.y}) scale(${transform.scale})`}>
            <ConnectionLines connections={connections} />
            {rooms.map((room) => (<RoomPolygon key={room.id} room={room} isHovered={hoveredRoom === room.id}
              onHover={setHoveredRoom} onLeave={() => setHoveredRoom(null)} />))}
          </g>
        </svg>

        {/* Zoom indicator — stays in viewport */}
        <div style={{ ...panelStyle, position: "absolute", bottom: 12, right: 12, padding: "5px 10px", fontSize: 8, color: T.textLo, letterSpacing: 1 }}>
          {Math.round(transform.scale * 100)}% · SCROLL ZOOM · DRAG PAN</div>

        {/* Hover tooltip — stays in viewport */}
        {hRoom && <div style={{ ...panelStyle, position: "absolute", top: 12, right: 12,
          padding: "10px 14px", fontSize: 10, letterSpacing: 1, lineHeight: 1.8, minWidth: 180 }}>
          <div style={{ color: T.cyan, fontWeight: 700, fontSize: 11, marginBottom: 4 }}>{hRoom.templateName}</div>
          <div><span style={{ color: T.textLo }}>ID:</span> <span style={{ color: T.textMid }}>#{hRoom.id}</span></div>
          <div><span style={{ color: T.textLo }}>ROLE:</span> <span style={{
            color: hRoom.role === "start" ? T.teal : hRoom.role === "exit" ? T.fuchsia :
              hRoom.role === "spine" ? T.teal : hRoom.role === "branch" ? T.orange : T.textMid,
            fontWeight: 700 }}>{(hRoom.role || "fill").toUpperCase()}</span></div>
          <div><span style={{ color: T.textLo }}>TYPE:</span> <span style={{ color: T.textMid }}>{hRoom.type}</span></div>
          <div><span style={{ color: T.textLo }}>SOCKETS:</span> <span style={{ color: T.textMid }}>{hRoom.sockets.filter((s) => s.connected).length}/{hRoom.sockets.length}</span></div>
          {hRoom.sockets.some((s) => s.isLoop) && <div style={{ color: T.fuchsia, marginTop: 2 }}>◆ Loop connection</div>}
        </div>}
      </div>

      {/* ── Bottom bar — Generation controls + Room weights ─── */}
      {/* Mirrors the topbar visually. Two sections separated by
          a vertical divider: fixed-width generation controls on
          the left, flexible room-weight grid on the right. */}
      <div onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
        style={{
          display: "flex", alignItems: "stretch", gap: 0,
          padding: 0,
          borderTop: `1px solid ${T.borderDim}`,
          background: T.bgPanel + "ee",
          backdropFilter: "blur(8px)",
          fontSize: 9, letterSpacing: 1,
          flexShrink: 0,
        }}>

        {/* Left section: Rooms + Paths to Exit */}
        <div style={{
          width: 220, minWidth: 200, flexShrink: 0,
          padding: "14px 20px",
          display: "flex", flexDirection: "column", justifyContent: "center", gap: 10,
        }}>
          {/* Rooms slider */}
          <div>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 2 }}>
              <span style={{ color: T.textLo, fontSize: 9, textTransform: "uppercase", letterSpacing: 1.5 }}>Rooms</span>
              <span style={{ color: T.cyan, fontWeight: 700, fontSize: 11 }}>{roomCount}</span>
            </div>
            <input type="range" min={8} max={50} value={roomCount} onChange={(e) => setRoomCount(parseInt(e.target.value))}
              style={{ width: "100%", accentColor: T.cyan, height: 10, display: "block" }} />
          </div>

          {/* Paths to Exit slider */}
          <div>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 2 }}>
              <span style={{ color: T.textLo, fontSize: 9, textTransform: "uppercase", letterSpacing: 1.5 }}>Paths to Exit</span>
              <span style={{ color: T.orange, fontWeight: 700, fontSize: 11 }}>{pathsToExit}</span>
            </div>
            <input type="range" min={1} max={3} value={pathsToExit} onChange={(e) => setPathsToExit(parseInt(e.target.value))}
              style={{ width: "100%", accentColor: T.orange, height: 10, display: "block" }} />
          </div>
        </div>

        {/* Vertical divider */}
        <div style={{
          width: 1,
          background: `linear-gradient(180deg, transparent, ${T.borderDim}, transparent)`,
          flexShrink: 0,
        }} />

        {/* Right section: Room weights — 2-column wrap grid */}
        <div style={{
          flex: 1, minWidth: 0,
          padding: "14px 20px",
          display: "flex", flexDirection: "column", gap: 6,
        }}>
          <div style={{ color: T.textLo, fontSize: 8, textTransform: "uppercase", letterSpacing: 1.5, marginBottom: 2 }}>
            Room Weights
          </div>
          <div style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
            maxWidth: 600,
            gap: "10px 24px",
          }}>
            {stage.rooms.map((rm, i) => {
              const w = weights[i];
              const isZero = w === 0;
              return (
                <div key={`${stageKey}-${i}`} style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  {/* Room name — fixed width, truncated */}
                  <span style={{
                    color: isZero ? T.textLo : T.textMid,
                    fontSize: 9, textTransform: "uppercase",
                    opacity: isZero ? 0.4 : 1,
                    width: 75, minWidth: 75, flexShrink: 0,
                    overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
                  }}>{rm.name}</span>

                  {/* Slider */}
                  <input type="range" min={0} max={3} step={0.1} value={w}
                    onChange={(e) => setWeight(i, parseFloat(e.target.value))}
                    style={{
                      flex: 1, minWidth: 40,
                      accentColor: isZero ? T.textLo : T.cyanDim,
                      height: 8, display: "block",
                    }} />

                  {/* Value readout */}
                  <span style={{
                    color: isZero ? T.textLo : T.cyan,
                    fontSize: 9, fontWeight: 600,
                    opacity: isZero ? 0.4 : 1,
                    width: 24, textAlign: "right", flexShrink: 0,
                  }}>{w === 0 ? "—" : w.toFixed(1)}</span>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </div>
  );
}

// ── Global registration ──────────────────────────────────────
// The OperationsHub reads this to render the tool when active.
window.OpsTool_StageMap = ProceduralLevelGenerator;

}());
