/**
 * ScrollScene — a section that pins itself while a stack of full-bleed images
 * plays behind text that swaps in place.
 *
 * The root is the scroll track; the caller supplies the markup and tags it:
 *   .scroll-scene-stage  one sticky, viewport-tall stage (required)
 *   .scroll-scene-layer  N background images inside it, painted in DOM order
 *   .scroll-scene-step   N text blocks, stacked on top of each other
 *   .scroll-scene-tick   N optional progress ticks
 *
 * The track's height is stage height + N * stepVh viewports, so the stage stays
 * pinned for exactly N steps. Progress is read from the track's rect — the track
 * is never transformed, so a frame's reading can't be polluted by the previous
 * frame's output.
 *
 * Per step, in track progress t = progress * N:
 *   layer i slides up over the whole of step i-1, linearly, so the image moves
 *   with the wheel the entire time and never sits still mid-step. The layer it
 *   covers eases back by layerScale so the stack recedes into the background;
 *   the heading swaps at textSwapAt, out then in, one at a time.
 *
 * Position is taken straight from the scroll — `smoothing` at 1 means no chasing
 * and no catch-up after the wheel stops. Lower it only if a scene wants drift.
 */
const ScrollScene = ({
  children,
  className = '',
  stepVh = 0.55, // viewport heights of scrolling per step
  imageTransition = 1, // share of a step the image slide takes (1 = always moving)
  textTransition = 0.1, // share of a step a heading takes to fade
  textSwapAt = 0.5, // point in the step where the heading hands over
  layerScale = 0.05, // how far a covered image eases back
  textShift = 12, // px the text travels while crossfading
  smoothing = 1 // 1 = track the scroll exactly; < 1 lerps towards it
}) => {
  const rootRef = React.useRef(null);
  const rafRef = React.useRef(0);
  const lastFrameRef = React.useRef(-1);

  React.useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root) return;

    const stage = root.querySelector('.scroll-scene-stage');
    const layers = Array.from(root.querySelectorAll('.scroll-scene-layer'));
    const steps = Array.from(root.querySelectorAll('.scroll-scene-step'));
    const ticks = Array.from(root.querySelectorAll('.scroll-scene-tick'));
    const n = layers.length;
    if (!stage || !n) return;

    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const clamp = (v, min, max) => (v < min ? min : v > max ? max : v);

    let travel = 1;
    let current = 0; // scene position, in steps
    let settled = false;
    const painted = []; // last transform written per layer, so we skip no-ops

    // Decode the images up front. Without this the first slide of each one
    // lands on a decode, which is exactly where a hitch would be visible.
    layers.forEach(layer => {
      const url = (layer.style.backgroundImage.match(/url\(["']?(.*?)["']?\)/) || [])[1];
      if (!url) return;
      const img = new Image();
      img.src = url;
      if (img.decode) img.decode().catch(() => {});
    });

    const targetSteps = () =>
      clamp(-root.getBoundingClientRect().top / travel, 0, 1) * n;

    const layout = () => {
      travel = Math.max(1, Math.round(n * stepVh * window.innerHeight));
      root.style.height = `${stage.offsetHeight + travel}px`;
      layers.forEach((layer, i) => {
        layer.style.zIndex = String(i + 1);
      });
      current = targetSteps(); // adopt the position, never animate into it
      settled = false;
    };

    const render = t => {
      for (let i = 0; i < n; i++) {
        // How far this image has slid up over the one before it, and how far the
        // next one has slid over this one.
        const p = i === 0 ? 1 : clamp((t - i + imageTransition) / imageTransition, 0, 1);
        const covered =
          i + 1 < n ? clamp((t - (i + 1) + imageTransition) / imageTransition, 0, 1) : 0;

        // Linear, deliberately: the image travels in step with the wheel, so it
        // can't look like it is easing in late or coasting on afterwards.
        const slide = reduceMotion ? (p >= 1 ? 0 : 100) : (1 - p) * 100;
        const scale = reduceMotion ? 1 : 1 - layerScale * covered;
        const transform = `translate3d(0, ${slide.toFixed(2)}%, 0) scale(${scale.toFixed(4)})`;
        // Parked layers hold the same transform for most of the scene; skipping
        // the write keeps the compositor off work it doesn't need.
        if (painted[i] !== transform) {
          layers[i].style.transform = transform;
          painted[i] = transform;
        }
      }

      for (let i = 0; i < steps.length; i++) {
        // The heading hands over partway through the slide (textSwapAt), out
        // first and then in, so two lines of large type never overlap and the
        // copy changes while its image is arriving rather than after it lands.
        const fadeIn = clamp((t - (i - textSwapAt)) / textTransition, 0, 1);
        // The last step holds — the section is on its way out by then.
        const fadeOut =
          i === steps.length - 1
            ? 0
            : clamp((t - (i + textSwapAt - textTransition)) / textTransition, 0, 1);
        const opacity = reduceMotion ? (fadeIn >= 1 && fadeOut < 1 ? 1 : 0) : fadeIn * (1 - fadeOut);

        const step = steps[i];
        step.style.opacity = opacity.toFixed(3);
        step.style.transform = reduceMotion
          ? ''
          : `translate3d(0, ${((1 - fadeIn) * textShift - fadeOut * textShift).toFixed(1)}px, 0)`;
        step.style.visibility = opacity < 0.02 ? 'hidden' : 'visible';
        step.setAttribute('aria-hidden', opacity < 0.5 ? 'true' : 'false');
      }

      // Rounds at the halfway point, so the rail flips with the heading.
      const active = clamp(Math.round(t), 0, n - 1);
      ticks.forEach((tick, i) => tick.classList.toggle('is-active', i === active));
    };

    // One step of the chase: read where the scroll wants the scene to be, ease
    // towards it, and only paint when that actually moved something.
    const advance = now => {
      const dt = clamp(now - lastFrameRef.current, 1, 50);
      lastFrameRef.current = now;

      const rect = root.getBoundingClientRect();
      if (rect.bottom < -240 || rect.top > window.innerHeight + 240) return;

      const target = clamp(-rect.top / travel, 0, 1) * n;
      const diff = target - current;

      if (Math.abs(diff) < 0.0004) {
        if (settled) return;
        current = target;
        settled = true;
      } else {
        // Frame-rate independent lerp, so 120Hz doesn't ease twice as fast.
        current += diff * (reduceMotion ? 1 : 1 - Math.pow(1 - smoothing, dt / 16.667));
        settled = false;
      }
      render(current);
    };

    const tick = now => {
      rafRef.current = requestAnimationFrame(tick);
      advance(now || performance.now());
    };

    // The loop only exists while the track is near the viewport. Off-screen it
    // was still costing a rAF callback and a getBoundingClientRect() every
    // frame for the whole life of the page — a forced layout on the same main
    // thread the scroll is being animated on. `will-change` rides the same
    // switch, so four viewport-sized layers only hold compositor memory while
    // the scene is the thing on screen.
    let running = false;
    const start = () => {
      if (running || document.hidden) return;
      running = true;
      root.classList.add('is-live');
      lastFrameRef.current = performance.now();
      settled = false;
      current = targetSteps(); // adopt, don't ease in from wherever we stopped
      render(current);
      rafRef.current = requestAnimationFrame(tick);
    };
    const stop = () => {
      if (!running) return;
      running = false;
      cancelAnimationFrame(rafRef.current);
      rafRef.current = 0;
      root.classList.remove('is-live');
    };

    // Both triggers re-derive the answer instead of sharing a cached flag: a
    // page opened in a background tab gets no IntersectionObserver callbacks
    // (delivery rides the frame loop, which is frozen), so a flag set only by
    // the observer is still false when the tab is finally revealed and the
    // scene would stay frozen. One rect per tab switch or crossing is nothing.
    // The margin is wide enough that the scene is settled and its layers are
    // already promoted before any of it is visible, entering from either end.
    const shouldRun = () => {
      if (document.hidden) return false;
      const margin = window.innerHeight * 0.25;
      const rect = root.getBoundingClientRect();
      return rect.bottom > -margin && rect.top < window.innerHeight + margin;
    };
    const sync = () => {
      if (shouldRun()) start();
      // Paint the frame the scroll position calls for on the way out, so the
      // scene is never left mid-transition once the loop is gone.
      else { stop(); render(targetSteps()); }
    };

    const io = new IntersectionObserver(sync, { rootMargin: '25% 0px' });
    io.observe(root);
    document.addEventListener('visibilitychange', sync);

    // Fallback for frames rAF skips (throttled tabs); a no-op while it is alive.
    const onScroll = () => {
      if (running) return;
      const now = performance.now();
      if (now - lastFrameRef.current > 40) advance(now);
    };

    const onResize = () => {
      layout();
      render(current);
    };

    lastFrameRef.current = performance.now();
    layout();
    render(current);
    sync(); // the observer's first delivery is a frame away, and never arrives
            // at all if the page loaded in a background tab
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);

    return () => {
      io.disconnect();
      document.removeEventListener('visibilitychange', sync);
      cancelAnimationFrame(rafRef.current);
      root.classList.remove('is-live');
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
      root.style.height = '';
      layers.forEach(layer => {
        layer.style.transform = '';
        layer.style.zIndex = '';
      });
      steps.forEach(step => {
        step.style.opacity = '';
        step.style.transform = '';
        step.style.visibility = '';
        step.removeAttribute('aria-hidden');
      });
    };
  }, [stepVh, imageTransition, textTransition, textSwapAt, layerScale, textShift, smoothing]);

  return (
    <div className={`scroll-scene ${className}`.trim()} ref={rootRef}>
      {children}
    </div>
  );
};

window.ScrollScene = ScrollScene;
