// ====== LegoDigital — main game ======
// SOURCE OF TRUTH — edit in web-play/, ship via deploy-product.sh lego-digital. Never hub public/LegoDigital/.
const { useState, useEffect, useRef, useMemo, useCallback } = React;

// Piece palette: basic bricks + tiles + specials. Each entry is one click-to-pick item.
const PIECE_PALETTE = [
  // Bricks
  { key: '1x1',     w: 1, d: 1, label: '1×1',     type: 'brick', cat: 'bricks' },
  { key: '1x2',     w: 1, d: 2, label: '1×2',     type: 'brick', cat: 'bricks' },
  { key: '1x3',     w: 1, d: 3, label: '1×3',     type: 'brick', cat: 'bricks' },
  { key: '1x4',     w: 1, d: 4, label: '1×4',     type: 'brick', cat: 'bricks' },
  { key: '2x2',     w: 2, d: 2, label: '2×2',     type: 'brick', cat: 'bricks' },
  { key: '2x3',     w: 2, d: 3, label: '2×3',     type: 'brick', cat: 'bricks' },
  { key: '2x4',     w: 2, d: 4, label: '2×4',     type: 'brick', cat: 'bricks' },
  { key: '2x6',     w: 2, d: 6, label: '2×6',     type: 'brick', cat: 'bricks' },
  { key: '4x4',     w: 4, d: 4, label: '4×4',     type: 'brick', cat: 'bricks' },
  // Tiles = 1 plate tall (⅓ brick) — real Lego plate height
  { key: 't1x1',    w: 1, d: 1, label: 'tile 1×1', type: 'tile',  cat: 'tiles', h: 1/3 },
  { key: 't2x2',    w: 2, d: 2, label: 'tile 2×2', type: 'tile',  cat: 'tiles', h: 1/3 },
  { key: 't2x4',    w: 2, d: 4, label: 'tile 2×4', type: 'tile',  cat: 'tiles', h: 1/3 },
  // Slopes
  { key: 'slS',     w: 1, d: 2, label: 'slope ↑', type: 'slope', slopeDir: 'S', cat: 'slopes' },
  { key: 'slE',     w: 2, d: 1, label: 'slope →', type: 'slope', slopeDir: 'E', cat: 'slopes' },
  { key: 'slN',     w: 1, d: 2, label: 'slope ↓', type: 'slope', slopeDir: 'N', cat: 'slopes' },
  { key: 'slW',     w: 2, d: 1, label: 'slope ←', type: 'slope', slopeDir: 'W', cat: 'slopes' },
  // Specials
  { key: 'wheel',   w: 1, d: 1, label: 'wheel',   type: 'wheel',    cat: 'specials' },
  { key: 'cone1',   w: 1, d: 1, label: 'cone 1×1', type: 'cone',    cat: 'specials', h: 2 },
  { key: 'cone2',   w: 2, d: 2, label: 'cone 2×2', type: 'cone',    cat: 'specials', h: 2 },
  { key: 'cyl',     w: 1, d: 1, label: 'cylinder', type: 'cylinder', cat: 'specials' },
  { key: 'win',     w: 1, d: 1, label: 'window',   type: 'window',   cat: 'specials' },
  { key: 'door',    w: 1, d: 1, label: 'door',     type: 'door',     cat: 'specials', h: 2 },
  { key: 'fig',     w: 1, d: 1, label: 'minifig',  type: 'minifig',  cat: 'specials', h: 4 },
];
const PALETTE_CATS = ['bricks', 'tiles', 'slopes', 'specials'];

const SANDBOX_BASE = { w: 20, d: 16 };

/** Human-readable deny toast copy (ENGINE_SPEC §7.3). Raw codes never shown. */
const DENY_COPY = {
  out_of_bounds: 'Outside the plate',
  oob: 'Outside the plate',
  collision: 'Space is occupied',
  overlap: 'Space is occupied',
  unsupported: 'Needs more stud support',
  no_support: 'Needs more stud support',
};

function denyToastCopy(reason) {
  if (!reason || reason === 'nocell') return null;
  return DENY_COPY[reason] || 'Cannot place here';
}

function normalizeYawDeg(d) {
  if (window.LegoEngine && typeof window.LegoEngine.normalizeYaw === 'function') {
    return window.LegoEngine.normalizeYaw(d);
  }
  return d;
}

// Download a JS object as a JSON file
function downloadJSON(obj, filename) {
  const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 500);
}

// ============================================================
// Camera control component
// ============================================================
function CameraControls({ view, setView, yawDeg = 0, setYawDeg, zoom, setZoom, hideAboveZ, setHideAboveZ, maxZ, showGrid, setShowGrid, topView, setTopView }) {
  // Iso HUD = signed yawDeg only (match slider + debug overlay; never view*90).
  const displayDeg = topView ? view * 90 : yawDeg;
  const orbitLeft = () => {
    if (topView) setView((view + 3) % 4);
    else if (setYawDeg) setYawDeg(d => normalizeYawDeg(d - 15));
    else setView((view + 3) % 4);
  };
  const orbitRight = () => {
    if (topView) setView((view + 1) % 4);
    else if (setYawDeg) setYawDeg(d => normalizeYawDeg(d + 15));
    else setView((view + 1) % 4);
  };
  return (
    <div className="vp-camera">
      <div className="cam-orbit">
        <button className="sk-btn sm" title={topView ? 'rotate top view left' : 'orbit left 15°'}
                onClick={orbitLeft}>↺</button>
        <span className="sk-label tiny" style={{ minWidth: 56, textAlign: 'center' }}>
          {topView ? `top · ${view * 90}°` : `${Math.round(displayDeg)}°`}
        </span>
        <button className="sk-btn sm" title={topView ? 'rotate top view right' : 'orbit right 15°'}
                onClick={orbitRight}>↻</button>
      </div>
      {setYawDeg && !topView && (
        <div className="cam-yaw">
          <input type="range" min="-180" max="180" step="1" value={yawDeg}
                 onChange={e => setYawDeg(normalizeYawDeg(parseInt(e.target.value, 10)))}
                 title="drag to rotate freely" />
          <button className="sk-btn sm" title="reset rotation" onClick={() => setYawDeg(25)}>reset</button>
        </div>
      )}
      {setTopView && (
        <div className="cam-mode">
          <button className={`sk-btn sm ${!topView ? 'on' : ''}`}
                  onClick={() => {
                    // Leaving top: keep iso yaw; do not add view*90.
                    setTopView(false);
                  }}
                  style={!topView ? { background: 'var(--lego-yellow)' } : null}>iso</button>
          <button className={`sk-btn sm ${topView ? 'on' : ''}`}
                  onClick={() => {
                    // Entering top: derive quarter from current yaw for a sensible match.
                    if (typeof viewQuarterFromYaw === 'function') setView(viewQuarterFromYaw(yawDeg));
                    else setView(0);
                    setTopView(true);
                  }}
                  style={topView ? { background: 'var(--lego-yellow)' } : null}>top</button>
        </div>
      )}
      <div className="cam-zoom">
        <button className="sk-btn sm" onClick={() => setZoom(z => Math.max(0.35, z / 1.2))}>−</button>
        <span className="sk-label tiny" style={{ minWidth: 36, textAlign: 'center' }}>{zoom.toFixed(1)}×</span>
        <button className="sk-btn sm" onClick={() => setZoom(z => Math.min(4, z * 1.2))}>+</button>
        <button className="sk-btn sm" onClick={() => setZoom(1)}>⌂</button>
      </div>
      {setShowGrid && (
        <div className="cam-grid">
          <button className={`sk-btn sm ${showGrid ? 'on' : ''}`}
                  title="show/hide A1 grid coordinates"
                  onClick={() => setShowGrid(g => !g)}
                  style={showGrid ? { background: 'var(--lego-yellow)' } : null}>
            A1 grid {showGrid ? 'on' : 'off'}
          </button>
        </div>
      )}
      {setHideAboveZ && (
        <div className="cam-layer">
          <span className="sk-label tiny">layer ≤</span>
          <input type="range" min="1" max={Math.max(1, maxZ + 1)} value={hideAboveZ ?? Math.max(1, maxZ + 1)}
                 onChange={e => setHideAboveZ(parseInt(e.target.value, 10))} />
          <span className="sk-label tiny">{hideAboveZ ?? '∞'}</span>
        </div>
      )}
    </div>
  );
}

// ============================================================
// Build-instructions panel: derives chess-style steps from any brick list
// ============================================================
function describeBrick(b) {
  const col = colLabel(b.x);
  const row = b.y + 1;
  const h = brickH(b);
  const sizeStr = `${b.w}×${b.d}` + (h !== 1 ? `×${h}` : '');
  const typeStr = b.type && b.type !== 'brick' ? b.type : 'brick';
  return { col, row, sizeStr, typeStr, color: b.color };
}

function InstructionsPanel({ bricks, onClose }) {
  const sorted = React.useMemo(() => {
    return [...bricks].sort((a, b) => {
      if (a.z !== b.z) return a.z - b.z;
      if ((a.x + a.y) !== (b.x + b.y)) return (a.x + a.y) - (b.x + b.y);
      return a.x - b.x;
    });
  }, [bricks]);

  const text = React.useMemo(() => {
    return sorted.map((b, i) => {
      const d = describeBrick(b);
      const zStr = b.z > 0 ? `, layer ${b.z + 1}` : '';
      return `${i + 1}. Pick a ${d.sizeStr} ${d.color} ${d.typeStr} — place at ${d.col}${d.row}${zStr}`;
    }).join('\n');
  }, [sorted]);

  const copy = () => {
    navigator.clipboard?.writeText(text).then(
      () => alert('Instructions copied to clipboard'),
      () => alert('Could not copy — select and Ctrl/⌘+C from the panel instead.')
    );
  };

  return (
    <div className="instructions-modal" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="instructions-modal-card">
        <div className="instructions-modal-head">
          <h3>Build instructions</h3>
          <span className="sk-label tiny">{sorted.length} pieces · sorted bottom-up</span>
          <span style={{ flex: 1 }} />
          <button className="sk-btn sm" onClick={copy}>📋 copy</button>
          <button className="sk-btn sm" onClick={onClose}>✕ close</button>
        </div>
        <div className="instructions-modal-body">
          {sorted.length === 0 ? (
            <div className="sk-label tiny">No pieces placed yet — drop a brick on the baseplate and come back!</div>
          ) : (
            <ol className="instr-list">
              {sorted.map((b, i) => {
                const d = describeBrick(b);
                return (
                  <li key={b.id || i}>
                    Pick a <b>{d.sizeStr} {d.color} {d.typeStr}</b> — place at <b>{d.col}{d.row}</b>
                    {b.z > 0 && <span className="sk-label tiny"> · layer {b.z + 1}</span>}
                    <span className="instr-preview">
                      <PiecePreview piece={{ w: b.w, d: b.d, h: b.h, type: b.type || 'brick', slopeDir: b.slopeDir }} color={b.color} />
                    </span>
                  </li>
                );
              })}
            </ol>
          )}
        </div>
        <div className="instructions-modal-foot sk-label tiny">
          Coords use A1 chess notation: A-Z columns (X), 1-N rows (Y). z=0 is the baseplate.
        </div>
      </div>
    </div>
  );
}

// ============================================================
// Sandbox mode
// ============================================================
function SandboxScreen({ initialBricks = [], initialBase = SANDBOX_BASE, onExit, setName = 'untitled' }) {
  const [bricks, setBricks] = useState(() => settleBricks(initialBricks.map(b => ({ ...b, h: brickH(b) }))));
  const [history, setHistory] = useState([]);
  const [future, setFuture] = useState([]);
  const [baseSize, setBaseSize] = useState(initialBase);
  const [paletteCat, setPaletteCat] = useState('bricks');
  const [selectedPiece, setSelectedPiece] = useState(0);
  const [color, setColor] = useState('red');
  const [tool, setTool] = useState('place');
  const [rot, setRot] = useState(0);
  const [view, setView] = useState(0);
  const [hover, setHover] = useState(null);
  const [selected, setSelected] = useState(null);
  const [zoom, setZoom] = useState(1);
  const [hideAboveZ, setHideAboveZ] = useState(null);
  const [showGrid, setShowGrid] = useState(true);
  const [showInstructions, setShowInstructions] = useState(false);
  const [topView, setTopView] = useState(false);
  const [yawDeg, setYawDeg] = useState(25);
  const [panX, setPanX] = useState(0);
  const [panY, setPanY] = useState(0);
  const [muted, setMuted] = useState(() => !!(window.LegoEngine && window.LegoEngine.AudioBus && window.LegoEngine.AudioBus.muted));
  const [denyMsg, setDenyMsg] = useState(null);
  const [clutchStuds, setClutchStuds] = useState([]);
  const idRef = useRef(1);
  const fileInputRef = useRef(null);
  const hydratedRef = useRef(initialBricks.length > 0);
  const worldRef = useRef(null);
  const yawRef = useRef(25);
  const yawTweenCancel = useRef(null);

  const commitYaw = (d) => {
    const n = normalizeYawDeg(d);
    yawRef.current = n;
    setYawDeg(n);
    return n;
  };

  const makeCamera = () => createCamera({
    mode: topView ? 'top' : 'iso',
    viewQuarter: view,
    yawDeg,
    panX,
    panY,
    zoom,
  });

  /** Sync React brick list through BrickWorld (single command surface). */
  const syncWorld = (opts) => {
    const w = createWorld({
      baseSize: (opts && opts.baseSize) || baseSize,
      bricks: (opts && opts.bricks) || bricks,
      idStart: idRef.current,
    });
    worldRef.current = w;
    return w;
  };

  const flashDeny = (reason) => {
    const copy = denyToastCopy(reason);
    if (!copy) return; // silent no-op for nocell / empty
    setDenyMsg(copy);
    setTimeout(() => setDenyMsg(null), 1200);
    if (window.LegoEngine && window.LegoEngine.AudioBus) window.LegoEngine.AudioBus.play('deny');
  };

  // Persist — load once, then write only after hydration so we never clobber with [].
  useEffect(() => {
    if (initialBricks.length > 0) {
      hydratedRef.current = true;
      return;
    }
    const saved = localStorage.getItem('legodigital:sandbox');
    if (!saved) {
      hydratedRef.current = true;
      return;
    }
    try {
      const data = JSON.parse(saved);
      if (Array.isArray(data.bricks)) {
        const loaded = settleBricks(data.bricks.map(b => {
          const { _lift, _hover, _ghost, _invalid, ...rest } = b;
          return { ...rest, h: brickH(rest) };
        }));
        setBricks(loaded);
        if (data.baseSize) setBaseSize(data.baseSize);
        let maxId = 0;
        loaded.forEach(b => {
          const m = parseInt(String(b.id).replace(/\D/g, ''), 10);
          if (!isNaN(m)) maxId = Math.max(maxId, m);
        });
        idRef.current = maxId + 1;
      }
    } catch (e) { /* ignore */ }
    hydratedRef.current = true;
  }, []); // eslint-disable-line

  useEffect(() => {
    if (!hydratedRef.current) return;
    if (initialBricks.length > 0) return; // set-opened sandboxes are not the freeform autosave
    localStorage.setItem('legodigital:sandbox', JSON.stringify({ bricks, baseSize, savedAt: Date.now() }));
  }, [bricks, baseSize, initialBricks.length]);

  // Filter palette
  const visiblePalette = useMemo(() => PIECE_PALETTE.filter(p => p.cat === paletteCat), [paletteCat]);

  // Ensure selectedPiece is valid for current category
  useEffect(() => {
    if (selectedPiece >= visiblePalette.length) setSelectedPiece(0);
  }, [paletteCat, visiblePalette.length, selectedPiece]);

  const piece = visiblePalette[selectedPiece] || PIECE_PALETTE[0];
  const efW = rot ? piece.d : piece.w;
  const efD = rot ? piece.w : piece.d;

  const pushHistory = () => {
    setHistory(h => [...h, bricks].slice(-50));
    setFuture([]);
  };
  const doUndo = () => {
    setHistory(h => {
      if (h.length === 0) return h;
      const prev = h[h.length - 1];
      setFuture(f => [bricks, ...f].slice(0, 50));
      setBricks(prev);
      return h.slice(0, -1);
    });
  };
  const doRedo = () => {
    setFuture(f => {
      if (f.length === 0) return f;
      const next = f[0];
      setHistory(h => [...h, bricks].slice(-50));
      setBricks(next);
      return f.slice(1);
    });
  };

  const doDelete = (b) => {
    pushHistory();
    const before = bricks;
    const w = syncWorld();
    const ids = b.groupId && window.LegoEngine && window.LegoEngine.selectGroup
      ? window.LegoEngine.selectGroup(w, b.groupId).map(x => x.id)
      : [b.id];
    if (ids.length > 1) w.removeMany(ids);
    else w.remove(b.id);
    const after = w.bricks;
    if (window.LegoEngine && window.LegoEngine.deleteFall) {
      window.LegoEngine.deleteFall(before, after, (frame) => setBricks(frame), (final) => setBricks(final));
    } else setBricks(after);
    setSelected(null);
    if (window.LegoEngine && window.LegoEngine.AudioBus) window.LegoEngine.AudioBus.play('delete');
  };
  const doPaint = (b) => {
    pushHistory();
    const w = syncWorld();
    w.replace(b.id, { color });
    setBricks(w.bricks);
    if (window.LegoEngine && window.LegoEngine.AudioBus) window.LegoEngine.AudioBus.play('ui');
  };

  // Placement via canPlace (majority clutch). Pick via unified LE.pick only.
  function computePlacement(cell) {
    if (!cell) return { ok: false, reason: 'nocell' };
    const ph = piece.h != null ? piece.h : brickH({ type: piece.type });
    let candidate;
    if (topView) {
      const rotBase = viewedBaseSize(baseSize, view);
      let rgx = cell.x, rgy = cell.y;
      if (rgx + efW > rotBase.w) rgx = rotBase.w - efW;
      if (rgy + efD > rotBase.d) rgy = rotBase.d - efD;
      if (rgx < 0 || rgy < 0) return { ok: false, reason: 'oob' };
      const orig = invView(rgx, rgy, efW, efD, view, baseSize);
      const z = topZAt(bricks, orig.x, orig.y, orig.w, orig.d);
      candidate = { ...orig, z, h: ph, type: piece.type, slopeDir: piece.slopeDir };
    } else {
      let gx = cell.x, gy = cell.y;
      if (gx + efW > baseSize.w) gx = baseSize.w - efW;
      if (gy + efD > baseSize.d) gy = baseSize.d - efD;
      if (gx < 0 || gy < 0) return { ok: false, reason: 'oob' };
      const z = topZAt(bricks, gx, gy, efW, efD);
      candidate = { x: gx, y: gy, w: efW, d: efD, z, h: ph, type: piece.type, slopeDir: piece.slopeDir };
    }
    const check = canPlace(bricks, candidate, { baseSize });
    if (!check.ok) return { ok: false, reason: check.reason, candidate, check };
    return { ok: true, candidate, check };
  }

  function pickAt(sx, sy) {
    const cam = makeCamera();
    const world = { baseSize, bricks };
    if (typeof pick === 'function') return pick(sx, sy, world, cam, { x: 0, y: 0 });
    return null;
  }

  const onPointerMove = ({ sx, sy }) => {
    const hit = pickAt(sx, sy);
    if (tool === 'place') {
      const cell = hit && hit.cell;
      if (!cell) { setHover(null); setClutchStuds([]); return; }
      const plc = computePlacement(cell);
      if (!plc.ok) {
        if (plc.candidate) {
          setHover({ ...plc.candidate, color, id: '__ghost', _invalid: true });
          setClutchStuds([]);
        } else { setHover(null); setClutchStuds([]); }
        return;
      }
      const c = plc.candidate;
      setHover({
        ...c, color, id: '__ghost',
        h: piece.h != null ? piece.h : brickH({ type: piece.type }),
      });
      if (window.LegoEngine && window.LegoEngine.exposedStudsUnder) {
        setClutchStuds(window.LegoEngine.exposedStudsUnder(bricks, c.x, c.y, c.w, c.d, c.z));
      }
    } else {
      setClutchStuds([]);
      if (hit && hit.brick) {
        const orig = bricks.find(b => b.id === (hit.brick._origId || hit.brick.id));
        setHover(orig ? { ...orig, _hover: true } : null);
      } else setHover(null);
    }
  };

  const onSceneClick = ({ sx, sy }) => {
    const hit = pickAt(sx, sy);
    if (tool === 'place') {
      const cell = hit && hit.cell;
      const plc = computePlacement(cell);
      if (!plc.ok) { flashDeny(plc.reason); return; }
      // Ghost = commit: hover footprint must match the brick we add (AC-16).
      if (hover && !hover._hover && !hover._invalid) {
        const mismatch = hover.x !== plc.candidate.x || hover.y !== plc.candidate.y
          || hover.w !== plc.candidate.w || hover.d !== plc.candidate.d;
        if (mismatch) {
          // Re-sync ghost then commit the pick-derived candidate (source of truth).
          setHover({ ...plc.candidate, color, id: '__ghost', h: plc.candidate.h });
        }
      }
      pushHistory();
      const w = syncWorld();
      const res = w.add({
        ...plc.candidate,
        color,
        id: `sb-${idRef.current++}`,
      });
      if (!res.ok) { flashDeny(res.reason); return; }
      const placed = res.brick;
      if (window.LegoEngine && window.LegoEngine.placeSnap) {
        setBricks([...w.bricks.filter(b => b.id !== placed.id), { ...placed, _lift: 0.35 }]);
        window.LegoEngine.placeSnap(placed, (b) => {
          setBricks(prev => prev.map(x => x.id === b.id ? { ...b } : x));
        }, (b) => {
          setBricks(prev => prev.map(x => x.id === b.id ? { ...b, _lift: 0 } : x));
        });
      } else setBricks(w.bricks);
      if (window.LegoEngine && window.LegoEngine.AudioBus) {
        window.LegoEngine.AudioBus.play(placed.z > 0 ? 'stack' : 'snap');
      }
      setClutchStuds([]);
    } else {
      if (!hit || !hit.brick) { setSelected(null); return; }
      const orig = bricks.find(b => b.id === (hit.brick._origId || hit.brick.id));
      if (!orig) return;
      if (tool === 'select') setSelected(orig);
      else if (tool === 'delete') doDelete(orig);
      else if (tool === 'paint') doPaint(orig);
    }
  };

  const onSceneContext = ({ sx, sy }) => {
    const hit = pickAt(sx, sy);
    if (!hit || !hit.brick) return;
    const orig = bricks.find(b => b.id === (hit.brick._origId || hit.brick.id));
    if (orig) doDelete(orig);
  };

  const clearAll = () => {
    if (!window.confirm('Clear the entire build?')) return;
    pushHistory();
    const w = syncWorld();
    w.clear();
    setBricks([]);
    setSelected(null);
  };

  const easeYawBy = (delta) => {
    if (yawTweenCancel.current) {
      yawTweenCancel.current();
      yawTweenCancel.current = null;
    }
    const from = yawRef.current;
    const end = normalizeYawDeg(from + delta);
    let travel = end - from;
    if (travel > 180) travel -= 360;
    if (travel < -180) travel += 360;
    if (window.LegoEngine && window.LegoEngine.tween) {
      yawTweenCancel.current = window.LegoEngine.tween({
        from, to: from + travel, duration: 0.18,
        onUpdate: v => commitYaw(Math.round(v)),
        onDone: () => { yawTweenCancel.current = null; commitYaw(end); },
      });
    } else commitYaw(end);
  };

  // Keyboard via engine input action map
  useEffect(() => {
    const LE = window.LegoEngine;
    if (!LE || !LE.createInputMap) return;
    const map = LE.createInputMap();
    const offs = [
      map.on('RotatePiece', () => setRot(r => r ? 0 : 1)),
      map.on('ToolDelete', () => setTool('delete')),
      map.on('Delete', () => { if (selected) doDelete(selected); }),
      map.on('Place', () => setTool('place')),
      map.on('Select', () => setTool('select')),
      map.on('Paint', () => setTool('paint')),
      map.on('OrbitLeft', () => {
        if (topView) setView(v => (v + 3) % 4);
        else easeYawBy(-15);
      }),
      map.on('OrbitRight', () => {
        if (topView) setView(v => (v + 1) % 4);
        else easeYawBy(15);
      }),
      map.on('Undo', () => doUndo()),
      map.on('Redo', () => doRedo()),
      map.on('ToggleGrid', () => setShowGrid(g => !g)),
      map.on('ToggleTop', () => {
        setTopView(t => {
          if (!t) {
            // Entering top: sync quarter from current yaw (match iso/top buttons).
            if (typeof viewQuarterFromYaw === 'function') setView(viewQuarterFromYaw(yawDeg));
          }
          return !t;
        });
      }),
      map.on('Mute', () => {
        if (LE.AudioBus) {
          const m = LE.AudioBus.toggleMute();
          setMuted(m);
        }
      }),
    ];
    const handler = e => {
      if (map.handleKey(e)) return;
      if (e.key >= '1' && e.key <= '9') {
        const idx = parseInt(e.key) - 1;
        if (idx < visiblePalette.length) {
          setSelectedPiece(idx);
          setTool('place');
        }
      }
    };
    window.addEventListener('keydown', handler);
    return () => {
      window.removeEventListener('keydown', handler);
      offs.forEach(off => off && off());
    };
  }, [selected, bricks, visiblePalette.length, topView, yawDeg, baseSize]); // eslint-disable-line

  // Download / upload
  const handleDownload = () => {
    const w = syncWorld();
    const payload = (window.LegoEngine && window.LegoEngine.serializeWorldV2)
      ? window.LegoEngine.serializeWorldV2(w, { name: setName || 'untitled' })
      : {
          format: 'LegoDigital.v2',
          name: setName || 'untitled',
          baseSize, bricks,
          savedAt: new Date().toISOString(),
        };
    downloadJSON(payload, `${(setName || 'untitled').replace(/[^a-z0-9_-]/gi, '_')}.lego.json`);
  };

  /** Set-author lite: export current selection (or all) as a single step JSON. */
  const handleExportStep = () => {
    const stepBricks = selected
      ? (selected.groupId && window.LegoEngine
          ? window.LegoEngine.selectGroup(syncWorld(), selected.groupId)
          : [selected])
      : bricks;
    downloadJSON({
      format: 'LegoDigital.set.v2',
      id: (setName || 'step') + '-step',
      baseSize,
      steps: [{ title: 'Exported step', bricks: stepBricks.map(({ id, _lift, _hover, ...b }) => b) }],
    }, `${(setName || 'step').replace(/[^a-z0-9_-]/gi, '_')}.step.json`);
  };
  const handleUploadClick = () => fileInputRef.current?.click();
  const handleUploadFile = e => {
    const file = e.target.files?.[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = ev => {
      try {
        const data = JSON.parse(ev.target.result);
        if (!Array.isArray(data.bricks)) throw new Error('No bricks array');
        pushHistory();
        if (window.LegoEngine && window.LegoEngine.loadWorldV2) {
          const w = window.LegoEngine.loadWorldV2(data);
          setBricks(w.bricks);
          setBaseSize(w.baseSize);
          worldRef.current = w;
        } else {
          setBricks(settleBricks(data.bricks.map((b, i) => ({ ...b, h: brickH(b), id: b.id || `up-${i}` }))));
          if (data.baseSize) setBaseSize(data.baseSize);
        }
        let maxId = 0;
        data.bricks.forEach(b => {
          const m = parseInt(String(b.id || '').replace(/\D/g, ''), 10);
          if (!isNaN(m)) maxId = Math.max(maxId, m);
        });
        idRef.current = maxId + 1;
        alert(`Loaded ${data.bricks.length} bricks from ${file.name}`);
      } catch (err) {
        alert('Could not load file: ' + err.message);
      }
    };
    reader.readAsText(file);
    e.target.value = '';
  };

  const tallestZ = useMemo(
    () => bricks.reduce((m, b) => Math.max(m, b.z + brickH(b)), 0),
    [bricks]
  );
  const maxZ = useMemo(() => Math.max(tallestZ, 6), [tallestZ]);
  const camState = makeCamera();
  const isoVB = computeIsoViewBox(bricks, baseSize, 0, {
    maxZ: Math.max(maxZ, 4),
    padX: 80,
    padY: 80,
    camera: camState,
    stableOrbit: true,
  });
  const topVB = computeTopViewBox(viewedBaseSize(baseSize, view), { zoom, panX, panY });
  const viewBox = topView ? topVB : isoVB;

  return (
    <div className="game-frame">
      <div className="game-top">
        <button className="sk-btn sm" onClick={onExit}>← back to library</button>
        <span className="file-name">~/builds/{setName}.lego</span>
        <span style={{ flex: 1 }} />
        <button className="sk-btn sm" onClick={doUndo} disabled={!history.length}>↶ undo</button>
        <button className="sk-btn sm" onClick={doRedo} disabled={!future.length}>↷ redo</button>
        <button className="sk-btn sm" onClick={() => setShowInstructions(true)}>📋 instructions</button>
        <button className="sk-btn sm" onClick={handleUploadClick}>⤒ upload</button>
        <button className="sk-btn sm" onClick={handleDownload}>⤓ download</button>
        <button className="sk-btn sm" onClick={handleExportStep} title="Export selection or build as set.v2 step">step JSON</button>
        <button className="sk-btn sm" onClick={clearAll}>✕ clear</button>
        <button className="sk-btn sm" title="Mute SFX (M)" onClick={() => {
          if (window.LegoEngine && window.LegoEngine.AudioBus) {
            setMuted(window.LegoEngine.AudioBus.toggleMute());
          }
        }}>{muted ? '🔇' : '🔊'}</button>
        <span className="sk-label tiny" style={{ marginLeft: 8 }}>{bricks.length} pcs</span>
        <input ref={fileInputRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={handleUploadFile} />
      </div>

      <div className="game-body sandbox-grid">
        {/* Tool rail */}
        <div className="tool-rail">
          {[
            { id: 'place',  icon: '✚', label: 'place' },
            { id: 'select', icon: '✥', label: 'select' },
            { id: 'paint',  icon: '🪣', label: 'paint' },
            { id: 'delete', icon: '⌫', label: 'delete' },
          ].map(t => (
            <div key={t.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
              <div className={`tool ${tool === t.id ? 'on' : ''}`} onClick={() => setTool(t.id)} title={t.label}>{t.icon}</div>
              <div className="tool-label">{t.label}</div>
            </div>
          ))}
          <div style={{ height: 10 }} />
          <div className={`tool ${rot ? 'on' : ''}`} onClick={() => setRot(r => r ? 0 : 1)} title="rotate piece (R)">↻</div>
          <div className="tool-label">R rot</div>
        </div>

        {/* Viewport */}
        <div className="viewport" style={{ position: 'relative' }}>
          <div className="vp-hud">
            tool: <b>{tool}</b> · piece: <b>{piece.label}{rot ? ' (R)' : ''}</b> · {topView ? `top · ${view * 90}°` : `iso · ${Math.round(yawDeg)}°`} · zoom {zoom.toFixed(1)}×
          </div>
          <CameraControls view={view} setView={setView}
            yawDeg={yawDeg} setYawDeg={(v) => {
              const next = typeof v === 'function' ? v(yawRef.current) : v;
              commitYaw(next);
            }}
            zoom={zoom} setZoom={setZoom}
            hideAboveZ={hideAboveZ} setHideAboveZ={setHideAboveZ} maxZ={tallestZ}
            showGrid={showGrid} setShowGrid={setShowGrid}
            topView={topView} setTopView={setTopView} />
          {topView ? (
            <TopDownScene
              bricks={bricks}
              baseSize={baseSize}
              origin={{ x: 0, y: 0 }}
              view={view}
              ghost={tool === 'place' ? hover : null}
              selectedId={selected ? selected.id : (hover && hover._hover ? hover.id : null)}
              viewBox={viewBox}
              style={{ width: '100%', height: '100%', display: 'block', cursor: tool === 'place' ? 'crosshair' : 'default' }}
              onPointerMove={onPointerMove}
              onSceneClick={onSceneClick}
              onSceneContext={onSceneContext}
              hideAboveZ={hideAboveZ}
              showGrid={showGrid}
            />
          ) : (
            <IsoScene
              bricks={bricks}
              baseSize={baseSize}
              origin={{ x: 0, y: 0 }}
              view={0}
              yawDeg={yawDeg}
              panX={panX}
              panY={panY}
              zoom={zoom}
              setYawDeg={(v) => {
                const next = typeof v === 'function' ? v(yawRef.current) : v;
                commitYaw(next);
              }}
              setPan={(dx, dy) => { setPanX(p => p + dx); setPanY(p => p + dy); }}
              ghost={tool === 'place' ? hover : null}
              clutchStuds={clutchStuds}
              selectedId={selected ? selected.id : (hover && hover._hover ? hover.id : null)}
              viewBox={viewBox}
              style={{ width: '100%', height: '100%', display: 'block', cursor: tool === 'place' ? 'crosshair' : 'default' }}
              onPointerMove={onPointerMove}
              onSceneClick={onSceneClick}
              onSceneContext={onSceneContext}
              hideAboveZ={hideAboveZ}
              showGrid={showGrid}
            />
          )}
          {denyMsg && <div className="deny-toast">{denyMsg}</div>}
        </div>

        {/* Inspector */}
        <div className="inspector">
          <h3>Inspector</h3>
          {selected ? (
            <>
              <div className="sk-label tiny">
                <b>{selected.type || 'brick'}</b> · {selected.w}×{selected.d}×{brickH(selected).toFixed(2).replace(/\.00$/, '')} · {selected.color}
              </div>
              <div className="sk-box thin" style={{ padding: 8 }}>
                <div className="row"><span>X</span><b>{selected.x}</b></div>
                <div className="row"><span>Y</span><b>{selected.y}</b></div>
                <div className="row"><span>Z</span><b>{selected.z}</b></div>
                {selected.slopeDir && <div className="row"><span>slope</span><b>{selected.slopeDir}</b></div>}
              </div>
              <div style={{ display: 'flex', gap: 6 }}>
                <button className="sk-btn sm" onClick={() => doPaint(selected)}>recolor</button>
                <button className="sk-btn sm danger" onClick={() => doDelete(selected)}>delete</button>
              </div>
            </>
          ) : (
            <div className="sk-label tiny">click a brick to inspect</div>
          )}

          <hr className="sk-divider" />

          <div>
            <div className="sk-label">Color</div>
            <div className="swatch-row" style={{ marginTop: 6 }}>
              {COLOR_ORDER.map(c =>
                <div key={c} className={`swatch ${color === c ? 'on' : ''}`}
                  style={{ background: COLORS[c] }} title={c}
                  onClick={() => setColor(c)} />
              )}
            </div>
            <div className="sk-label tiny" style={{ marginTop: 4 }}>{color}</div>
          </div>

          <hr className="sk-divider" />

          <div>
            <div className="sk-label">Shortcuts</div>
            <div className="sk-label tiny">
              1–9: piece · R: rotate · P/V/B/D: tools<br/>
              Q/E or Shift-drag: orbit · two-finger drag on touch<br/>
              ⌘Z undo · ⇧⌘Z redo<br/>
              right-click: delete brick
            </div>
          </div>
        </div>
      </div>

      {/* Piece palette */}
      <div className="palette">
        <div className="palette-tabs">
          {PALETTE_CATS.map(c =>
            <span key={c} className={`pt ${paletteCat === c ? 'on' : ''}`} onClick={() => setPaletteCat(c)}>
              {c}
            </span>
          )}
          <span style={{ marginLeft: 'auto', alignSelf: 'center', paddingRight: 14 }} className="sk-label tiny">
            click a piece to select it
          </span>
        </div>
        <div className="palette-bin">
          {visiblePalette.map((p, i) => (
            <div key={p.key} className={`item ${selectedPiece === i ? 'on' : ''}`}
                 data-piece={p.key}
                 onClick={() => { setSelectedPiece(i); setTool('place'); }}>
              <div style={{ height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                <PiecePreview piece={p} color={color} />
              </div>
              <div className="name">{p.label}</div>
            </div>
          ))}
        </div>
      </div>

      {showInstructions && <InstructionsPanel bricks={bricks} onClose={() => setShowInstructions(false)} />}
    </div>
  );
}

// Small palette preview — same brickGeometry / projector path as the scene (yaw 25°).
function PiecePreview({ piece, color }) {
  const ph = piece.h != null ? piece.h : brickH({ type: piece.type });
  const fake = {
    x: 0, y: 0, z: 0,
    w: piece.w, d: piece.d, h: ph,
    color, type: piece.type, slopeDir: piece.slopeDir,
    id: 'preview',
  };
  const cam = createCamera({ yawDeg: 25 });
  const base = { w: Math.max(piece.w, 1), d: Math.max(piece.d, 1) };
  const restore = applyCamera(cam, base);
  let node;
  try {
    const g = brickGeometry(fake, { x: 0, y: 0 });
    const hex = colorHex(color);
    const shades = faceShades(hex, ISO.yaw);
    const toPts = poly => poly.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
    const fillFor = (face) => (face === 'E' || face === 'W') ? shades.right : shades.front;
    const pts = g.silhouette || [];
    const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
    const pad = 6;
    const minX = Math.min(...xs) - pad, maxX = Math.max(...xs) + pad;
    const minY = Math.min(...ys) - pad, maxY = Math.max(...ys) + pad;
    node = (
      <svg viewBox={`${minX} ${minY} ${maxX - minX} ${maxY - minY}`} width="60" height="36" style={{ overflow: 'visible' }}>
        <g>
          {(g.visibleVertical || []).map(face => (
            <polygon key={face} points={toPts(g.faces[face])} fill={fillFor(face)} stroke="none" />
          ))}
          <polygon points={toPts(g.top)} fill={shades.top} stroke="none" />
          <polygon points={toPts(g.silhouette)} fill="none" stroke="#1b2a34" strokeWidth="1.1" strokeLinejoin="round" />
        </g>
      </svg>
    );
  } finally {
    restore();
  }
  return node;
}

// ============================================================
// Instructions mode
// ============================================================
function InstructionsScreen({ set, onExit, onSwitchToSandbox }) {
  const [stepIdx, setStepIdx] = useState(0);
  const [view, setView] = useState(0);
  const [yawDeg, setYawDeg] = useState(25);
  const [zoom, setZoom] = useState(1);
  const [showGrid, setShowGrid] = useState(true);
  const [topView, setTopView] = useState(false);
  const allBricks = useMemo(() => flattenSet(set), [set]);
  const totalSteps = set.steps.length;

  const placedBricks = useMemo(
    () => allBricks.filter(b => b.step < stepIdx),
    [allBricks, stepIdx]
  );
  const currentBricks = useMemo(
    () => allBricks.filter(b => b.step === stepIdx),
    [allBricks, stepIdx]
  );

  useEffect(() => {
    localStorage.setItem(`legodigital:progress:${set.id}`, String(stepIdx));
  }, [stepIdx, set.id]);
  useEffect(() => {
    const v = localStorage.getItem(`legodigital:progress:${set.id}`);
    if (v != null) {
      const n = parseInt(v, 10);
      if (!isNaN(n) && n <= totalSteps) setStepIdx(n);
    }
    // eslint-disable-next-line
  }, [set.id]);

  const next = () => setStepIdx(i => Math.min(totalSteps, i + 1));
  const prev = () => setStepIdx(i => Math.max(0, i - 1));

  const currentStep = stepIdx < totalSteps ? set.steps[stepIdx] : null;
  const progress = stepIdx / totalSteps;

  const maxZ = useMemo(() => allBricks.reduce((m, b) => Math.max(m, b.z + brickH(b)), 6), [allBricks]);
  const isoVB = computeIsoViewBox(allBricks, set.baseSize, view, {
    maxZ: Math.max(maxZ, 4) / zoom, padX: 80 / zoom, padY: 80 / zoom,
    camera: createCamera({ yawDeg, zoom, panX: 0, panY: 0 }),
    stableOrbit: true,
  });
  const topVB = computeTopViewBox(viewedBaseSize(set.baseSize, view));
  const viewBox = topView ? topVB : isoVB;

  const stepPieceSummary = useMemo(() => {
    if (!currentStep) return [];
    const map = new Map();
    currentStep.bricks.forEach(b => {
      const k = `${b.type || 'brick'}-${b.w}x${b.d}-${b.color}`;
      if (!map.has(k)) map.set(k, { type: b.type || 'brick', w: b.w, d: b.d, color: b.color, h: brickH(b), slopeDir: b.slopeDir, count: 0 });
      map.get(k).count++;
    });
    return [...map.values()];
  }, [currentStep]);

  // Per-piece coord lines for this step, e.g. "Place 2×4 red brick at A2 (layer 1)"
  const stepCoords = useMemo(() => {
    if (!currentStep) return [];
    return currentStep.bricks.map(b => ({
      coord: `${colLabel(b.x)}${b.y + 1}`,
      layer: b.z + 1,
      sizeStr: `${b.w}×${b.d}${brickH(b) !== 1 ? `×${brickH(b)}` : ''}`,
      typeStr: (b.type && b.type !== 'brick') ? b.type : 'brick',
      color: b.color,
    }));
  }, [currentStep]);

  const finished = stepIdx >= totalSteps;
  // Placed bricks solid; current-step pieces translucent ghosts (engine honors _ghost).
  const sceneBricks = useMemo(() => [
    ...placedBricks,
    ...currentBricks.map(b => ({ ...b, _ghost: true })),
  ], [placedBricks, currentBricks]);

  return (
    <div className="game-frame">
      <div className="game-top">
        <button className="sk-btn sm" onClick={onExit}>← back to library</button>
        <span className="file-name">{set.name} · step {Math.min(stepIdx + 1, totalSteps)} / {totalSteps}</span>
        <span style={{ flex: 1 }} />
        <button className="sk-btn sm" onClick={onSwitchToSandbox}>open in sandbox</button>
      </div>

      <div className="wf-toolbar" style={{ gap: 14 }}>
        <span className="sk-label" style={{ minWidth: 110 }}>Step {Math.min(stepIdx + 1, totalSteps)} / {totalSteps}</span>
        <div className="progress" style={{ flex: 1 }}>
          <i style={{ width: `${progress * 100}%` }} />
        </div>
        <button className="sk-btn sm" onClick={() => setStepIdx(0)}>↺ restart</button>
        <button className="sk-btn sm" onClick={() => setStepIdx(totalSteps)}>⏭ skip to end</button>
      </div>

      <div className="game-body instructions-grid">
        <div className="step-panel">
          <h3>Steps</h3>
          <div className="step-list">
            {set.steps.map((s, i) => {
              const cls = i < stepIdx ? 'done' : i === stepIdx ? 'current' : 'upcoming';
              return (
                <div key={i} className={`step ${cls}`} onClick={() => setStepIdx(i)}>
                  <span className="num">{i + 1}.</span>
                  <span>{s.name}</span>
                </div>
              );
            })}
            <div className={`step ${finished ? 'current' : 'upcoming'}`} onClick={() => setStepIdx(totalSteps)}>
              <span className="num">★</span>
              <span>Build complete!</span>
            </div>
          </div>
        </div>

        <div className="instruction-card">
          {currentStep ? (
            <>
              <div className="sk-label">Step {stepIdx + 1}</div>
              <h2 style={{ fontSize: 26, lineHeight: 1.05, marginTop: 4 }}>{currentStep.name}</h2>
              <p style={{ fontFamily: 'Kalam', fontWeight: 300, fontSize: 16, color: 'var(--ink-soft)', margin: '8px 0' }}>
                {currentStep.note}
              </p>

              <div className="sk-label" style={{ marginTop: 8 }}>Pieces for this step</div>
              <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', padding: '8px 4px' }}>
                {stepPieceSummary.map((p, i) => (
                  <div key={i} style={{ textAlign: 'center', minWidth: 60 }}>
                    <PiecePreview piece={p} color={p.color} />
                    <div className="sk-label tiny" style={{ marginTop: 4 }}>
                      {p.type !== 'brick' ? `${p.type} ` : ''}{p.w}×{p.d} · ×{p.count}
                    </div>
                  </div>
                ))}
              </div>

              <div className="sk-label" style={{ marginTop: 8 }}>Exact placements</div>
              <ol className="step-coords">
                {stepCoords.map((s, i) => (
                  <li key={i}>
                    Place <b>{s.sizeStr} {s.color} {s.typeStr}</b> at <b>{s.coord}</b>
                    {s.layer > 1 && <span className="sk-label tiny"> · layer {s.layer}</span>}
                  </li>
                ))}
              </ol>

              <div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
                <button className="sk-btn" onClick={prev} disabled={stepIdx === 0}>← previous</button>
                <button className="sk-btn primary lg" onClick={next}>place + next →</button>
              </div>
              <div className="sk-label tiny" style={{ marginTop: 6 }}>
                Ghost bricks show where this step's pieces go. Use orbit / top-down to look around.
              </div>
            </>
          ) : (
            <>
              <h2 style={{ fontSize: 36 }}>Done!</h2>
              <p style={{ fontFamily: 'Kalam', fontSize: 16 }}>
                You built the entire {set.name}.
              </p>
              <button className="sk-btn lg primary" onClick={onSwitchToSandbox}>open in sandbox</button>
            </>
          )}
        </div>

        <div className="viewport" style={{ position: 'relative' }}>
          <div className="vp-hud">live build · {placedBricks.length} of {allBricks.length} pieces placed</div>
          <CameraControls view={view} setView={setView}
                          yawDeg={yawDeg} setYawDeg={setYawDeg}
                          zoom={zoom} setZoom={setZoom} maxZ={maxZ}
                          showGrid={showGrid} setShowGrid={setShowGrid}
                          topView={topView} setTopView={setTopView} />
          {topView ? (
            <TopDownScene
              bricks={sceneBricks}
              baseSize={set.baseSize}
              origin={{ x: 0, y: 0 }}
              view={view}
              viewBox={viewBox}
              style={{ width: '100%', height: '100%', display: 'block' }}
              showGrid={showGrid}
            />
          ) : (
            <IsoScene
              bricks={sceneBricks}
              baseSize={set.baseSize}
              baseColor={set.baseColor}
              origin={{ x: 0, y: 0 }}
              view={view}
              yawDeg={yawDeg}
              setYawDeg={setYawDeg}
              viewBox={viewBox}
              style={{ width: '100%', height: '100%', display: 'block' }}
              showGrid={showGrid}
            />
          )}
        </div>
      </div>
    </div>
  );
}

// ============================================================
// Sets library home
// ============================================================
function HomeScreen({ onOpenSandbox, onOpenDetail, onOpenInstructions }) {
  const setList = Object.values(SETS);
  const starters = setList.filter(s => s.starter);
  const hp = setList.filter(s => !s.starter);
  const savedBuild = (() => {
    try {
      const raw = localStorage.getItem('legodigital:sandbox');
      if (!raw) return null;
      const data = JSON.parse(raw);
      if (data && data.bricks && data.bricks.length > 0) return data;
    } catch (e) {}
    return null;
  })();

  return (
    <div className="home">
      <div className="home-hero">
        <div>
          <h1 style={{ fontSize: 56, lineHeight: 1 }}>LegoDigital</h1>
          <div className="sk-label" style={{ marginTop: 6 }}>
            Snap virtual bricks. Build the wizarding world. Or whatever you want.
          </div>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          <button className="sk-btn primary lg" onClick={() => onOpenSandbox({ fresh: true })}>+ New Sandbox</button>
          {savedBuild && (
            <button className="sk-btn" onClick={() => onOpenSandbox({ fresh: false })}>
              ↩ Resume last sandbox ({savedBuild.bricks.length} pcs)
            </button>
          )}
        </div>
      </div>

      <div style={{ display: 'flex', alignItems: 'baseline', gap: 14, margin: '24px 0 12px' }}>
        <h2 style={{ fontSize: 32 }}>Starter Builds</h2>
        <span className="sk-label tiny">{starters.length} quick builds · learn the basics</span>
      </div>
      <div className="set-grid">
        {starters.map(set => (
          <SetCard key={set.id} set={set}
            onClick={() => onOpenDetail(set.id)}
            onStartBuild={() => onOpenInstructions(set.id)} />
        ))}
      </div>

      <div style={{ display: 'flex', alignItems: 'baseline', gap: 14, margin: '32px 0 12px' }}>
        <h2 style={{ fontSize: 32 }}>★ Harry Potter Playables</h2>
        <span className="sk-label tiny">{hp.length} fan builds · guided steps</span>
      </div>
      <div className="set-grid">
        {hp.map(set => (
          <SetCard key={set.id} set={set}
            onClick={() => onOpenDetail(set.id)}
            onStartBuild={() => onOpenInstructions(set.id)} />
        ))}
      </div>

      <div className="footnote" style={{ marginTop: 32 }}>
        ✎ Free sandbox plus {starters.length} starter builds and {hp.length} Harry Potter–inspired guided sets.
        Open a card for instructions or sandbox. Orbit with Q/E or Shift-drag; rotate pieces with R;
        download/upload JSON; windows, doors, slopes, wheels, cones, and minifigs included.
      </div>
    </div>
  );
}

function SetCard({ set, onClick, onStartBuild }) {
  const previewBricks = useMemo(() => flattenSet(set), [set]);
  const maxZ = useMemo(() => previewBricks.reduce((m, b) => Math.max(m, b.z + brickH(b)), 4) + 2, [previewBricks]);
  // Thumbnails use the same nice default 3D yaw as the sandbox + generous
  // padding so the whole build fits inside the card without clipping.
  const previewYaw = 25;
  const viewBox = computeIsoViewBox(previewBricks, set.baseSize, 0, {
    maxZ, yawDeg: previewYaw, padX: 55, padY: 55, minScale: 0.5, square: true,
  });

  return (
    <div className="set-card" onClick={onClick} style={{ cursor: 'pointer' }}>
      <div className="ribbon">{set.diff >= 4 ? 'Hero build' : 'Quick'}</div>
      <div className="thumb thumb-iso">
        <IsoScene
          bricks={previewBricks}
          baseSize={set.baseSize}
          baseColor={set.baseColor}
          origin={{ x: 0, y: 0 }}
          yawDeg={previewYaw}
          viewBox={viewBox}
          style={{ width: '100%', height: '100%' }}
        />
      </div>
      <div className="body">
        <h3>{set.name}</h3>
        <div className="sk-label tiny">{set.sub}</div>
        <div className="stats">
          <span>pieces · <b>{previewBricks.length}</b></span>
          <span>build · <b>{set.time}</b></span>
          <span>diff · <b>{'★'.repeat(set.diff)}{'☆'.repeat(5 - set.diff)}</b></span>
        </div>
        <div className="actions">
          <button className="sk-btn primary" onClick={e => {
            e.stopPropagation();
            (onStartBuild || onClick)();
          }}>▶ Start Build</button>
          <span className="sk-label tiny" style={{ marginLeft: 'auto' }}>★ 4.{set.diff + 4}</span>
        </div>
      </div>
    </div>
  );
}

function SetDetail({ setId, onBack, onStartInstructions, onOpenInSandbox }) {
  const set = SETS[setId];
  const previewBricks = useMemo(() => flattenSet(set), [set]);
  const maxZ = useMemo(() => previewBricks.reduce((m, b) => Math.max(m, b.z + brickH(b)), 4) + 2, [previewBricks]);
  const [view, setView] = useState(0);
  const [yawDeg, setYawDeg] = useState(25);
  const [zoom, setZoom] = useState(1);
  const [showGrid, setShowGrid] = useState(false);
  const [topView, setTopView] = useState(false);
  const isoVB = computeIsoViewBox(previewBricks, set.baseSize, view, {
    maxZ: maxZ / zoom, padX: 60 / zoom, padY: 60 / zoom,
    camera: createCamera({ yawDeg, zoom }),
    stableOrbit: true,
  });
  const topVB = computeTopViewBox(viewedBaseSize(set.baseSize, view));
  const viewBox = topView ? topVB : isoVB;
  const progress = parseInt(localStorage.getItem(`legodigital:progress:${set.id}`) || '0', 10);

  return (
    <div className="detail-frame">
      <div className="game-top">
        <button className="sk-btn sm" onClick={onBack}>← back to library</button>
        <span className="file-name">{set.name}</span>
      </div>
      <div className="detail-body">
        <div className="detail-hero" style={{ position: 'relative' }}>
          <CameraControls view={view} setView={setView}
                          yawDeg={yawDeg} setYawDeg={setYawDeg}
                          zoom={zoom} setZoom={setZoom} maxZ={maxZ}
                          showGrid={showGrid} setShowGrid={setShowGrid}
                          topView={topView} setTopView={setTopView} />
          {topView ? (
            <TopDownScene
              bricks={previewBricks}
              baseSize={set.baseSize}
              origin={{ x: 0, y: 0 }}
              view={view}
              viewBox={viewBox}
              style={{ width: '100%', height: '100%' }}
              showGrid={showGrid}
            />
          ) : (
            <IsoScene
              bricks={previewBricks}
              baseSize={set.baseSize}
              baseColor={set.baseColor}
              origin={{ x: 0, y: 0 }}
              yawDeg={yawDeg}
              setYawDeg={setYawDeg}
              view={view}
              viewBox={viewBox}
              style={{ width: '100%', height: '100%' }}
              showGrid={showGrid}
            />
          )}
        </div>
        <div className="detail-info">
          <h2 style={{ fontSize: 48, lineHeight: 1 }}>{set.name}</h2>
          <div className="sk-label">{set.sub}</div>
          <p style={{ fontFamily: 'Kalam', fontWeight: 300, fontSize: 17, color: 'var(--ink-soft)', maxWidth: 420 }}>
            {set.summary}
          </p>

          <div className="sk-box" style={{ padding: 14, background: 'var(--paper-2)' }}>
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
              <Stat label="pieces" value={previewBricks.length.toLocaleString()} />
              <Stat label="est. build" value={set.time} />
              <Stat label="difficulty" value={'★'.repeat(set.diff) + '☆'.repeat(5 - set.diff)} />
              <Stat label="steps" value={set.steps.length} />
              <Stat label="base size" value={`${set.baseSize.w} × ${set.baseSize.d}`} />
              <Stat label="mode" value={set.starter ? 'starter' : 'guided'} />
            </div>
          </div>

          <div>
            <div className="sk-label">Start as:</div>
            <div style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
              <button className="sk-btn primary lg" onClick={onStartInstructions}>📖 Guided Instructions</button>
              <button className="sk-btn lg" onClick={onOpenInSandbox}>🛠 Open in Sandbox</button>
            </div>
          </div>

          {progress > 0 && progress < set.steps.length && (
            <div className="sk-box thin" style={{ padding: 10, display: 'flex', alignItems: 'center', gap: 12 }}>
              <div style={{ flex: 1 }}>
                <div style={{ fontFamily: 'Patrick Hand', fontSize: 16, fontWeight: 700 }}>Resume your build</div>
                <div className="sk-label tiny">step {progress} of {set.steps.length}</div>
                <div className="progress" style={{ marginTop: 4 }}>
                  <i style={{ width: `${(progress / set.steps.length) * 100}%` }} />
                </div>
              </div>
              <button className="sk-btn go" onClick={onStartInstructions}>resume →</button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function Stat({ label, value }) {
  return (
    <div>
      <div className="sk-label tiny" style={{ textTransform: 'uppercase', letterSpacing: 1 }}>{label}</div>
      <div style={{ fontFamily: 'Patrick Hand', fontSize: 19, fontWeight: 700 }}>{value}</div>
    </div>
  );
}

// ============================================================
// Root App
// ============================================================
function App() {
  const [route, setRoute] = useState({ kind: 'home' });

  const openSandbox = ({ fresh, initial, name, initialBase } = {}) => {
    if (fresh) localStorage.removeItem('legodigital:sandbox');
    setRoute({
      kind: 'sandbox',
      initial: initial || [],
      name: name || 'untitled-build',
      initialBase: initialBase || SANDBOX_BASE,
    });
  };
  const openDetail = id => setRoute({ kind: 'detail', id });
  const openInstructions = id => setRoute({ kind: 'instructions', id });
  const openSetInSandbox = (id) => {
    const set = SETS[id];
    const initial = flattenSet(set).map(b => ({ ...b, id: 'sb-' + b.id }));
    openSandbox({ fresh: true, initial, name: set.id, initialBase: set.baseSize });
  };

  let body;
  if (route.kind === 'home') {
    body = <HomeScreen
      onOpenSandbox={openSandbox}
      onOpenDetail={openDetail}
      onOpenInstructions={openInstructions} />;
  } else if (route.kind === 'detail') {
    body = <SetDetail setId={route.id}
      onBack={() => setRoute({ kind: 'home' })}
      onStartInstructions={() => openInstructions(route.id)}
      onOpenInSandbox={() => openSetInSandbox(route.id)}
    />;
  } else if (route.kind === 'sandbox') {
    body = <SandboxScreen
      initialBricks={route.initial}
      initialBase={route.initialBase || SANDBOX_BASE}
      setName={route.name}
      onExit={() => setRoute({ kind: 'home' })} />;
  } else if (route.kind === 'instructions') {
    body = <InstructionsScreen
      set={SETS[route.id]}
      onExit={() => setRoute({ kind: 'home' })}
      onSwitchToSandbox={() => openSetInSandbox(route.id)} />;
  }

  return (
    <div className="app">
      <header className="app-header">
        <div className="shell-brand" onClick={() => setRoute({ kind: 'home' })} style={{ cursor: 'pointer' }}>
          <span className="brick-logo" />
          LegoDigital
        </div>
        <div className="app-nav">
          <span className={route.kind === 'home' ? 'on' : ''} onClick={() => setRoute({ kind: 'home' })}>Library</span>
          <span className={route.kind === 'sandbox' ? 'on' : ''} onClick={() => openSandbox({ fresh: false })}>Sandbox</span>
        </div>
      </header>
      <main className="app-main">{body}</main>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
