// BugHunt.jsx — the hero's interactive tube.
// A Tempest-style tube drawn as a slow, looping vector piece. Bugs climb the
// lanes toward the rim; the hackhack scanner rides the rim and finds them, and
// every found bug flies out of the hero and lands on the top row of the
// findings log below. The flight is drawn on a fixed overlay that spans the
// viewport, so it crosses the section boundary; the log steps a row when it
// hears the `hackhack:bug-filed` event. It plays itself until you click; then
// it is a run, in levels like Tempest: every RIM_LEVEL_SIZE bugs found the rim
// morphs to a new shape and the tube gets faster and busier. You have three
// lives: a shot that hits nothing costs one, and so does a bug that reaches
// you — bugs that make it to the rim cling to it and hunt along it toward
// the scanner, and can still be shot there.

const RIM_LANES = 16;
const RIM_RINGS = 10;
const RIM_FAR = 0.08;                     // scale of the far end of the tube
const RIM_K = 1 / RIM_FAR - 1;
const RIM_TAU = Math.PI * 2;
const RIM_MAX_BUGS = 11;
const RIM_SHOT_SPEED = 2.6;          // tube lengths per second
const RIM_LEVEL_SIZE = 6;            // bugs found per level
const RIM_LEVEL_MORPH = 1.2;         // seconds the rim takes to change shape at a level-up
const RIM_LIVES = 3;
const RIM_RUN_START = [0.12, 0.24, 0.38]; // a gentle, far-away opening wave
const RIM_WIDE_BREAKPOINT = 1000;
const RIM_SHARE_URL = 'https://hackhack.ai/bug-hunt/';
const RIM_CARD_PATH = '/api/bug-hunt-card';
const RIM_LOG_ROW = '.feed-slide tbody tr';
const RIM_FILED_EVENT = 'hackhack:bug-filed';
const RIM_COLORS = { line: '#ebf3ff', accent: '#7effa4', bug: '#ff5362' };
const RIM_SEVERITIES = ['low', 'medium', 'medium', 'high', 'high', 'critical'];
const RIM_SEVERITY_SIZE = { low: 0.8, medium: 1, high: 1.15, critical: 1.35 };

// Rim shapes, morphed one into the next: hold, then a smooth two-second morph.
const rimAngle = (index) => -Math.PI / 2 + (index * RIM_TAU) / RIM_LANES;
const RIM_SHAPES = [
  { name: 'circle', at: (i) => { const a = rimAngle(i); return [Math.cos(a), Math.sin(a)]; } },
  { name: 'square', at: (i) => { const a = rimAngle(i); const c = Math.cos(a); const s = Math.sin(a); const m = Math.max(Math.abs(c), Math.abs(s)); return [(c / m) * 0.84, (s / m) * 0.84]; } },
  { name: 'star', at: (i) => { const a = rimAngle(i); const r = i % 2 ? 0.6 : 1.06; return [Math.cos(a) * r, Math.sin(a) * r]; } },
  { name: 'diamond', at: (i) => { const a = rimAngle(i); const c = Math.cos(a); const s = Math.sin(a); const m = Math.abs(c) + Math.abs(s); return [(c / m) * 1.08, (s / m) * 1.08]; } },
  { name: 'peanut', at: (i) => { const a = rimAngle(i); const r = 1 - 0.3 * Math.cos(2 * a); return [Math.cos(a) * r, Math.sin(a) * r]; } },
].map((shape) => ({ name: shape.name, vertices: Array.from({ length: RIM_LANES }, (_, i) => shape.at(i)) }));
const RIM_HOLD = 4.6;
const RIM_MORPH = 2.2;
const RIM_PERIOD = RIM_HOLD + RIM_MORPH;

const rimSmoother = (u) => u * u * u * (u * (u * 6 - 15) + 10);
const rimSmooth = (u) => u * u * (3 - 2 * u);
const rimWrap = (i) => ((i % RIM_LANES) + RIM_LANES) % RIM_LANES;
const rimShortest = (from, to) => ((((to - from) % RIM_LANES) + RIM_LANES * 1.5) % RIM_LANES) - RIM_LANES / 2;
const rimScaleAt = (depth) => 1 / (1 + RIM_K * (1 - depth));

function BugHunt() {
  const stageRef = React.useRef(null);
  const canvasRef = React.useRef(null);
  const flightRef = React.useRef(null);
  const [phase, setPhase] = React.useState('attract'); // attract | playing | over
  const retryRef = React.useRef(null);
  const [score, setScore] = React.useState(0);
  const [lives, setLives] = React.useState(RIM_LIVES);
  const [level, setLevel] = React.useState(1);
  const [coarsePointer, setCoarsePointer] = React.useState(false);

  React.useEffect(() => {
    const stage = stageRef.current;
    const canvas = canvasRef.current;
    const flight = flightRef.current;
    const context = canvas?.getContext('2d');
    const flightContext = flight?.getContext('2d');
    if (!stage || !canvas || !flight || !context || !flightContext) return undefined;
    const hero = stage.closest('.hero-rim');
    const copy = hero?.querySelector('.hero-rim-copy');
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    setCoarsePointer(window.matchMedia('(pointer: coarse)').matches);

    const logo = new Image();
    logo.src = '/assets/hackhack/logo.svg';

    // Deterministic randomness, so attract mode looks the same on every visit.
    let seed = 11;
    const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;

    // ---------- layout ----------
    let width = 0;
    let height = 0;
    let radius = 100;
    const rim = { x: 0, y: 0 };
    const vanish = { x: 0, y: 0 };
    const resize = () => {
      const rect = stage.getBoundingClientRect();
      width = rect.width;
      height = rect.height;
      const ratio = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      if (window.innerWidth >= RIM_WIDE_BREAKPOINT && copy) {
        const copyRect = copy.getBoundingClientRect();
        const left = copyRect.right - rect.left + 16;
        const right = width - (copyRect.left - rect.left);
        rim.x = (left + right) / 2;
        rim.y = height * 0.47;
        radius = Math.max(90, Math.min(height * 0.37, (right - left) / 2 / 1.2));
      } else {
        // Stacked layout: tube high in the stage, HUD and hint beneath it.
        rim.x = width * 0.5;
        rim.y = height * 0.43;
        radius = Math.min(height * 0.33, width * 0.42);
      }
      // The HUD and hint centre under the tube, wherever it ended up.
      stage.style.setProperty('--rim-center', `${rim.x}px`);
    };
    // The flight overlay covers the viewport; flights are kept in page
    // coordinates so scrolling mid-flight does not tear them.
    let viewWidth = 0;
    let viewHeight = 0;
    let flightsDirty = false;
    const resizeFlights = () => {
      viewWidth = window.innerWidth;
      viewHeight = window.innerHeight;
      const ratio = Math.min(2, window.devicePixelRatio || 1);
      flight.width = Math.round(viewWidth * ratio);
      flight.height = Math.round(viewHeight * ratio);
      flightContext.setTransform(ratio, 0, 0, ratio, 0, 0);
      flightsDirty = true;
    };
    // Where a zapped bug lands: the top row of the findings log, measured live
    // because the log slides as rows arrive. Falls back to just below the stage.
    const landing = (out) => {
      const row = document.querySelector(RIM_LOG_ROW);
      if (row) {
        const rowRect = row.getBoundingClientRect();
        out.x = rowRect.left + Math.min(rowRect.width * 0.5, 220) + window.scrollX;
        out.y = rowRect.top + rowRect.height * 0.5 + window.scrollY;
        return out;
      }
      const rect = stage.getBoundingClientRect();
      out.x = rect.left + rim.x + window.scrollX;
      out.y = rect.bottom + 160 + window.scrollY;
      return out;
    };
    const stagePoint = (local, out) => {
      const rect = stage.getBoundingClientRect();
      out.x = rect.left + local.x + window.scrollX;
      out.y = rect.top + local.y + window.scrollY;
      return out;
    };

    // ---------- rim morphing ----------
    const vertices = RIM_SHAPES[0].vertices.map((vertex) => vertex.slice());
    // During a run the shape is set by the level: at each level-up the rim
    // morphs from wherever it is to the next shape. Attract mode cycles freely.
    let levelMorph = null; // { from, to, start }
    let lastTilt = 0;
    const morphTo = (shapeIndex, time) => {
      // Snapshot the current shape with the tilt removed; the tilt is re-applied on top.
      const cosTilt = Math.cos(-lastTilt);
      const sinTilt = Math.sin(-lastTilt);
      const from = vertices.map(([x, y]) => [x * cosTilt - y * sinTilt, x * sinTilt + y * cosTilt]);
      levelMorph = { from, to: RIM_SHAPES[shapeIndex % RIM_SHAPES.length].vertices, start: time };
    };
    const updateVertices = (time) => {
      let from;
      let to;
      let mix;
      if (levelMorph) {
        from = levelMorph.from;
        to = levelMorph.to;
        mix = rimSmoother(Math.min(1, (time - levelMorph.start) / RIM_LEVEL_MORPH));
      } else {
        const cycle = Math.floor(time / RIM_PERIOD);
        const shapeIndex = cycle % RIM_SHAPES.length;
        const phaseTime = time - cycle * RIM_PERIOD;
        mix = phaseTime < RIM_HOLD ? 0 : rimSmoother((phaseTime - RIM_HOLD) / RIM_MORPH);
        from = RIM_SHAPES[shapeIndex].vertices;
        to = RIM_SHAPES[(shapeIndex + 1) % RIM_SHAPES.length].vertices;
      }
      const tilt = 0.32 * Math.sin(time * 0.21);
      lastTilt = tilt;
      const cosTilt = Math.cos(tilt);
      const sinTilt = Math.sin(tilt);
      for (let i = 0; i < RIM_LANES; i += 1) {
        const x = from[i][0] + (to[i][0] - from[i][0]) * mix;
        const y = from[i][1] + (to[i][1] - from[i][1]) * mix;
        vertices[i][0] = x * cosTilt - y * sinTilt;
        vertices[i][1] = x * sinTilt + y * cosTilt;
      }
    };

    // ---------- projection ----------
    const project = (index, depth, out) => {
      const scale = rimScaleAt(depth);
      const vertex = vertices[rimWrap(index)];
      out.x = vanish.x + (rim.x - vanish.x) * scale + vertex[0] * radius * scale;
      out.y = vanish.y + (rim.y - vanish.y) * scale + vertex[1] * radius * scale;
      out.s = scale;
      return out;
    };
    const scratchA = { x: 0, y: 0, s: 0 };
    const scratchB = { x: 0, y: 0, s: 0 };
    const projectFractional = (laneF, depth, out) => {
      const base = Math.floor(laneF);
      const fraction = laneF - base;
      project(base, depth, scratchA);
      project(base + 1, depth, scratchB);
      out.x = scratchA.x + (scratchB.x - scratchA.x) * fraction;
      out.y = scratchA.y + (scratchB.y - scratchA.y) * fraction;
      out.s = scratchA.s;
      return out;
    };

    // ---------- state ----------
    const bugs = [];
    const shots = [];
    const bursts = [];
    const flashes = [];
    const packets = [];
    const pagePoint = { x: 0, y: 0 };
    let clawLane = 4;
    let targetLane = 4;
    let shotCooldown = 0;
    let autoFireTimer = 0;
    let spawnTimer = 1.0;
    let userUntil = -1;
    let simTime = 0;
    let playing = false;
    let frozen = false; // after a lost run: the tube holds still until Retry
    let playTime = 0;
    let difficulty = 0; // 0 in attract mode; grows by one per level during a run
    let rimPulse = 0;   // level-up flash on the rim
    let runScore = 0;
    let runLevel = 1;
    let runLives = RIM_LIVES;
    const pointer = { x: 0, y: 0, down: false };

    const spawnBug = (startScale) => {
      if (bugs.length >= Math.min(24, RIM_MAX_BUGS + Math.round(2 * difficulty))) return;
      const severity = RIM_SEVERITIES[Math.floor(random() * RIM_SEVERITIES.length)];
      const scale = startScale ?? RIM_FAR;
      bugs.push({
        lane: Math.floor(random() * RIM_LANES),
        s: scale,
        d: 1 - (1 / scale - 1) / RIM_K,
        speed: 0.075 + random() * 0.035,
        flip: null,
        riding: false, // clinging to the rim, hunting along it
        phase: random() * RIM_TAU,
        nextFlip: (2 + random() * 5) * Math.max(0.3, 1 - 0.1 * difficulty),
        severity,
        size: RIM_SEVERITY_SIZE[severity],
      });
    };
    [0.3, 0.45, 0.6, 0.72, 0.85].forEach((scale) => spawnBug(scale));
    const bugLane = (bug) => (bug.flip && bug.flip.t > 0.5 ? bug.flip.to : bug.lane);
    const bugLaneF = (bug) => (bug.flip ? bug.lane + 0.5 + bug.flip.dir * rimSmooth(bug.flip.t) : bug.lane + 0.5);

    // A counted shot is a promise: if it hits nothing, it costs a life. Shots
    // leave from the lane you aimed at, not from wherever the claw is on its
    // way there, so a tap or click always fires where you pointed.
    const fire = (counts, lane = clawLane) => {
      if (shotCooldown > 0) return;
      shotCooldown = 0.14;
      shots.push({ lane: rimWrap(Math.round(lane)), d: 1, prev: 1, counts: Boolean(counts && playing) });
    };
    const laneFromPointer = (x, y) => {
      const pointerAngle = Math.atan2(y - rim.y, x - rim.x);
      let best = 0;
      let bestDiff = Infinity;
      for (let i = 0; i < RIM_LANES; i += 1) {
        projectFractional(i + 0.5, 1, scratchA);
        const angle = Math.atan2(scratchA.y - rim.y, scratchA.x - rim.x);
        let diff = Math.abs(angle - pointerAngle);
        if (diff > Math.PI) diff = RIM_TAU - diff;
        if (diff < bestDiff) { bestDiff = diff; best = i; }
      }
      return best;
    };

    // ---------- input ----------
    let lastInputWall = -1e9;
    const markUser = () => { userUntil = simTime + 2.6; lastInputWall = performance.now(); };
    // Starts a run from a clean tube: whatever attract mode had in flight is
    // cleared and a gentle wave starts far away. Returns true if a run began.
    const takeControl = () => {
      if (playing) return false;
      playing = true;
      frozen = false;
      playTime = 0;
      difficulty = 0;
      runScore = 0;
      runLevel = 1;
      runLives = RIM_LIVES;
      morphTo(0, simTime);
      bugs.length = 0;
      shots.length = 0;
      bursts.length = 0;
      flashes.length = 0;
      spawnTimer = 1.4;
      RIM_RUN_START.forEach((scale) => spawnBug(scale));
      setPhase('playing');
      setScore(0);
      setLevel(1);
      setLives(RIM_LIVES);
      return true;
    };
    const levelUp = () => {
      runLevel += 1;
      rimPulse = 1;
      morphTo(runLevel - 1, simTime);
      setLevel(runLevel);
    };
    const endRun = () => {
      playing = false;
      frozen = true;
      difficulty = 0;
      userUntil = -1;
      setPhase('over');
    };
    const loseLife = () => {
      runLives -= 1;
      setLives(runLives);
      if (runLives <= 0) endRun();
    };
    const retry = () => {
      takeControl();
      markUser();
      canvas.focus({ preventScroll: true });
    };
    retryRef.current = retry;
    const readPointer = (event) => {
      const rect = stage.getBoundingClientRect();
      pointer.x = event.clientX - rect.left;
      pointer.y = event.clientY - rect.top;
    };
    // A touch fires on release, and only if it was a tap: a swipe that turns
    // into a page scroll must not cost a shot. A mouse fires on press.
    let touchTap = null;
    const onPointerMove = (event) => { if (frozen) return; readPointer(event); targetLane = laneFromPointer(pointer.x, pointer.y); markUser(); };
    const onPointerDown = (event) => {
      if (frozen || event.target.closest('a, button')) return;
      readPointer(event);
      targetLane = laneFromPointer(pointer.x, pointer.y);
      pointer.down = true;
      markUser();
      if (event.pointerType === 'touch') {
        touchTap = { x: event.clientX, y: event.clientY, at: performance.now() };
        return;
      }
      const started = takeControl();
      fire(!started, targetLane);
      canvas.focus({ preventScroll: true });
    };
    const onPointerUp = (event) => {
      pointer.down = false;
      if (!touchTap) return;
      const tap = touchTap;
      touchTap = null;
      if (frozen) return;
      const moved = Math.hypot(event.clientX - tap.x, event.clientY - tap.y);
      if (moved > 14 || performance.now() - tap.at > 400) return;
      readPointer(event);
      targetLane = laneFromPointer(pointer.x, pointer.y);
      clawLane = targetLane; // no hover on touch: jump to the tapped lane
      markUser();
      const started = takeControl();
      fire(!started, targetLane);
    };
    const onPointerCancel = () => { pointer.down = false; touchTap = null; };
    const onPointerLeave = () => { userUntil = simTime + 0.8; };
    const onKeyDown = (event) => {
      if (frozen) {
        if (event.key === ' ' || event.key === 'Enter') { retry(); event.preventDefault(); }
        return;
      }
      if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { targetLane = rimWrap(targetLane - 1); markUser(); takeControl(); event.preventDefault(); }
      else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { targetLane = rimWrap(targetLane + 1); markUser(); takeControl(); event.preventDefault(); }
      else if (event.key === ' ' || event.key === 'Enter') { markUser(); const started = takeControl(); fire(!started, targetLane); event.preventDefault(); }
    };
    stage.addEventListener('pointermove', onPointerMove);
    stage.addEventListener('pointerdown', onPointerDown);
    stage.addEventListener('pointerleave', onPointerLeave);
    window.addEventListener('pointerup', onPointerUp);
    window.addEventListener('pointercancel', onPointerCancel);
    canvas.addEventListener('keydown', onKeyDown);

    // ---------- simulation ----------
    const land = { x: 0, y: 0 };
    // Flights keep moving even while the tube itself is scrolled out of view.
    const stepFlights = (dt) => {
      for (let i = packets.length - 1; i >= 0; i -= 1) {
        const packet = packets[i];
        packet.t += dt / packet.duration;
        if (packet.t > 0.2) { landing(land); packet.x1 = land.x; packet.y1 = land.y; }
        if (packet.t >= 1) {
          window.dispatchEvent(new CustomEvent(RIM_FILED_EVENT, { detail: { severity: packet.severity, playing } }));
          if (playing) {
            runScore += 1;
            setScore(runScore);
            if (runScore % RIM_LEVEL_SIZE === 0) levelUp();
          }
          packets.splice(i, 1);
          flightsDirty = true;
        }
      }
    };
    const step = (dt) => {
      simTime += dt;
      updateVertices(simTime);
      vanish.x = rim.x + radius * 0.45 * Math.sin(simTime * 0.19);
      vanish.y = rim.y + radius * 0.30 * Math.sin(simTime * 0.27 + 1.3);

      if (playing) {
        playTime += dt;
        // Levels drive the ramp, with a slow drift so a cautious run still tightens.
        difficulty = (runLevel - 1) + (runScore % RIM_LEVEL_SIZE) / RIM_LEVEL_SIZE + playTime / 90;
      }
      rimPulse = Math.max(0, rimPulse - dt / 0.9);

      // Attract mode aims itself and fires only when lined up. During a run
      // the scanner is yours alone: nothing fires unless you do.
      const userActive = simTime < userUntil;
      const autopilot = !playing && !userActive;
      if (autopilot) {
        let top = null;
        bugs.forEach((bug) => { if (!top || bug.d > top.d) top = bug; });
        targetLane = top ? bugLane(top) : rimWrap(Math.round(RIM_LANES / 2 + (RIM_LANES / 2) * Math.sin(simTime * 0.35)));
      }
      clawLane = rimWrap(clawLane + rimShortest(clawLane, targetLane) * (1 - Math.exp(-dt * 11)));
      const clawIndex = rimWrap(Math.round(clawLane));

      shotCooldown -= dt;
      autoFireTimer -= dt;
      if (!playing && autoFireTimer <= 0) {
        const aligned = bugs.some((bug) => bugLane(bug) === clawIndex) && Math.abs(rimShortest(clawLane, clawIndex)) < 0.3;
        if (aligned) { fire(false); autoFireTimer = 0.35; }
      }

      spawnTimer -= dt;
      if (spawnTimer <= 0) { spawnBug(); spawnTimer = (1.0 + random() * 1.2) * Math.max(0.22, 1 - 0.12 * difficulty); }

      const pace = Math.min(4, 1 + 0.35 * difficulty);
      const riderFlip = Math.max(0.22, 0.5 - 0.03 * difficulty); // seconds per lane along the rim
      for (let i = bugs.length - 1; i >= 0; i -= 1) {
        const bug = bugs[i];
        if (bug.riding) {
          // On the rim: hunt lane by lane toward the scanner. Reaching it costs a life.
          if (bug.flip) {
            bug.flip.t += dt / riderFlip;
            if (bug.flip.t >= 1) { bug.lane = bug.flip.to; bug.flip = null; bug.nextFlip = 0.12; }
          } else if (bug.lane === clawIndex) {
            flashes.push({ lane: bug.lane, t: 0 });
            bugs.splice(i, 1);
            if (playing) loseLife();
            continue;
          } else {
            bug.nextFlip -= dt;
            if (bug.nextFlip <= 0) {
              const dir = rimShortest(bug.lane, clawIndex) > 0 ? 1 : -1;
              bug.flip = { to: rimWrap(bug.lane + dir), dir, t: 0 };
            }
          }
          continue;
        }
        bug.s = Math.min(1, bug.s + bug.speed * pace * dt);
        bug.d = 1 - (1 / bug.s - 1) / RIM_K;
        if (bug.flip) {
          bug.flip.t += dt / 0.6;
          if (bug.flip.t >= 1) { bug.lane = bug.flip.to; bug.flip = null; }
        } else {
          bug.nextFlip -= dt;
          if (bug.nextFlip <= 0 && bug.d < 0.9) {
            const dir = random() < 0.5 ? -1 : 1;
            bug.flip = { to: rimWrap(bug.lane + dir), dir, t: 0 };
            bug.nextFlip = (2 + random() * 5) * Math.max(0.3, 1 - 0.1 * difficulty);
          }
        }
        if (bug.d >= 1 && !bug.flip) {
          // Made it to the rim: it clings on and starts hunting.
          bug.riding = true;
          bug.d = 1;
          bug.s = 1;
          bug.nextFlip = 0.2;
          flashes.push({ lane: bug.lane, t: 0 });
        }
      }

      for (let i = shots.length - 1; i >= 0; i -= 1) {
        const shot = shots[i];
        shot.prev = shot.d;
        shot.d -= RIM_SHOT_SPEED * dt;
        let hit = false;
        for (let j = bugs.length - 1; j >= 0; j -= 1) {
          const bug = bugs[j];
          if (bugLane(bug) !== shot.lane) continue;
          if (shot.prev >= bug.d - 0.01 && shot.d <= bug.d + 0.02) {
            projectFractional(bugLaneF(bug), bug.d, scratchA);
            bursts.push({ x: scratchA.x, y: scratchA.y, s: scratchA.s, t: 0, rot: bug.phase });
            landing(land);
            stagePoint(scratchA, pagePoint);
            const distance = Math.hypot(land.x - pagePoint.x, land.y - pagePoint.y);
            packets.push({
              x0: pagePoint.x, y0: pagePoint.y, x1: land.x, y1: land.y, t: -0.1,
              duration: Math.min(1.5, 0.6 + distance / 1400), severity: bug.severity, size: bug.size,
            });
            bugs.splice(j, 1);
            hit = true;
            break;
          }
        }
        if (hit || shot.d < 0) shots.splice(i, 1);
        if (!hit && shot.d < 0 && shot.counts && playing) {
          bursts.push({ x: vanish.x, y: vanish.y, s: 0.5, t: 0, rot: 0, color: RIM_COLORS.bug });
          loseLife();
        }
      }

      stepFlights(dt);
      for (let i = bursts.length - 1; i >= 0; i -= 1) { bursts[i].t += dt / 0.55; if (bursts[i].t >= 1) bursts.splice(i, 1); }
      for (let i = flashes.length - 1; i >= 0; i -= 1) { flashes[i].t += dt / 0.7; if (flashes[i].t >= 1) flashes.splice(i, 1); }
    };

    // ---------- drawing ----------
    const p0 = { x: 0, y: 0, s: 0 };
    const p1 = { x: 0, y: 0, s: 0 };
    const p2 = { x: 0, y: 0, s: 0 };
    const p3 = { x: 0, y: 0, s: 0 };
    const pm = { x: 0, y: 0, s: 0 };
    const ringPath = (depth) => {
      context.beginPath();
      for (let i = 0; i < RIM_LANES; i += 1) {
        project(i, depth, p0);
        if (i === 0) context.moveTo(p0.x, p0.y);
        else context.lineTo(p0.x, p0.y);
      }
      context.closePath();
    };

    const draw = () => {
      context.clearRect(0, 0, width, height);
      context.lineJoin = 'round';
      context.lineCap = 'round';

      // Rings, with a brightness wave sweeping outward through them.
      context.strokeStyle = RIM_COLORS.line;
      context.lineWidth = 1;
      for (let r = 0; r < RIM_RINGS; r += 1) {
        const depth = r / RIM_RINGS;
        const scale = rimScaleAt(depth);
        const wave = Math.cos(RIM_TAU * (depth - simTime * 0.18));
        context.globalAlpha = 0.10 + 0.22 * scale + (wave > 0 ? wave * wave * wave * wave * 0.35 : 0);
        ringPath(depth);
        context.stroke();
      }
      context.globalAlpha = 0.32;
      context.beginPath();
      for (let i = 0; i < RIM_LANES; i += 1) {
        project(i, 0, p0);
        project(i, 1, p1);
        context.moveTo(p0.x, p0.y);
        context.lineTo(p1.x, p1.y);
      }
      context.stroke();
      context.globalAlpha = 0.95;
      context.lineWidth = 2;
      ringPath(1);
      context.stroke();
      if (rimPulse > 0) {
        // Level-up: the rim flares mint and settles.
        context.strokeStyle = RIM_COLORS.accent;
        context.globalAlpha = rimPulse * 0.9;
        context.lineWidth = 2 + 10 * (1 - rimPulse);
        ringPath(1);
        context.stroke();
        context.strokeStyle = RIM_COLORS.line;
      }
      context.fillStyle = RIM_COLORS.line;
      for (let i = 0; i < RIM_LANES; i += 1) {
        project(i, 1, p0);
        context.beginPath();
        context.arc(p0.x, p0.y, 2.4, 0, RIM_TAU);
        context.fill();
      }

      // A bug that reached the rim lights its segment red.
      flashes.forEach((flash) => {
        context.globalAlpha = 1 - rimSmooth(flash.t);
        context.strokeStyle = RIM_COLORS.bug;
        context.lineWidth = 4 + 6 * flash.t;
        project(flash.lane, 1, p0);
        project(flash.lane + 1, 1, p1);
        context.beginPath();
        context.moveTo(p0.x, p0.y);
        context.lineTo(p1.x, p1.y);
        context.stroke();
      });

      // Bugs, far first; the one in the scanner's lane gets a lock-on bracket.
      const clawIndex = rimWrap(Math.round(clawLane));
      const locked = Math.abs(rimShortest(clawLane, clawIndex)) < 0.35;
      bugs.sort((a, b) => a.d - b.d);
      bugs.forEach((bug) => {
        const laneF = bugLaneF(bug);
        projectFractional(laneF, bug.d, p0);
        project(bug.lane, bug.d, p1);
        project(bug.lane + 1, bug.d, p2);
        const tangent = Math.atan2(p2.y - p1.y, p2.x - p1.x);
        projectFractional(laneF, 1, p3);
        const nx = p3.x - p0.x;
        const ny = p3.y - p0.y;
        const upSign = Math.sign(-Math.sin(tangent) * nx + Math.cos(tangent) * ny) || 1;
        const flipRotation = bug.flip ? bug.flip.dir * rimSmooth(bug.flip.t) * Math.PI : 0;
        const size = (4.5 + 13 * p0.s) * bug.size;
        const w = size;
        const h = size * 0.55 * (1 + 0.18 * Math.sin(simTime * 7 + bug.phase));
        context.save();
        context.translate(p0.x, p0.y);
        context.rotate(tangent + flipRotation);
        context.globalAlpha = 0.35 + 0.65 * Math.min(1, p0.s * 3);
        context.fillStyle = RIM_COLORS.bug;
        context.strokeStyle = RIM_COLORS.bug;
        context.lineWidth = 1.2;
        context.beginPath();
        context.moveTo(-w, -h); context.lineTo(0, 0); context.lineTo(-w, h); context.closePath();
        context.moveTo(w, -h); context.lineTo(0, 0); context.lineTo(w, h); context.closePath();
        context.fill();
        context.beginPath();
        context.moveTo(-w * 0.25, upSign * h * 0.4); context.lineTo(-w * 0.55, upSign * h * 2.1);
        context.moveTo(w * 0.25, upSign * h * 0.4); context.lineTo(w * 0.55, upSign * h * 2.1);
        context.stroke();
        context.restore();

        if (locked && bugLane(bug) === clawIndex && p0.s > 0.2) {
          const r = size * 2.1 + 4;
          const c = r * 0.45;
          context.save();
          context.translate(p0.x, p0.y);
          context.strokeStyle = RIM_COLORS.accent;
          context.lineWidth = 1.5;
          context.globalAlpha = 0.9;
          context.beginPath();
          context.moveTo(-r, -r + c); context.lineTo(-r, -r); context.lineTo(-r + c, -r);
          context.moveTo(r - c, -r); context.lineTo(r, -r); context.lineTo(r, -r + c);
          context.moveTo(r, r - c); context.lineTo(r, r); context.lineTo(r - c, r);
          context.moveTo(-r + c, r); context.lineTo(-r, r); context.lineTo(-r, r - c);
          context.stroke();
          context.restore();
        }
      });

      shots.forEach((shot) => {
        projectFractional(shot.lane + 0.5, Math.max(0, shot.d), p0);
        projectFractional(shot.lane + 0.5, Math.min(1, shot.d + 0.06), p1);
        context.strokeStyle = RIM_COLORS.accent;
        context.globalAlpha = 0.25;
        context.lineWidth = 7;
        context.beginPath(); context.moveTo(p0.x, p0.y); context.lineTo(p1.x, p1.y); context.stroke();
        context.globalAlpha = 1;
        context.lineWidth = 2;
        context.beginPath(); context.moveTo(p0.x, p0.y); context.lineTo(p1.x, p1.y); context.stroke();
      });

      bursts.forEach((burst) => {
        const eased = 1 - Math.pow(1 - burst.t, 3);
        const rad = (4 + 34 * Math.max(0.35, burst.s)) * eased;
        context.globalAlpha = 1 - burst.t;
        context.fillStyle = burst.color || RIM_COLORS.line;
        context.strokeStyle = burst.color || RIM_COLORS.line;
        context.lineWidth = 1.5;
        context.beginPath(); context.arc(burst.x, burst.y, rad * 0.7, 0, RIM_TAU); context.stroke();
        const dot = 3.2 * (1 - burst.t) * Math.max(0.5, burst.s * 1.5);
        for (let k = 0; k < 10; k += 1) {
          const angle = burst.rot + (k * RIM_TAU) / 10;
          context.beginPath();
          context.arc(burst.x + Math.cos(angle) * rad, burst.y + Math.sin(angle) * rad, dot, 0, RIM_TAU);
          context.fill();
        }
      });

      // The scanner: a claw on the rim, with the hackhack mark riding just outside it.
      {
        const l = clawLane;
        const breathe = 0.94 + 0.03 * Math.sin(simTime * 3.1);
        projectFractional(l, 1, p0);
        projectFractional(l + 1, 1, p1);
        projectFractional(l + 0.18, breathe, p2);
        projectFractional(l + 0.82, breathe, p3);
        projectFractional(l + 0.5, breathe + 0.03, pm);
        context.strokeStyle = RIM_COLORS.accent;
        context.globalAlpha = 0.28;
        context.lineWidth = 8;
        context.beginPath(); context.moveTo(p0.x, p0.y); context.lineTo(p2.x, p2.y); context.lineTo(pm.x, pm.y); context.lineTo(p3.x, p3.y); context.lineTo(p1.x, p1.y); context.stroke();
        context.globalAlpha = 1;
        context.lineWidth = 2.5;
        context.beginPath(); context.moveTo(p0.x, p0.y); context.lineTo(p2.x, p2.y); context.lineTo(pm.x, pm.y); context.lineTo(p3.x, p3.y); context.lineTo(p1.x, p1.y); context.stroke();
        context.fillStyle = RIM_COLORS.accent;
        context.beginPath(); context.arc(p0.x, p0.y, 3.5, 0, RIM_TAU); context.fill();
        context.beginPath(); context.arc(p1.x, p1.y, 3.5, 0, RIM_TAU); context.fill();

        // Outward normal of the claw's rim segment, pointing away from the tube.
        const midX = (p0.x + p1.x) / 2;
        const midY = (p0.y + p1.y) / 2;
        let nx = -(p1.y - p0.y);
        let ny = p1.x - p0.x;
        const length = Math.hypot(nx, ny) || 1;
        nx /= length; ny /= length;
        if (nx * (midX - rim.x) + ny * (midY - rim.y) < 0) { nx = -nx; ny = -ny; }
        const logoHeight = Math.max(22, Math.min(36, radius * 0.13));
        const logoWidth = logoHeight * (151 / 118);
        const logoX = midX + nx * (logoHeight * 0.62 + 4);
        const logoY = midY + ny * (logoHeight * 0.62 + 4);
        context.globalAlpha = 0.5;
        context.lineWidth = 1.5;
        context.beginPath(); context.moveTo(midX, midY); context.lineTo(logoX - nx * logoHeight * 0.5, logoY - ny * logoHeight * 0.5); context.stroke();
        context.globalAlpha = 1;
        if (logo.complete && logo.naturalWidth > 0) {
          context.drawImage(logo, logoX - logoWidth / 2, logoY - logoHeight / 2, logoWidth, logoHeight);
        } else {
          context.beginPath(); context.arc(logoX, logoY, 6, 0, RIM_TAU); context.fill();
        }
      }
      context.globalAlpha = 1;
    };

    // Zapped bugs cross to the findings log: a red square on a bowed path with
    // a fading tail, drawn on the viewport overlay in page coordinates.
    const drawFlights = () => {
      if (!packets.length && !flightsDirty) return;
      flightsDirty = false;
      flightContext.clearRect(0, 0, viewWidth, viewHeight);
      flightContext.lineJoin = 'round';
      flightContext.lineCap = 'round';
      const scrollX = window.scrollX;
      const scrollY = window.scrollY;
      packets.forEach((packet) => {
        if (packet.t < 0) return;
        const cx = (packet.x0 + packet.x1) / 2;
        const cy = Math.min(packet.y0, packet.y1) - Math.abs(packet.x1 - packet.x0) * 0.25 - 40;
        const at = (u, out) => {
          const m = 1 - u;
          out.x = m * m * packet.x0 + 2 * m * u * cx + u * u * packet.x1 - scrollX;
          out.y = m * m * packet.y0 + 2 * m * u * cy + u * u * packet.y1 - scrollY;
        };
        const u = rimSmooth(packet.t);
        flightContext.strokeStyle = RIM_COLORS.bug;
        flightContext.lineWidth = 1.5;
        for (let k = 1; k <= 6; k += 1) {
          at(Math.max(0, u - k * 0.035), p0);
          at(Math.max(0, u - (k - 1) * 0.035), p1);
          flightContext.globalAlpha = 0.5 * (1 - k / 7);
          flightContext.beginPath(); flightContext.moveTo(p0.x, p0.y); flightContext.lineTo(p1.x, p1.y); flightContext.stroke();
        }
        at(u, pm);
        // The square shrinks into the row over the last stretch of the flight.
        const square = (4 + 3 * packet.size) * (1 - Math.max(0, (packet.t - 0.85) / 0.15));
        flightContext.save();
        flightContext.translate(pm.x, pm.y);
        flightContext.rotate(packet.t * RIM_TAU);
        flightContext.globalAlpha = 1;
        flightContext.fillStyle = RIM_COLORS.bug;
        flightContext.fillRect(-square / 2, -square / 2, square, square);
        flightContext.restore();
      });
      flightContext.globalAlpha = 1;
    };

    // ---------- loop ----------
    let last = performance.now();
    let visible = true;
    let running = !document.hidden;
    let frame = 0;
    const intersection = new IntersectionObserver((entries) => { visible = entries[0].isIntersecting; }, { threshold: 0.05 });
    intersection.observe(stage);
    const onVisibility = () => { running = !document.hidden; last = performance.now(); };
    document.addEventListener('visibilitychange', onVisibility);
    const stageObserver = new ResizeObserver(resize);
    stageObserver.observe(stage);
    if (copy) stageObserver.observe(copy);
    const tick = (now) => {
      frame = window.requestAnimationFrame(tick);
      let dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      if (!running) return;
      if (reduce && now - lastInputWall > 1500) dt = 0;
      if (!visible || frozen) {
        // Only the flights already in the air keep going.
        if (dt > 0) stepFlights(dt);
        drawFlights();
        return;
      }
      if (dt > 0) step(dt);
      draw();
      drawFlights();
    };
    window.addEventListener('resize', resizeFlights);
    resizeFlights();
    resize();
    updateVertices(0);
    vanish.x = rim.x;
    vanish.y = rim.y;
    step(0.0001);
    draw();
    frame = window.requestAnimationFrame(tick);

    return () => {
      retryRef.current = null;
      window.cancelAnimationFrame(frame);
      window.removeEventListener('resize', resizeFlights);
      intersection.disconnect();
      stageObserver.disconnect();
      document.removeEventListener('visibilitychange', onVisibility);
      stage.removeEventListener('pointermove', onPointerMove);
      stage.removeEventListener('pointerdown', onPointerDown);
      stage.removeEventListener('pointerleave', onPointerLeave);
      window.removeEventListener('pointerup', onPointerUp);
      window.removeEventListener('pointercancel', onPointerCancel);
      canvas.removeEventListener('keydown', onKeyDown);
    };
  }, []);

  const shareText = `I found ${score} ${score === 1 ? 'bug' : 'bugs'} and reached level ${level} in @hackhackai's bug hunt. Find all bugs. Make no mistakes. Can you beat my score?`;
  const sharePath = `${RIM_SHARE_URL}?score=${score}&level=${level}`;
  const shareOnX = `https://x.com/intent/post?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(sharePath)}`;
  const cardPath = `${RIM_CARD_PATH}?score=${score}&level=${level}`;

  // On phones the score card is attached to the post outright through the
  // native share sheet. Everywhere else the X intent link opens, and X pulls
  // the same card from the share page's Open Graph tags. Desktop browsers
  // (Safari on macOS included) get the link: their share sheet leads
  // to AirDrop and Mail, not to X.
  const share = async (event) => {
    if (!coarsePointer) return;
    if (typeof navigator === 'undefined' || typeof navigator.canShare !== 'function') return;
    event.preventDefault();
    try {
      const response = await fetch(cardPath);
      if (!response.ok) throw new Error('card unavailable');
      const file = new File([await response.blob()], 'hackhack-bug-hunt.png', { type: 'image/png' });
      if (navigator.canShare({ files: [file] })) {
        await navigator.share({ files: [file], text: `${shareText} ${sharePath}` });
        return;
      }
    } catch (error) {
      if (error && error.name === 'AbortError') return;
    }
    window.open(shareOnX, '_blank', 'noopener');
  };

  const shareButton = (
    <a className="rim-share" href={shareOnX} target="_blank" rel="noopener noreferrer" onClick={share}>
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
      </svg>
      Share your score
    </a>
  );

  return (
    <div className={`rim-stage${phase === 'over' ? ' is-over' : ''}`} id="bug-hunt" ref={stageRef}>
      <canvas
        ref={canvasRef}
        tabIndex="0"
        aria-label="Bug tube. Arrow keys steer the scanner, space finds the bug in its lane."
      />
      <canvas className="rim-flights" ref={flightRef} aria-hidden="true" />
      {phase === 'playing' && (
        <div className="rim-hud" aria-live="polite">
          <div className="rim-score">
            <span>found <b>{String(score).padStart(3, '0')}</b></span>
            <span>level <b>{level}</b></span>
            <span>lives <b className="rim-lives" aria-label={`${lives} of ${RIM_LIVES}`}>{'◆'.repeat(lives)}{'◇'.repeat(RIM_LIVES - lives)}</b></span>
            <span className="rim-rule">a miss costs a life · bugs on the rim hunt you</span>
          </div>
        </div>
      )}
      {phase === 'over' && (
        <div className="rim-over" role="dialog" aria-live="assertive" aria-label="Game over">
          <span className="rim-over-label">Game over · out of lives</span>
          <strong>{String(score).padStart(3, '0')}</strong>
          <span className="rim-over-sub">{score === 1 ? 'bug found' : 'bugs found'} · level {level}</span>
          <div className="rim-over-actions">
            <button type="button" className="rim-retry" onClick={() => retryRef.current?.()}>Retry</button>
            {score > 0 && shareButton}
          </div>
        </div>
      )}
      {phase === 'attract' && (
        <div className="rim-hint" aria-hidden="true">
          {coarsePointer ? <><kbd>tap</kbd> a lane to find the bug</> : <>Move to steer <kbd>click</kbd> to find</>}
        </div>
      )}
    </div>
  );
}

window.BugHunt = BugHunt;
