// DriftWall — ported from React Bits (reactbits.dev) to plain JSX. Columns of tiles drift past
// a tilted 3D plane, the plane leans toward the pointer, and a hovered tile lifts out of it.
// Drives the About page's wall of Maria's certificates.
//
// Deviations from upstream, all deliberate:
//
//   * **The frame loop only runs while the wall is on screen.** Upstream starts a
//     `requestAnimationFrame` loop on mount and never stops it — it keeps animating a wall
//     nobody is looking at, on a page that already runs a WebGL backdrop. An
//     IntersectionObserver starts and stops it, and `visibilitychange` stops it for a
//     backgrounded tab. Same arrangement as SideRays and Aurora.
//   * **Under `prefers-reduced-motion` there is no loop at all.** Upstream keeps the rAF
//     running even when reduced — it skips the drift but still animates the pointer lean every
//     frame. Here the plane is positioned once and the columns stand still, so the wall becomes
//     a static montage. Tiles still lift on hover, which is a transition rather than motion.
//   * **`elementFromPoint` is rAF-throttled.** Upstream hit-tests the document on every single
//     `pointermove`; the active tile can only change once per painted frame.
//   * **Tiles take `srcSet`/`sizes`**, so the wall uses this site's own derivatives instead of
//     one full-size URL per tile — it renders each item several times over, so a single large
//     source would be downloaded once but painted at every copy.
//   * **`object-fit: contain` on a paper ground, not upstream's `cover`.** These tiles are
//     documents. `cover` at tile size crops a certificate to an illegible fragment of itself;
//     contained, the whole page reads as a miniature, which is the only honest way to show one.
//   * Colours come from the site's tokens. Upstream's `#060010` overlay and `#0b0b12` tile
//     ground are built for a near-black page and would sit as dark rectangles on this cream one.
//   * **Tiles size themselves from each image's aspect ratio.** Upstream gives every tile one
//     fixed `tileHeight`, which for documents of two different shapes means letterboxing the
//     portrait ones inside landscape boxes. Each item carries a `ratio` and the tile takes
//     `tileWidth / ratio`, so a certificate's box *is* the certificate. The loop maths follows:
//     a column's `copyHeight` is the sum of its own tiles rather than `count * tileHeight`, or
//     the wrap point drifts out of step and the column visibly jumps.
//   * **Clicking a tile opens it**, via an `onSelect` callback — upstream only supports an
//     `href` per tile, which would navigate away from the page.
//   * Track refs are indexed, never pushed — React re-invokes an inline ref callback on every
//     update, and a pushed array doubles up on a language switch.

const DW_reduced = () =>
  typeof window !== "undefined" && window.matchMedia
  && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// How much taller than its container each column's track has to be. The plane is scaled 1.18
// and pitched on two axes, so its projection reaches well past the container box.
const DW_COVER = 2.2;

// Upstream's golden-ratio jitter, so neighbouring columns never share a speed.
const dwColumnFactor = (index, variance) => {
  const pseudo = ((index * 0.6180339887 + 0.35) % 1) * 2 - 1;
  return 1 + variance * pseudo;
};

const DriftWall = ({
  items = [],
  columns = 5,
  tileWidth = 200,
  tileHeight = 132,
  gap = 18,
  radius = 14,
  tilt = 16,
  turn = -14,
  roll = 0,
  perspective = 1200,
  depth = 120,
  scale = 1.18,   // upstream hardcodes 1.18; the About page passes 1 below 900px so the plane fits its column
  speed = 42,
  direction = "up",
  variance = 0.45,
  parallax = 0.6,
  pauseOnHover = false,
  lift = 64,
  fade = 0.6,
  dim = 0.55,
  grayscale = false,
  overlayColor = "var(--bg)",
  onSelect,
  className = "",
  style,
}) => {
  const { useRef, useEffect, useLayoutEffect, useMemo, useState, useCallback } = React;

  const containerRef = useRef(null);
  const planeRef = useRef(null);
  const trackRefs = useRef([]);
  const rafRef = useRef(0);
  const offsetsRef = useRef([]);
  const velocitiesRef = useRef([]);
  const hoveredColRef = useRef(-1);
  const wallHoveredRef = useRef(false);
  const pointerRef = useRef({ x: 0, y: 0 });
  const dampedRef = useRef({ x: 0, y: 0 });
  const lastTsRef = useRef(null);
  const activeIdRef = useRef(null);
  const hitRafRef = useRef(0);

  const [containerHeight, setContainerHeight] = useState(600);
  const [activeId, setActiveId] = useState(null);
  const reduced = DW_reduced();

  const columnItems = useMemo(() => {
    const cols = Array.from({ length: columns }, () => []);
    items.forEach((item, i) => cols[i % columns].push(item));
    return cols.map((col) => (col.length ? col : items.slice(0, 1)));
  }, [items, columns]);

  // A tile is as tall as its own certificate. `ratio` is width/height of the source image.
  const heightOf = useCallback(
    (item) => (item && item.ratio ? Math.round(tileWidth / item.ratio) : tileHeight),
    [tileWidth, tileHeight]);

  const columnMeta = useMemo(() => columnItems.map((col) => {
    // Summed, not `count * unit`: with mixed portrait and landscape tiles a fixed unit puts
    // the wrap point out of step with the real column and the loop jumps every cycle.
    const copyHeight = Math.max(1, col.reduce((sum, it) => sum + heightOf(it) + gap, 0));
    // Upstream sizes the track against `containerHeight * 1.6`, which is not enough here for
    // two reasons: the plane is scaled 1.18 and pitched, so it covers far more than the
    // container, and the plane is centred on its *tallest* column — a short column therefore
    // ran out and left visible emptiness where the queue should continue. The track has to
    // cover the plane's extent plus a whole copy, because the drift translates it by up to
    // `copyHeight` before wrapping.
    const needed = containerHeight * DW_COVER + copyHeight;
    return { copyHeight, copies: Math.max(3, Math.ceil(needed / copyHeight)) };
  }), [columnItems, heightOf, gap, containerHeight]);

  useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => setContainerHeight(entry.contentRect.height || 600));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const baseVelocities = useMemo(() => {
    const dirSign = direction === "up" ? 1 : -1;
    return columnItems.map((_, c) =>
      speed * dwColumnFactor(c, variance) * dirSign * (c % 2 === 0 ? 1 : -1));
  }, [columnItems, speed, direction, variance]);

  useEffect(() => {
    offsetsRef.current = columnMeta.map((m, c) => m.copyHeight * ((c * 0.37) % 1));
    velocitiesRef.current = columnItems.map(() => 0);
  }, [columnMeta, columnItems]);

  // `translate(-50%, -50%)` centres the plane's *untransformed* box, which is not the same as
  // centring what you see: under perspective, `rotateY` brings one edge toward the viewer and
  // pushes the other away, so the near edge is magnified outward and the far edge shrinks
  // inward. At turn -12deg that left the wall visibly right of centre. This measures the
  // projected bounding box once and carries the difference as a screen-space nudge, so it
  // stays correct if turn/tilt/depth/perspective are ever retuned rather than being a magic
  // number that silently goes stale.
  const shiftRef = useRef(0);

  const applyPlane = useCallback((px, py) => {
    const plane = planeRef.current;
    if (!plane) return;
    plane.style.transform =
      `translate(-50%, -50%) translateX(${shiftRef.current}px) scale(${scale}) ` +
      `rotateX(${tilt + py}deg) rotateY(${turn + px}deg) rotateZ(${roll}deg) ` +
      `translateZ(${-depth}px)`;
  }, [tilt, turn, roll, depth]);

  // The nudge itself. Two things this has to get right, both learned the hard way:
  //
  //   * **Measure the tiles actually on screen, not the plane's box.** The plane is ~2700px
  //     tall and mostly clipped, and under `rotateX` its far rows project to a different
  //     horizontal span than the rows you can see — so its bounding box is not what "looks
  //     centred" means. Only tiles overlapping the host's vertical band are counted.
  //   * **Iterate.** Perspective is not a linear map: shifting the plane changes its own
  //     projection, so a single measure-and-correct overshoots. This converges instead, and
  //     bails after a few passes rather than looping on a degenerate layout.
  //
  // Measured with the pointer lean at rest, or the reading bakes in whatever parallax happened
  // to be applied at that instant.
  const recentre = useCallback(() => {
    const plane = planeRef.current, host = containerRef.current;
    if (!plane || !host) return;
    const h = host.getBoundingClientRect();
    if (!h.width) return;
    const hostCentre = h.left + h.width / 2;

    shiftRef.current = 0;
    for (let pass = 0; pass < 5; pass++) {
      applyPlane(0, 0);
      let left = Infinity, right = -Infinity;
      const tiles = plane.querySelectorAll(".drift-wall__tile");
      for (let i = 0; i < tiles.length; i++) {
        const r = tiles[i].getBoundingClientRect();
        if (!r.width || r.bottom < h.top || r.top > h.bottom) continue;   // off the visible band
        if (r.left < left) left = r.left;
        if (r.right > right) right = r.right;
      }
      if (left === Infinity) return;
      const delta = hostCentre - (left + right) / 2;
      shiftRef.current += delta;
      if (Math.abs(delta) < 0.5) break;
    }
    shiftRef.current = Math.round(shiftRef.current);
    applyPlane(0, 0);
  }, [applyPlane]);

  useLayoutEffect(() => {
    recentre();
    // Tile heights settle after the scans decode, which changes the plane's box.
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(recentre);
    window.addEventListener("resize", recentre);
    return () => window.removeEventListener("resize", recentre);
  }, [recentre, columnMeta, containerHeight]);

  useEffect(() => {
    // Reduced motion: place the plane and the columns once, then never run a loop.
    if (reduced) {
      applyPlane(0, 0);
      trackRefs.current.forEach((el, c) => {
        if (el) el.style.transform = `translate3d(0, ${-(offsetsRef.current[c] ?? 0)}px, 0)`;
      });
      return;
    }
    const el = containerRef.current;
    if (!el) return;

    let running = false;
    const frame = (ts) => {
      if (lastTsRef.current === null) lastTsRef.current = ts;
      const dt = Math.min(0.05, Math.max(0, ts - lastTsRef.current) / 1000);
      lastTsRef.current = ts;

      const maxTilt = parallax * 8;
      const damp = 1 - Math.exp(-dt / 0.12);
      dampedRef.current.x += (pointerRef.current.x * maxTilt - dampedRef.current.x) * damp;
      dampedRef.current.y += (-pointerRef.current.y * maxTilt - dampedRef.current.y) * damp;
      applyPlane(dampedRef.current.x, dampedRef.current.y);

      for (let c = 0; c < trackRefs.current.length; c++) {
        const meta = columnMeta[c];
        if (!meta) continue;
        const paused = wallHoveredRef.current && pauseOnHover;
        const target = baseVelocities[c] * (paused || hoveredColRef.current === c ? 0 : 1);
        const ease = 1 - Math.exp(-dt / (target === 0 ? 0.16 : 0.28));
        velocitiesRef.current[c] += (target - velocitiesRef.current[c]) * ease;
        let next = (offsetsRef.current[c] ?? 0) + velocitiesRef.current[c] * dt;
        next = ((next % meta.copyHeight) + meta.copyHeight) % meta.copyHeight;
        offsetsRef.current[c] = next;
        const t = trackRefs.current[c];
        if (t) t.style.transform = `translate3d(0, ${-next}px, 0)`;
      }
      rafRef.current = requestAnimationFrame(frame);
    };

    const start = () => {
      if (running) return;
      running = true;
      lastTsRef.current = null;
      rafRef.current = requestAnimationFrame(frame);
    };
    const stop = () => {
      running = false;
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      rafRef.current = 0;
      lastTsRef.current = null;
    };

    const io = typeof IntersectionObserver === "function"
      ? new IntersectionObserver((es) => (es.some((e) => e.isIntersecting) ? start() : stop()),
                                 { rootMargin: "120px" })
      : null;
    if (io) io.observe(el); else start();
    const onVis = () => (document.hidden ? stop() : start());
    document.addEventListener("visibilitychange", onVis);

    return () => {
      stop();
      if (io) io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      if (hitRafRef.current) cancelAnimationFrame(hitRafRef.current);
    };
  }, [reduced, baseVelocities, columnMeta, pauseOnHover, parallax, applyPlane]);

  const release = useCallback(() => {
    activeIdRef.current = null;
    hoveredColRef.current = -1;
    setActiveId(null);
  }, []);

  // Clicking a drifting tile does not work with `onClick`: a browser only fires `click` when
  // mousedown and mouseup land on the *same* element, and by mouseup the tile has moved out
  // from under the cursor — so the click is silently swallowed. The tile is captured on
  // pointerdown instead and opened on pointerup, which also lets a touch drag scroll the page
  // without opening anything: past ~10px of travel it is a scroll, not a tap.
  const pressRef = useRef(null);

  const itemFromTileId = useCallback((id) => {
    if (!id) return null;
    const [c, , i] = id.split("-").map(Number);
    const col = columnItems[c];
    return col ? col[i] : null;
  }, [columnItems]);

  // Hit-testing 3D-transformed quads is not watertight: about half of all points over the
  // wall resolve to the track/column/plane rather than to a tile, so `closest` alone loses
  // roughly every other press and the card reads as dead. When it misses, fall back to the
  // tile whose projected box contains the point — nearest centre wins where they overlap.
  const tileAt = useCallback((e) => {
    const direct = e.target && e.target.closest ? e.target.closest("[data-tile-id]") : null;
    if (direct) return direct;
    const root = containerRef.current;
    if (!root) return null;
    const tiles = root.querySelectorAll("[data-tile-id]");
    let best = null, bestDist = Infinity;
    for (let i = 0; i < tiles.length; i++) {
      const r = tiles[i].getBoundingClientRect();
      if (!r.width || e.clientX < r.left || e.clientX > r.right
          || e.clientY < r.top || e.clientY > r.bottom) continue;
      const d = Math.hypot(e.clientX - (r.left + r.width / 2), e.clientY - (r.top + r.height / 2));
      if (d < bestDist) { bestDist = d; best = tiles[i]; }
    }
    return best;
  }, []);

  const onPointerDown = useCallback((e) => {
    const tile = tileAt(e);
    pressRef.current = tile
      ? { id: tile.dataset.tileId, x: e.clientX, y: e.clientY, t: Date.now() }
      : null;
  }, [tileAt]);

  const onPointerUp = useCallback((e) => {
    const press = pressRef.current;
    pressRef.current = null;
    if (!press || typeof onSelect !== "function") return;
    if (Math.hypot(e.clientX - press.x, e.clientY - press.y) > 10) return;  // a drag, not a tap
    if (Date.now() - press.t > 700) return;                                  // a long press
    const item = itemFromTileId(press.id);
    if (item) onSelect(item);
  }, [onSelect, itemFromTileId]);

  const onPointerMove = useCallback((e) => {
    const rect = containerRef.current && containerRef.current.getBoundingClientRect();
    if (!rect) return;
    if (parallax > 0 && !reduced) {
      pointerRef.current = {
        x: (e.clientX - rect.left) / rect.width - 0.5,
        y: (e.clientY - rect.top) / rect.height - 0.5,
      };
    }
    // The hit test is the expensive part, and the answer cannot change more than once a frame.
    const cx = e.clientX, cy = e.clientY;
    if (hitRafRef.current) return;
    hitRafRef.current = requestAnimationFrame(() => {
      hitRafRef.current = 0;
      const hit = document.elementFromPoint(cx, cy);
      const tile = tileAt({ target: hit, clientX: cx, clientY: cy });
      if (!tile) return;
      const id = tile.dataset.tileId || null;
      if (id === activeIdRef.current) return;
      activeIdRef.current = id;
      hoveredColRef.current = Number(tile.dataset.col);
      setActiveId(id);
    });
  }, [parallax, reduced, tileAt]);

  const cssVars = Object.assign({
    "--dw-tile-w": `${tileWidth}px`,
    "--dw-tile-h": `${tileHeight}px`,
    "--dw-gap": `${gap}px`,
    "--dw-radius": `${radius}px`,
    "--dw-perspective": `${perspective}px`,
    "--dw-lift": `${lift}px`,
    "--dw-dim": dim,
    "--dw-gray": grayscale ? 1 : 0,
    "--dw-overlay": overlayColor,
    "--dw-edge": `${Math.max(0, (1 - fade) * 100)}%`,
  }, style);

  const renderTile = (item, id, colIndex) => (
    <div key={id} tabIndex={0} role="button" aria-label={item.title || "certificate"}
         className={`drift-wall__tile${activeId === id ? " is-active" : ""}`}
         data-tile-id={id} data-col={colIndex}
         style={{ height: heightOf(item) + gap }}
         onKeyDown={(e) => {
           if ((e.key === "Enter" || e.key === " ") && typeof onSelect === "function") {
             e.preventDefault();
             onSelect(item);
           }
         }}
         onFocus={() => { activeIdRef.current = id; hoveredColRef.current = colIndex; setActiveId(id); }}
         onBlur={release}>
      <span className="drift-wall__inner">
        <img src={item.image} srcSet={item.srcSet} sizes={item.sizes}
             alt={item.title || ""} loading="lazy" decoding="async" draggable={false} />
        <span className="drift-wall__overlay" aria-hidden="true" />
      </span>
    </div>
  );

  return (
    <div ref={containerRef}
         className={`drift-wall ${reduced ? "drift-wall--reduced" : ""} ${className}`.trim()}
         style={cssVars}
         onPointerMove={onPointerMove}
         onPointerDown={onPointerDown}
         onPointerUp={onPointerUp}
         onPointerEnter={() => { wallHoveredRef.current = true; }}
         onPointerLeave={() => { wallHoveredRef.current = false; pointerRef.current = { x: 0, y: 0 }; pressRef.current = null; release(); }}
         role="group">
      <div ref={planeRef} className="drift-wall__plane">
        {columnItems.map((col, c) => (
          <div className="drift-wall__col" key={`col-${c}`}>
            <div className="drift-wall__track" ref={(el) => { trackRefs.current[c] = el; }}>
              {Array.from({ length: columnMeta[c].copies }).map((_, copyIndex) =>
                col.map((item, itemIndex) => renderTile(item, `${c}-${copyIndex}-${itemIndex}`, c)))}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};
window.DriftWall = DriftWall;
