// Hero Scene — wave-as-water, viewport-rendered
// -----------------------------------------------
// Coordinates are in DOCUMENT space; engine gives us scrollY each frame so we
// translate doc → screen with `y - scrollY`. Canvas is viewport-only.
//
// Bubbles fall in doc-space. Below the wave surface they switch to water
// physics (slower gravity + sinusoidal lateral sway). They keep falling until
// they pass the bottom of the document.
//
// Perf principles:
//   - All bubbles ALWAYS simulate — cost is negligible and avoids "bunching"
//     when you scroll back to a region full of frozen bubbles.
//   - Canvas DRAW work is what we cull: offscreen bubbles skip arc(), wave
//     skips its polygon when it's not in the viewport.
//   - Fixed-timestep physics (60Hz) so big and small bubbles step uniformly.

const createHeroScene = () => {
  const T_ZOOM_END = 1.1;

  const BUBBLE_MIN = 10;
  const BUBBLE_MAX = 100;

  const WAVE_LEN = 380;
  const WAVE_AMP = 56;
  const WAVE_DOC_Y_FRAC = 0.60;

  const BUBBLE_CAP = 90;        // soft cap on total bubbles (incl. fading)
  const ONSCREEN_CAP = 55;      // hard protection: never fade on-screen bubbles below this count
  const FADE_DURATION = 0.6;    // seconds to shrink to zero
  const FADE_PER_FRAME = 4;     // max bubbles flagged per frame — prevents mass cull
  const EDGE_MARGIN = 80;       // px from horizontal edges = fade-priority
  const SMALL_THRESHOLD = 28;   // bubbles at/below this radius = "small" (fade first, drift down)

  const RAIN_INTERVAL = 0.9;

  // Fixed-timestep physics — 60Hz. Decouples physics from frame rate so
  // animation reads identically at 30/60/120fps and large bubbles don't
  // stutter when fps dips.
  const PHYS_HZ = 60;
  const PHYS_DT = 1 / PHYS_HZ;

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

  let bubbles = [];
  let lastRainSpawn = 0;
  let introDotConsumed = false;
  let physAccum = 0;

  // Per-frame wave constants
  let baseY = 0;
  let aBase = 0;
  let phase1 = 0;
  let phase2 = 0;

  const waveDocY = (x) =>
    baseY
    + Math.sin(x / WAVE_LEN + phase1) * aBase
    + Math.sin(x / (WAVE_LEN * 0.47) - phase2) * (aBase * 0.3);

  const drawWave = (ctx, viewW, viewH, scrollY) => {
    ctx.beginPath();
    const steps = 48;
    const bottomScreenY = viewH + 200;
    ctx.moveTo(-40, bottomScreenY);
    for (let i = 0; i <= steps; i++) {
      const x = (viewW + 80) * (i / steps) - 40;
      ctx.lineTo(x, waveDocY(x) - scrollY);
    }
    ctx.lineTo(viewW + 40, bottomScreenY);
    ctx.closePath();
    ctx.fill();
  };

  const spawnBubble = (viewW, tNow) => {
    const r = BUBBLE_MIN + rand() * (BUBBLE_MAX - BUBBLE_MIN);
    bubbles.push({
      x: rand() * viewW,
      y: -r - 20,
      vx: (rand() - 0.5) * 12,
      vy: 50 + rand() * 70,
      r,
      r0: r,                // original radius for shrink interpolation
      driftSeed: rand() * 1000,
      driftAmp: 18 + rand() * 30,
      bornAt: tNow,
      fadeStart: 0,         // 0 = not fading
    });
  };

  // Auto-fade bubbles that have fully drifted off the horizontal edges.
  // This is cheap and clears slots *before* cullPopulation has to make
  // harder choices — users never see them disappear because they're already gone.
  const flagOffscreenHorizontal = (tNow, viewW) => {
    for (let i = 0; i < bubbles.length; i++) {
      const b = bubbles[i];
      if (b.fadeStart) continue;
      if (b.x + b.r < 0 || b.x - b.r > viewW) {
        b.fadeStart = tNow;
      }
    }
  };

  // Population control — the tricky part.
  //
  // Two problems we're solving:
  //   1. On-screen bubbles must NOT visibly pop. Ever.
  //   2. When the cap is hit, flagging 40 bubbles at once makes them all
  //      fade over the same 0.6s — looks like a mass extinction.
  //
  // Strategy:
  //   • Classify each bubble: onScreen / aboveViewport / belowViewport.
  //   • Above-viewport bubbles are already scrolled past — safest to fade.
  //   • Below-viewport bubbles will become visible as user scrolls — protect them.
  //   • On-screen bubbles get the strongest protection.
  //   • Cap the number of NEW fades per frame (FADE_PER_FRAME). Even if we're
  //     way over cap, we trickle-fade rather than mass-fade. This keeps the
  //     visual rhythm smooth.
  //   • Separate cap for on-screen bubbles: don't let the on-screen count
  //     balloon past ONSCREEN_CAP. If it does, fade the smallest/oldest
  //     on-screen bubbles one per several frames.
  const cullPopulation = (tNow, scrollY, viewH, viewW) => {
    // Count active (non-fading) bubbles; bucket by location.
    let activeCount = 0;
    let onScreenCount = 0;
    const above = [];      // already scrolled past — cheapest to fade
    const below = [];      // will be visible soon — protect
    const onScreen = [];   // currently visible — heaviest protection

    for (let i = 0; i < bubbles.length; i++) {
      const b = bubbles[i];
      if (b.fadeStart) continue;
      activeCount++;
      const sy = b.y - scrollY;
      if (sy + b.r < 0) {
        above.push(b);
      } else if (sy - b.r > viewH) {
        below.push(b);
      } else {
        onScreen.push(b);
        onScreenCount++;
      }
    }

    if (activeCount <= BUBBLE_CAP && onScreenCount <= ONSCREEN_CAP) return;

    let budget = FADE_PER_FRAME;

    // Phase 1 — fade above-viewport bubbles first. They're gone anyway.
    // Older = lower score = first to go.
    if (budget > 0 && above.length > 0) {
      above.sort((a, c) => a.bornAt - c.bornAt);
      const need = activeCount - BUBBLE_CAP;
      const take = Math.min(budget, above.length, Math.max(need, 0));
      for (let i = 0; i < take; i++) {
        above[i].fadeStart = tNow;
        budget--;
        activeCount--;
      }
    }

    // Phase 2 — if still over cap, fade below-viewport bubbles (user hasn't
    // seen them yet, so no visual pop). Prefer small + edge bubbles.
    if (budget > 0 && activeCount > BUBBLE_CAP && below.length > 0) {
      below.sort((a, c) => {
        const sA = (a.r0 <= SMALL_THRESHOLD ? 0 : 1000) + a.r0 * 3 + a.bornAt;
        const sB = (c.r0 <= SMALL_THRESHOLD ? 0 : 1000) + c.r0 * 3 + c.bornAt;
        return sA - sB;
      });
      const need = activeCount - BUBBLE_CAP;
      const take = Math.min(budget, below.length, need);
      for (let i = 0; i < take; i++) {
        below[i].fadeStart = tNow;
        budget--;
        activeCount--;
      }
    }

    // Phase 3 — only if on-screen bubbles exceed THEIR cap, trickle-fade one
    // small on-screen bubble per frame. Never touches large ones.
    if (budget > 0 && onScreenCount > ONSCREEN_CAP) {
      const smalls = onScreen.filter((b) => b.r0 <= SMALL_THRESHOLD);
      if (smalls.length > 0) {
        smalls.sort((a, c) => a.bornAt - c.bornAt);
        // At most ONE on-screen fade per frame, regardless of budget —
        // so users never see a cluster dissolve.
        smalls[0].fadeStart = tNow;
      }
    }
  };

  // One physics step at fixed PHYS_DT. Simulates EVERY bubble — no viewport
  // band gating. Cost is tiny (~20 bubbles, no allocations) and prevents
  // "frozen bubble" pile-ups when scrolling back to a region.
  const stepPhysics = (tNow, docMaxY) => {
    const dt = PHYS_DT;
    const alive = [];
    for (let i = 0; i < bubbles.length; i++) {
      const b = bubbles[i];

      // Fade-shrink: gracefully shrink to zero, then cull. Small bubbles
      // also gain a gentle downward sink during fade so they feel like
      // popped bubbles dissolving, not abruptly cut.
      if (b.fadeStart) {
        const k = (tNow - b.fadeStart) / FADE_DURATION;
        if (k >= 1) continue; // dead
        b.r = b.r0 * (1 - k);
        if (b.r0 <= SMALL_THRESHOLD) {
          // small bubble "pop-sink": extra downward velocity + less sway
          b.vy += 90 * dt;
          b.vx *= 0.9;
        }
      }

      // Kill once past document bottom
      if (b.y - b.r > docMaxY) continue;

      const sizeNorm = (b.r - BUBBLE_MIN) / (BUBBLE_MAX - BUBBLE_MIN);
      const surface = waveDocY(b.x);
      const inWater = b.y > surface;

      let gravity, vTerm;
      if (inWater) {
        gravity = (360 - sizeNorm * 240) * 0.45;
        vTerm   = 50 + (1 - sizeNorm) * 90;
        const sway = Math.sin(tNow * 0.7 + b.driftSeed) * b.driftAmp;
        b.vx = b.vx * 0.92 + sway * 0.08;
      } else {
        gravity = 360 - sizeNorm * 240;
        vTerm   = 160 + (1 - sizeNorm) * 380;
        b.vx *= 0.995;
      }

      b.vy += gravity * dt;
      if (b.vy > vTerm) b.vy = vTerm;
      b.x += b.vx * dt;
      b.y += b.vy * dt;

      alive.push(b);
    }
    bubbles = alive;
  };

  const render = (ctx, info) => {
    const { viewW, viewH, t, dt, scrollY } = info;

    aBase = WAVE_AMP * (0.65 + 0.35 * Math.sin(t * 0.22));
    baseY = viewH * WAVE_DOC_Y_FRAC;
    phase1 = t * 0.35;
    phase2 = t * 0.55;

    // Wave is drawn only when its band intersects the viewport. Wave occupies
    // doc Y from (baseY - aBase*1.3) downward — so as long as that's above
    // the viewport bottom, draw it.
    const waveTopDoc = baseY - aBase * 1.3;
    if (waveTopDoc < scrollY + viewH) {
      drawWave(ctx, viewW, viewH, scrollY);
    }

    if (!introDotConsumed) {
      const cx = viewW / 2;
      const cyDoc = viewH / 2;
      if (t < T_ZOOM_END) {
        const p = Math.min(1, Math.max(0, (t - 0.2) / (T_ZOOM_END - 0.2)));
        const eased = 1 - Math.pow(1 - p, 3);
        // Start the dot LARGER than the viewport so it fills the entire
        // screen on frame one (no visible edges) and then shrinks down to
        // BUBBLE_MIN. Using max(viewW, viewH) ensures coverage on any
        // aspect ratio; the 0.75 multiplier gives a 50% bleed past the
        // longest edge so even rotated/oversized viewports start fully
        // black.
        const startR = Math.max(viewW, viewH) * 0.75;
        const r = startR + (BUBBLE_MIN - startR) * eased;
        const sy = cyDoc - scrollY;
        if (sy + r >= 0 && sy - r <= viewH) {
          ctx.beginPath();
          ctx.arc(cx, sy, r, 0, Math.PI * 2);
          ctx.fill();
        }
      } else {
        const tf = t - T_ZOOM_END;
        const gy = 1400;
        const yDoc = cyDoc + 0.5 * gy * tf * tf;
        const surfDoc = waveDocY(cx);
        if (yDoc >= surfDoc) {
          bubbles.push({
            x: cx, y: surfDoc,
            vx: (rand() - 0.5) * 8,
            vy: gy * tf * 0.4,
            r: BUBBLE_MIN,
            r0: BUBBLE_MIN,
            driftSeed: rand() * 1000,
            driftAmp: 24,
            bornAt: t,
            fadeStart: 0,
          });
          introDotConsumed = true;
        } else {
          const sy = yDoc - scrollY;
          if (sy + BUBBLE_MIN >= 0 && sy - BUBBLE_MIN <= viewH) {
            ctx.beginPath();
            ctx.arc(cx, sy, BUBBLE_MIN, 0, Math.PI * 2);
            ctx.fill();
          }
        }
      }
    }

    if (introDotConsumed && t - lastRainSpawn > RAIN_INTERVAL) {
      spawnBubble(viewW, t);
      lastRainSpawn = t;
    }

    // Flag horizontally-escaped bubbles BEFORE cullPopulation — they're
    // already invisible, so fading them is free and clears slots first.
    flagOffscreenHorizontal(t, viewW);

    // Population control — runs once per render frame, cheap.
    cullPopulation(t, scrollY, viewH, viewW);

    // Document max Y — bubbles that pass this are killed. Generous cushion
    // so they keep simulating well past the visible end.
    const docMaxY = Math.max(
      window.innerHeight,
      document.documentElement.scrollHeight
    ) + viewH;

    physAccum += dt;
    let steps = 0;
    while (physAccum >= PHYS_DT && steps < 4) {
      stepPhysics(t, docMaxY);
      physAccum -= PHYS_DT;
      steps++;
    }
    if (physAccum > PHYS_DT * 4) physAccum = 0;

    // Draw — only bubbles inside the viewport.
    for (let i = 0; i < bubbles.length; i++) {
      const b = bubbles[i];
      const sy = b.y - scrollY;
      if (sy + b.r >= 0 && sy - b.r <= viewH) {
        ctx.beginPath();
        ctx.arc(b.x, sy, b.r, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  };

  return { id: 'hero', render, reset: () => {
    // Called when the page becomes visible again after being hidden, or
    // restored from the browser back-forward cache (BFCache). Without this,
    // bubbles that were spawned before the page was backgrounded sit in
    // their pre-pause positions while the new frame's `t` jumps forward,
    // creating a visible stack of frozen circles in the same spot. Wiping
    // the simulation state forces a clean restart.
    bubbles = [];
    lastRainSpawn = 0;
    introDotConsumed = false;
    physAccum = 0;
  } };
};

window.createHeroScene = createHeroScene;
