// Cube3D.jsx — the Qubikos Cube, rendered with real WebGL (three.js).
//
// Loaded lazily: the three.js script tag is only injected once this
// section actually approaches the viewport, so nobody pays the ~600KB
// cost unless they scroll this far. No image assets, no glTF model —
// every icon is drawn procedurally onto a small canvas texture at
// runtime and every "glow" is a real emissive material, not a bloom
// postprocessing pass (kept out on purpose to avoid pulling in
// three/examples addons, which only ship as ES modules).
//
// Modeled on the approved reference: a staircase of individually
// floating labeled modules (not a connected Rubik's block) cascading
// from a small "Q / QUBIKOS" crown at the top, through the eight
// business types, down through a large glowing "AI" centerpiece, to
// the six operational tiles at the bottom. Three accent-color
// families carry the same grouping as the reference: cyan (core
// business + tutoring/private lessons), violet (dance/music/
// fitness/AI), gold (the operational row). Every tile shows both an
// icon and a text label.
//
// Tuned for real render size, not just zoomed-in screenshots: tiles
// are packed tight (see U below) and sized up so labels are actually
// legible at the ~150-200px a tile occupies on a real page, not just
// in a cropped close-up. The reference's branching lightning around
// AI isn't attempted here — no postprocessing pipeline in this stack
// makes a decent approximation harder to sell than a plain pulsing
// glow, so that's what AI gets instead.

const { useState: _g3UseS, useEffect: _g3UseE, useRef: _g3UseR } = React;

// Real pictograms — path data from Tabler Icons (MIT licensed), same
// outline style already used elsewhere on this site. Each icon is an
// array of subpaths since most of these are built from several
// strokes, not one.
const CUBE_ICON_PATHS = {
  gymnastics: [
    'M7 7a1 1 0 1 0 2 0a1 1 0 0 0 -2 0',
    'M13 21l1 -9l7 -6',
    'M3 11h6l5 1',
    'M11.5 8.5l4.5 -3.5',
  ],
  shirt: [
    'M15 4l6 2v5h-3v8a1 1 0 0 1 -1 1h-10a1 1 0 0 1 -1 -1v-8h-3v-5l6 -2a3 3 0 0 0 6 0',
  ],
  swimming: [
    'M15 9a1 1 0 1 0 2 0a1 1 0 1 0 -2 0',
    'M6 11l4 -2l3.5 3l-1.5 2',
    'M3 16.75a2.4 2.4 0 0 0 1 .25a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 1 -.25',
  ],
  music: [
    'M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0',
    'M13 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0',
    'M9 17v-13h10v13',
    'M9 8h10',
  ],
  book: [
    'M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0',
    'M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0',
    'M3 6l0 13',
    'M12 6l0 13',
    'M21 6l0 13',
  ],
  user: [
    'M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0',
    'M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2',
  ],
  'user-check': [
    'M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0',
    'M6 21v-2a4 4 0 0 1 4 -4h4',
    'M15 19l2 2l4 -4',
  ],
  barbell: [
    'M2 12h1',
    'M6 8h-2a1 1 0 0 0 -1 1v6a1 1 0 0 0 1 1h2',
    'M6 7v10a1 1 0 0 0 1 1h1a1 1 0 0 0 1 -1v-10a1 1 0 0 0 -1 -1h-1a1 1 0 0 0 -1 1',
    'M9 12h6',
    'M15 7v10a1 1 0 0 0 1 1h1a1 1 0 0 0 1 -1v-10a1 1 0 0 0 -1 -1h-1a1 1 0 0 0 -1 1',
    'M18 8h2a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-2',
    'M22 12h-1',
  ],
  calendar: [
    'M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12',
    'M16 3v4', 'M8 3v4', 'M4 11h16', 'M11 15h1', 'M12 15v3',
  ],
  'credit-card': [
    'M3 8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -8',
    'M3 10l18 0',
    'M7 15l.01 0',
    'M11 15l2 0',
  ],
  'chart-bar': [
    'M3 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -6',
    'M15 9a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -10',
    'M9 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -14',
    'M4 20h14',
  ],
  'chart-pie': [
    'M10 3.2a9 9 0 1 0 10.8 10.8a1 1 0 0 0 -1 -1h-6.8a2 2 0 0 1 -2 -2v-7a.9 .9 0 0 0 -1 -.8',
    'M15 3.5a9 9 0 0 1 5.5 5.5h-4.5a1 1 0 0 1 -1 -1v-4.5',
  ],
  'message-dots': [
    'M12 11v.01', 'M8 11v.01', 'M16 11v.01',
    'M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3l12 0',
  ],
  sparkles: [
    'M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6',
  ],
};

// No Tabler icon reads as "dance" specifically, so this one stays
// hand-drawn. Four attempts (a kick, an arabesque outline, a raised-
// arms-plus-skirt silhouette, crossed ballet shoes) all blurred into
// an unreadable blob at real icon size — anything with two shapes
// overlapping near the center collapses into one abstract mass this
// small. This is the "disco point" — one arm up, one hand on the
// hip, feet apart — arguably the single most recognized dance
// silhouette there is, and every stroke here stays clear of every
// other one, unlike the earlier attempts.
function drawDancer(ctx) {
  ctx.beginPath(); ctx.arc(10.5, 4, 1.6, 0, Math.PI * 2); ctx.fill();
  ctx.beginPath();
  ctx.moveTo(10.8, 5.6); ctx.lineTo(12, 14);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(11.5, 7.8); ctx.lineTo(19.5, 2);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(11, 9.5); ctx.lineTo(6.5, 12.5);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(12, 14); ctx.lineTo(9.5, 21.5);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(12, 14); ctx.lineTo(18, 18.5);
  ctx.stroke();
}

const CYAN = 0x4dd2ff;
const VIOLET = 0xb46bff;
const GOLD = 0xffb347;
const Q_COLOR = 0xd070ff; // exact middle stop of the "One operating system." gradient
const AI_COLOR = 0xb9c8ff;

// Evenly-spaced x offsets for a row of n tiles, centered on 0.
function rowX(n, spacing) {
  const out = [];
  for (let i = 0; i < n; i++) out.push((i - (n - 1) / 2) * spacing);
  return out;
}

const U = 1.32; // base spacing unit — tight enough that the tiles read
// as one dense object instead of scattered boxes, while cellSize
// (below, in initScene) is big enough to make the labels legible at
// the size this actually renders on a real page.
const T1_Y = 2.05 * U, T1_Z = -2.05 * U;
const T2_Y = 0.55 * U, T2_Z = -0.65 * U;
const T3_Y = -1.05 * U, T3_Z = 0.85 * U;
const T4_Y = -2.55 * U, T4_Z = 2.35 * U;
const [t1x0, t1x1, t1x2, t1x3] = rowX(4, 2.05 * U);
const [t2x0, , t2x2] = rowX(3, 2.55 * U);
const [t3x0, t3x1, t3x2, t3x3, t3x4] = rowX(5, 2.0 * U);
const [t4x0, t4x1, t4x2] = rowX(3, 2.3 * U);

// The staircase, top business row down to the bottom operational row.
// AI and Q are built separately below — they're special-cased (much
// bigger, their own lights, no plain label treatment).
const TILES = [
  { key:'gym',     label:'GYMNASTICS',      icon:'gymnastics',   color:CYAN,   x:t1x0, y:T1_Y, z:T1_Z },
  { key:'mart',    label:'MARTIAL ARTS',    icon:'shirt',        color:CYAN,   x:t1x1, y:T1_Y, z:T1_Z },
  { key:'swim',    label:'SWIMMING',        icon:'swimming',     color:CYAN,   x:t1x2, y:T1_Y, z:T1_Z },
  { key:'dance',   label:'DANCE',           icon:'dancer',       color:VIOLET, x:t1x3, y:T1_Y, z:T1_Z },

  { key:'tutor',   label:'TUTORING',        icon:'book',         color:CYAN,   x:t2x0, y:T2_Y, z:T2_Z },
  { key:'music',   label:'MUSIC',           icon:'music',        color:VIOLET, x:t2x2, y:T2_Y, z:T2_Z },

  { key:'private', label:'PRIVATE LESSONS', icon:'user',         color:CYAN,   x:t3x0, y:T3_Y, z:T3_Z },
  { key:'sched',   label:'SCHEDULE',        icon:'calendar',     color:GOLD,   x:t3x1, y:T3_Y, z:T3_Z },
  { key:'pay',     label:'PAYMENTS',        icon:'credit-card',  color:GOLD,   x:t3x2, y:T3_Y, z:T3_Z },
  { key:'att',     label:'ATTENDANCE',      icon:'user-check',   color:GOLD,   x:t3x3, y:T3_Y, z:T3_Z },
  { key:'fit',     label:'FITNESS',         icon:'barbell',      color:VIOLET, x:t3x4, y:T3_Y, z:T3_Z },

  { key:'prog',    label:'PROGRESS',        icon:'chart-bar',    color:GOLD,   x:t4x0, y:T4_Y, z:T4_Z },
  { key:'comm',    label:'COMMUNICATION',   icon:'message-dots', color:GOLD,   x:t4x1, y:T4_Y, z:T4_Z },
  { key:'insight', label:'INSIGHTS',        icon:'chart-pie',    color:GOLD,   x:t4x2, y:T4_Y, z:T4_Z },
];

// Assembly order: the business row lands first, AI ignites once
// tutoring/music are in place, then the operational rows cascade in
// below, and Q crowns the whole thing last.
const CONTENT_ORDER = [
  'gym', 'mart', 'swim', 'dance', 'tutor', 'music', 'ai',
  'private', 'sched', 'pay', 'att', 'fit',
  'prog', 'comm', 'insight', 'core',
];

let threeLoadPromise = null;
function loadThree() {
  if (window.THREE) return Promise.resolve(window.THREE);
  if (threeLoadPromise) return threeLoadPromise;
  threeLoadPromise = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = 'https://unpkg.com/three@0.149.0/build/three.min.js';
    s.async = true;
    s.onload = () => resolve(window.THREE);
    s.onerror = reject;
    document.head.appendChild(s);
  });
  return threeLoadPromise;
}

function hexToCss(hex) {
  return '#' + hex.toString(16).padStart(6, '0');
}

// A true rounded-corner box, not just a bevel texture trick — each
// vertex of a subdivided BoxGeometry gets clamped to an inner box and
// pushed back out to `radius`, the same technique three.js's own
// RoundedBoxGeometry addon uses. Written by hand because that addon
// only ships as an ES module and doesn't fit this global-script setup.
function makeRoundedBoxGeometry(THREE, size, radius, segments) {
  const geo = new THREE.BoxGeometry(size, size, size, segments, segments, segments);
  const pos = geo.attributes.position;
  const inner = size / 2 - radius;
  for (let i = 0; i < pos.count; i++) {
    const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
    const cx = Math.max(-inner, Math.min(inner, x));
    const cy = Math.max(-inner, Math.min(inner, y));
    const cz = Math.max(-inner, Math.min(inner, z));
    const dx = x - cx, dy = y - cy, dz = z - cz;
    const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
    if (d > 1e-6) {
      const s = radius / d;
      pos.setXYZ(i, cx + dx * s, cy + dy * s, cz + dz * s);
    }
  }
  geo.computeVertexNormals();
  return geo;
}

// A soft radial-gradient sprite, additively blended — the cheap way
// to fake a bloom halo around AI/Q without a full postprocessing
// pipeline (three/examples addons only ship as ES modules and don't
// fit this global-script setup).
function makeGlowSprite(THREE, colorHex, worldSize, localZOffset) {
  const r = (colorHex >> 16) & 255, g = (colorHex >> 8) & 255, b = colorHex & 255;
  const size = 128;
  const canvas = document.createElement('canvas');
  canvas.width = size; canvas.height = size;
  const ctx = canvas.getContext('2d');
  const grad = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
  grad.addColorStop(0, `rgba(${r},${g},${b},0.85)`);
  grad.addColorStop(0.4, `rgba(${r},${g},${b},0.32)`);
  grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, size, size);
  const tex = new THREE.CanvasTexture(canvas);
  const mat = new THREE.SpriteMaterial({
    map: tex, transparent: true, depthWrite: false,
    blending: THREE.AdditiveBlending, opacity: 0.9,
  });
  const sprite = new THREE.Sprite(mat);
  sprite.scale.set(worldSize, worldSize, 1);
  if (localZOffset) sprite.position.z = localZOffset;
  return sprite;
}

// Fits a label into one or two lines within maxWidth: two-word labels
// (MARTIAL ARTS, PRIVATE LESSONS) split at the space; single-word
// labels too wide to fit (COMMUNICATION) shrink until they do.
function fitLabel(ctx, label, maxWidth, baseSize) {
  ctx.font = `800 ${baseSize}px Arial, sans-serif`;
  if (ctx.measureText(label).width <= maxWidth) return { lines: [label], size: baseSize };
  if (label.includes(' ')) {
    const words = label.split(' ');
    const mid = Math.ceil(words.length / 2);
    const l1 = words.slice(0, mid).join(' ');
    const l2 = words.slice(mid).join(' ');
    const size2 = baseSize * 0.9;
    ctx.font = `800 ${size2}px Arial, sans-serif`;
    if (ctx.measureText(l1).width <= maxWidth && ctx.measureText(l2).width <= maxWidth) {
      return { lines: [l1, l2], size: size2 };
    }
  }
  let size = baseSize;
  ctx.font = `800 ${size}px Arial, sans-serif`;
  while (size > 13 && ctx.measureText(label).width > maxWidth) {
    size -= 1;
    ctx.font = `800 ${size}px Arial, sans-serif`;
  }
  return { lines: [label], size };
}

function fillTextSpaced(ctx, text, cx, y, spacing) {
  const chars = [...text];
  const widths = chars.map((ch) => ctx.measureText(ch).width);
  const total = widths.reduce((a, b) => a + b, 0) + spacing * (chars.length - 1);
  let x = cx - total / 2;
  chars.forEach((ch, i) => { ctx.fillText(ch, x + widths[i] / 2, y); x += widths[i] + spacing; });
}

// The icon is drawn once, crisp, no canvas blur — a blurred glow pass
// merges tightly-packed lines into unreadable blobs. The "glow"
// instead comes from the material's emissive channel (see
// makeFaceMat) — self-illuminated color against a near-black tile
// reads as lit without softening the shape.
function makeTileTexture(THREE, kind, colorHex, opts = {}) {
  const size = 320;
  const canvas = document.createElement('canvas');
  canvas.width = size; canvas.height = size;
  const ctx = canvas.getContext('2d');
  ctx.clearRect(0, 0, size, size);

  const css = hexToCss(colorHex);
  ctx.fillStyle = css;
  ctx.strokeStyle = css;
  ctx.textAlign = 'center';

  if (kind === 'Q') {
    // Geist is the site's own display face (used for every heading),
    // the closest available match to the wordmark logotype itself —
    // that one's a custom image, not a loadable font.
    ctx.font = `700 ${size * 0.42}px Geist, Arial, sans-serif`;
    ctx.textBaseline = 'middle';
    ctx.fillText('Q', size / 2, size * 0.42);
    ctx.font = `600 ${size * 0.09}px Geist, Arial, sans-serif`;
    fillTextSpaced(ctx, 'QUBIKOS', size / 2, size * 0.72, size * 0.035);
  } else if (kind === 'ai') {
    ctx.save();
    const s = size / 24;
    ctx.translate(size / 2 - 12 * s * 0.55, size * 0.19 - 12 * s * 0.55);
    ctx.scale(s * 0.55, s * 0.55);
    ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
    CUBE_ICON_PATHS.sparkles.forEach((d) => ctx.stroke(new Path2D(d)));
    ctx.restore();
    ctx.font = `900 ${size * 0.42}px Arial, sans-serif`;
    ctx.textBaseline = 'middle';
    ctx.fillText('AI', size / 2, size * 0.62);
  } else {
    const s = size / 24;
    ctx.save();
    ctx.translate(size / 2 - 12 * s * 0.62, size * 0.36 - 12 * s * 0.62);
    ctx.scale(s * 0.62, s * 0.62);
    ctx.lineWidth = 2;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    if (kind === 'dancer') drawDancer(ctx);
    else CUBE_ICON_PATHS[kind].forEach((d) => ctx.stroke(new Path2D(d)));
    ctx.restore();

    const { lines, size: fontSize } = fitLabel(ctx, opts.label || '', size * 0.86, size * 0.1);
    ctx.textBaseline = 'middle';
    ctx.font = `800 ${fontSize}px Arial, sans-serif`;
    const startY = lines.length === 1 ? size * 0.78 : size * 0.735;
    lines.forEach((line, i) => ctx.fillText(line, size / 2, startY + i * fontSize * 1.15));
  }

  const tex = new THREE.CanvasTexture(canvas);
  if ('colorSpace' in tex) tex.colorSpace = THREE.SRGBColorSpace || tex.colorSpace;
  else tex.encoding = THREE.sRGBEncoding;
  tex.needsUpdate = true;
  return tex;
}

function reducedMotion3D() {
  return typeof window !== 'undefined' && window.matchMedia &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

function QubikosCube3D({ active }) {
  const mountRef = _g3UseR(null);
  const stateRef = _g3UseR({ started: false });
  const [ready, setReady] = _g3UseS(false);

  _g3UseE(() => {
    if (!active || stateRef.current.started) return;
    stateRef.current.started = true;
    let disposed = false;

    loadThree().then((THREE) => {
      if (disposed || !mountRef.current) return;
      initScene(THREE, mountRef.current);
      setReady(true);
    }).catch((err) => {
      console.error('[Cube3D] failed to load three.js:', err);
    });

    return () => { disposed = true; };
  }, [active]);

  function initScene(THREE, container) {
    const reduced = reducedMotion3D();
    const width = container.clientWidth;
    const height = container.clientHeight;

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(30, width / height, 0.1, 100);
    camera.position.set(0, 0.66, 28.5);
    camera.lookAt(0, 0.66, 0);

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    renderer.setSize(width, height);
    if ('outputColorSpace' in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
    else renderer.outputEncoding = THREE.sRGBEncoding;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.0;
    container.innerHTML = '';
    container.appendChild(renderer.domElement);

    const group = new THREE.Group();
    group.rotation.x = 0.24;
    group.rotation.y = -0.34;
    scene.add(group);

    const ambient = new THREE.AmbientLight(0x8fa3ff, 0.5);
    scene.add(ambient);
    const l1 = new THREE.PointLight(0x6d8dff, 5, 30); l1.position.set(-8, 8, 10); scene.add(l1);
    const l2 = new THREE.PointLight(0xffb347, 4, 30); l2.position.set(6, -8, 10); scene.add(l2);
    const key = new THREE.DirectionalLight(0xffffff, 0.5); key.position.set(3, 6, 12); scene.add(key);

    const sideMat = new THREE.MeshStandardMaterial({ color: 0x0c1020, metalness: 0.5, roughness: 0.4 });
    const cellSize = 2.15;

    function makeFaceMat(tex, colorHex, intensity) {
      return new THREE.MeshStandardMaterial({
        color: 0x141a30, metalness: 0.38, roughness: 0.4, map: tex,
        emissive: colorHex, emissiveMap: tex, emissiveIntensity: intensity,
      });
    }

    const cubes = [];

    function addTile({ key: tileKey, label, icon, color, x, y, z, scale = 1, kind = icon, intensity = 0.55 }) {
      const tex = makeTileTexture(THREE, kind, color, { label });
      const mat = makeFaceMat(tex, color, intensity);
      const geo = makeRoundedBoxGeometry(THREE, cellSize, cellSize * 0.12, 4);
      const mesh = new THREE.Mesh(geo, [sideMat, sideMat, sideMat, sideMat, mat, sideMat]);
      const finalPos = new THREE.Vector3(x, y, z);
      mesh.position.copy(finalPos);
      mesh.scale.setScalar(scale);
      group.add(mesh);

      // A high angle threshold keeps EdgesGeometry to just the box's
      // real 12 edges — with the corners now rounded (lots of nearly-
      // flat micro-facets), the default 1° threshold would outline
      // every one of those too and the glow would look like a wireframe.
      const edges = new THREE.LineSegments(
        new THREE.EdgesGeometry(geo, 40),
        new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.6 })
      );
      mesh.add(edges);

      const order = CONTENT_ORDER.indexOf(tileKey);
      cubes.push({ mesh, edges, finalPos, order, baseOpacity: 0.6, baseScale: scale, tileKey });
      return mesh;
    }

    TILES.forEach((t) => addTile(t));

    // AI — the centerpiece: larger, brighter, its own light that
    // pulses once it lands (a steady breathing glow reads as "alive"
    // without trying to fake branching lightning in real-time WebGL).
    const aiMesh = addTile({
      key: 'ai', label: '', icon: 'sparkles', color: AI_COLOR,
      x: 0, y: T2_Y, z: T2_Z + 1.55 * U, scale: 1.15, kind: 'ai', intensity: 1.1,
    });
    const aiMat = aiMesh.material.find((m) => m !== sideMat);
    const aiLight = new THREE.PointLight(AI_COLOR, 5, 12);
    aiLight.position.copy(aiMesh.position);
    group.add(aiLight);
    // The sprite is a child of the mesh so it tracks position through the
    // assembly animation automatically — local scale is the desired world
    // size divided by the mesh's own baseScale, since the parent's scale
    // multiplies through to children.
    const aiGlow = makeGlowSprite(THREE, AI_COLOR, (cellSize * 2.7) / 1.15, cellSize * 0.58);
    aiMesh.add(aiGlow);

    // Q — the brand crown, above and behind the top row, colored to
    // match the "One operating system." headline gradient exactly.
    // emissiveIntensity is kept modest on purpose: pushed too high,
    // ACES tone mapping blows saturated color out toward white — the
    // brightness comes from the light + glow sprite instead, so the
    // glyph itself stays visibly violet, not just a violet-tinted halo
    // around a white letter.
    const qMesh = addTile({
      key: 'core', label: '', icon: 'Q', color: Q_COLOR,
      x: 0, y: T1_Y + 1.5 * U, z: T1_Z - 0.15 * U, scale: 1.15, kind: 'Q', intensity: 0.85,
    });
    const qLight = new THREE.PointLight(Q_COLOR, 3.4, 9);
    qLight.position.set(qMesh.position.x, qMesh.position.y - 1, qMesh.position.z + 1);
    group.add(qLight);
    const qGlow = makeGlowSprite(THREE, Q_COLOR, (cellSize * 2.9) / 1.15, cellSize * 0.58);
    qMesh.add(qGlow);

    const aiCube = cubes.find((c) => c.tileKey === 'ai');
    const aiIgniteAt = aiCube.order * (reduced ? 0 : Math.min(0.1, 2.8 / CONTENT_ORDER.length)) + 0.7;
    const aiBaseIntensity = { light: 5, mat: 1.1 };

    const clock = new THREE.Clock();
    let raf = null;
    let pulseStart = null;
    const stagger = reduced ? 0 : Math.min(0.1, 2.8 / CONTENT_ORDER.length);
    cubes.forEach((c) => { c.delay = c.order * stagger; });
    const totalDelay = reduced ? 0 : CONTENT_ORDER.length * stagger + 0.5;

    cubes.forEach((c) => {
      if (reduced) { c.mesh.scale.setScalar(c.baseScale); return; }
      c.mesh.scale.setScalar(0.001);
      c.mesh.position.set(
        c.finalPos.x + (Math.random() - 0.5) * 7,
        c.finalPos.y + (Math.random() - 0.5) * 7,
        c.finalPos.z - 5 - Math.random() * 3
      );
    });
    const target = { rx: group.rotation.x, ry: group.rotation.y };
    let mouseX = 0, mouseY = 0;

    function onMove(e) {
      const rect = container.getBoundingClientRect();
      mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
      mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
    }
    function onLeave() { mouseX = 0; mouseY = 0; }
    if (!reduced) {
      container.addEventListener('mousemove', onMove);
      container.addEventListener('mouseleave', onLeave);
    }

    function animate() {
      raf = requestAnimationFrame(animate);
      const t = clock.getElapsedTime();

      cubes.forEach((c) => {
        if (reduced) return;
        const localT = Math.max(0, Math.min(1, (t - c.delay) / 0.7));
        const eased = 1 - Math.pow(1 - localT, 3);
        c.mesh.scale.setScalar((0.001 + eased * 0.999) * c.baseScale);
        c.mesh.position.lerpVectors(c.mesh.position, c.finalPos, localT >= 1 ? 1 : eased * 0.18);
      });
      aiLight.position.copy(aiMesh.position);

      if (!reduced) {
        // A slow idle drift runs all the time, on every device — the
        // cube should never sit dead still. Cursor input (desktop
        // only, mouseX/mouseY stay 0 on touch) adds a stronger tilt
        // on top of it.
        const driftX = Math.sin(t * 0.35) * 0.04;
        const driftY = Math.cos(t * 0.24) * 0.055;
        target.rx = 0.24 + driftX + mouseY * -0.09;
        target.ry = -0.34 + driftY + mouseX * 0.13;
        group.rotation.x += (target.rx - group.rotation.x) * 0.06;
        group.rotation.y += (target.ry - group.rotation.y) * 0.06;
      }

      if (t > aiIgniteAt) {
        const breathe = reduced ? 0.5 : (Math.sin((t - aiIgniteAt) * 1.6) + 1) / 2;
        aiLight.intensity = aiBaseIntensity.light * (0.75 + breathe * 0.5);
        aiMat.emissiveIntensity = aiBaseIntensity.mat * (0.85 + breathe * 0.3);
      }

      if (!reduced && pulseStart === null && t > totalDelay) pulseStart = t;
      if (pulseStart !== null) {
        const pt = Math.min(1, (t - pulseStart) / 1.4);
        const glow = pt < 1 ? Math.sin(pt * Math.PI) : 0;
        cubes.forEach((c) => { c.edges.material.opacity = c.baseOpacity + glow * 0.6; });
        ambient.intensity = 0.5 + glow * 0.4;
        aiGlow.material.opacity = 0.75 + glow * 0.5;
        qGlow.material.opacity = 0.95 + glow * 0.5;
      }

      renderer.render(scene, camera);
    }
    animate();

    function onResize() {
      if (!mountRef.current) return;
      const w = mountRef.current.clientWidth, h = mountRef.current.clientHeight;
      camera.aspect = w / h; camera.updateProjectionMatrix();
      renderer.setSize(w, h);
    }
    window.addEventListener('resize', onResize);

    stateRef.current.cleanup = () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', onResize);
      container.removeEventListener('mousemove', onMove);
      container.removeEventListener('mouseleave', onLeave);
      group.remove(qLight);
      group.remove(aiLight);
      [aiGlow, qGlow].forEach((s) => { s.material.map.dispose(); s.material.dispose(); });
      cubes.forEach((c) => {
        c.mesh.geometry.dispose();
        c.mesh.material.forEach((m) => { if (m !== sideMat) { if (m.map) m.map.dispose(); m.dispose(); } });
        c.edges.geometry.dispose(); c.edges.material.dispose();
      });
      sideMat.dispose();
      renderer.dispose();
    };
  }

  _g3UseE(() => () => { if (stateRef.current.cleanup) stateRef.current.cleanup(); }, []);

  // mountRef's contents are owned entirely by Three.js (imperative
  // appendChild), so it must never also be a React render target — the
  // loading placeholder lives in a separate sibling node instead, or
  // React's reconciler and our manual DOM writes fight over the same
  // child and throw NotFoundError on removeChild.
  //
  // The placeholder is the same still image used elsewhere as a
  // fallback — shown instantly while three.js loads, then swapped for
  // the live scene the moment it's ready, so there's never a blank
  // gap or a plain loading box.
  return (
    <div style={{ position:'relative', width: '100%', aspectRatio: '0.88', minHeight: 420 }}>
      <div ref={mountRef} style={{ position:'absolute', inset: 0 }}/>
      {!ready && (
        <div style={{ position:'absolute', inset: 0, display:'grid', placeItems:'center', padding:'0 6%' }}>
          <img
            src="assets/qubikos-cube-illustration.webp"
            alt="The Qubikos Cube"
            style={{ width: '88%', height: 'auto', display: 'block', opacity: 0.9 }}
          />
        </div>
      )}
    </div>
  );
}

window.QBCube3D = QubikosCube3D;
