// The scroll-driven zoom into the latent vector, and the client tray under it.
//
// The pin is CSS: an outer element 220vh tall with a sticky, viewport-tall inner
// frame. This file's only job is to turn the outer element's position into a 0-1
// progress value and hand it to stats-zoom.js, which decides what to draw.
//
// Deliberately not GSAP ScrollTrigger. Sticky needs no pin-spacer, so nothing
// restructures the DOM, and there is no trigger instance to leak across a Barba
// navigation -- which is the failure this would otherwise have to clean up after.
//
// IIFE-wrapped with window exports, because every file here shares one global
// scope and a top-level binding would collide.
(function () {
const { useState, useEffect, useRef, useCallback } = React;

// Every timing in the zoom is a FRACTION of this container's travel, so raising
// it buys dwell everywhere without changing a single proportion. 260 rather than
// 220 to give the heading a readable hold; each further 40 costs about half a
// viewport of extra page before "Who I am", which is the real ceiling here.
const PIN_VH = 260;

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

// Progress across the sticky container's travel. The container is PIN_VH tall
// and its sticky child is one viewport tall, so the scrollable distance is the
// difference -- which is exactly how far -rect.top can travel.
function progressOf(el) {
  const zg = window.StatsZoomGeo;
  const r = el.getBoundingClientRect();
  const travel = r.height - window.innerHeight;
  if (travel <= 0) return 0;
  return zg.clamp01(-r.top / travel);
}

// --bar-h, in CSS pixels. The SVG is not drawn into the whole frame: .unet is
// `height: calc(100% - var(--bar-h))`, so a 900px frame hands the diagram an
// 836px box. Measuring the frame instead fired the detail thresholds about 7.6%
// early and built the deepest window to the wrong aspect ratio.
function barHeightPx() {
  if (!window.getComputedStyle) return 0;
  const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--bar-h'));
  return isFinite(v) ? v : 0;
}

function LatentZoom({ children }) {
  const geo = window.UNetGeo;
  const zg = window.StatsZoomGeo;
  const outer = useRef(null);
  const frame = useRef(null);
  const [progress, setProgress] = useState(0);
  const [tier, setTier] = useState(() => geo.tierFor(window.innerWidth));
  const [frameSize, setFrameSize] = useState({ w: 0, h: 0 });
  const [barH, setBarH] = useState(barHeightPx);
  const [reduced, setReduced] = useState(prefersReducedMotion);

  // No diagram below the mid breakpoint and none under reduced motion, so there
  // is nothing to zoom. Computed up here because the frame observer below is
  // keyed on it; it is a plain const off two pieces of state, so it moves no
  // hook and adds none.
  const active = geo.hasDiagram(tier) && !reduced;

  // One rAF-coalesced read per frame. Scroll fires far more often than the
  // browser paints, and getBoundingClientRect forces layout, so reading it on
  // every event would be the one expensive thing in this file.
  useEffect(() => {
    let raf = 0;
    const read = () => {
      raf = 0;
      if (outer.current) setProgress(progressOf(outer.current));
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(read); };
    window.addEventListener('scroll', onScroll, { passive: true });
    read();
    return () => {
      window.removeEventListener('scroll', onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, []);

  useEffect(() => {
    const onResize = () => {
      setTier(geo.tierFor(window.innerWidth));
      setBarH(barHeightPx());
      if (outer.current) setProgress(progressOf(outer.current));
    };
    window.addEventListener('resize', onResize);
    onResize();
    return () => window.removeEventListener('resize', onResize);
  }, []);

  useEffect(() => {
    if (!window.matchMedia) return undefined;
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener('change', onChange);
    return () => mq.removeEventListener('change', onChange);
  }, []);

  // The frame is observed, not measured inside the resize handler. The static
  // branch attaches no `frame` ref, so a single discrete resize across the 700px
  // breakpoint -- a rotation, a window snap -- set the tier, read a null ref and
  // returned; React then committed the active branch and attached the ref, and
  // nothing ever re-measured. frameSize.h stayed 0, which pinned `level` to
  // 'plain', so the numerals and the four figures never appeared again on any
  // later scroll. Keyed on `active` so it re-attaches the moment the ref exists.
  // Unconditional and after the other effects, so the hook order never varies.
  useEffect(() => {
    if (!frame.current) return undefined;
    const ro = new ResizeObserver(([e]) =>
      setFrameSize({ w: e.contentRect.width, h: e.contentRect.height }));
    ro.observe(frame.current);
    if (outer.current) setProgress(progressOf(outer.current));
    return () => ro.disconnect();
  }, [active]);

  if (!active) {
    return (
      <div className="latent-zoom is-static">
        <div className="latent-zoom-frame">
          {children}
          {/* Reduced motion means no pin, no marquee, no zoom -- it does not mean
              no diagram, and deleting it left a >=700px reduced-motion visitor
              with an empty hero. No zoom prop, so this is the plain static
              diagram. The tier check is for the phone tier, which draws nothing
              at all and would render an empty <svg>. */}
          {geo.hasDiagram(tier) ? <window.UNet /> : null}
        </div>
        {window.StatsBand ? <window.StatsBand /> : null}
      </div>);
  }

  // The inner box, not the frame: see barHeightPx above.
  const innerH = frameSize.h - barH;
  const aspect = innerH > 0 ? frameSize.w / innerH : 16 / 9;
  const rect = zg.rectAt(progress, tier, aspect);
  const chrome = zg.chromeOpacity(progress);
  const headOpacity = zg.headingOpacity(progress);
  // Its own ramp, not chrome: the copy has to clear the shared grid cell before
  // the heading arrives in it, while the diagram behind them both fades slower.
  const copyOp = zg.copyOpacity(progress);
  const level = rect && innerH > 0
    ? zg.levelFor(zg.cellPx(rect, frameSize.w, innerH))
    : 'plain';

  return (
    <div className="latent-zoom" ref={outer} style={{ height: `${PIN_VH}vh` }}>
      <div className="latent-zoom-frame" ref={frame}>
        {/* visibility, not just opacity and pointer-events: at chrome 0 the hero
            CTAs are two invisible links, and opacity alone leaves them in the
            tab order for a keyboard user to land on and read nothing. */}
        <div className="latent-zoom-copy" style={{
          opacity: copyOp,
          visibility: copyOp === 0 ? 'hidden' : undefined,
          pointerEvents: copyOp < 0.5 ? 'none' : undefined
        }}>
          {children}
        </div>
        {/* Takes the hero copy's place in the same grid cell as it fades, so the
            slot is never empty mid-scroll. Same visibility treatment for the
            mirror-image reason: at heading opacity 0 it must not be reachable
            either. aria-hidden while invisible so a screen reader is not read two
            competing headings for one section. */}
        <h2 className="latent-zoom-head" aria-hidden={headOpacity === 0}
          style={{
            opacity: headOpacity,
            visibility: headOpacity === 0 ? 'hidden' : undefined
          }}>{zg.HEADING}</h2>
        <window.UNet zoom={rect ? { rect, level, chrome } : null} />
        {/* The figures the diagram draws are unreachable to assistive tech: they
            sit under two nested role="img" ancestors, and ARIA specifies
            Children Presentational: True there. This is the copy that is
            actually announced -- same numbers, same order, same source. */}
        <div className="sr-only">{window.StatsBand ? <window.StatsBand /> : null}</div>
      </div>
    </div>);

}

// The figures without the zoom, for the two cases that have no diagram to zoom:
// viewports below the mid breakpoint, and anyone who asked for reduced motion.
// Same numbers, same order, same source of truth.
function StatsBand() {
  const zg = window.StatsZoomGeo;
  return (
    <div className="stats-band">
      {zg.STATS.map((s) =>
        <div className="stats-band-item" key={s.figure + s.cell}>
          <div className="stats-band-fig">{s.figure}</div>
          <div className="stats-band-lbl">{s.label.join(' ')}</div>
        </div>
      )}
    </div>);

}

window.StatsBand = StatsBand;

// Client tray. Seven wordmarks, so the set is repeated three times to make a
// seamless loop -- the row translates by exactly one third and restarts, which
// lands it back on an identical frame with no gap and no snap.
//
// The marks are buttons, not decorations: touch has no hover and a keyboard has
// no cursor. Focus pauses the row too, because tabbing through a moving target
// is unusable.
const TRAY_REPEATS = 3;

function ClientTray() {
  const zg = window.StatsZoomGeo;
  const [active, setActive] = useState(null);
  const clients = zg.CLIENTS;

  const onKeyDown = useCallback((e) => {
    if (e.key === 'Escape') setActive(null);
  }, []);

  const items = [];
  for (let r = 0; r < TRAY_REPEATS; r++) {
    clients.forEach((c, i) => {
      const key = `${r}-${c.name}`;
      // Index, not the name: the id goes into aria-describedby, which is a
      // space-separated list, and 'Edge Impulse' has a space in it.
      const popId = `tray-pop-${r}-${i}`;
      const isActive = active === key;
      items.push(
        <button className={`tray-mark${isActive ? ' is-active' : ''}`} key={key}
          type="button"
          aria-expanded={isActive}
          // The card is a DESCRIPTION, not part of the name. Without an explicit
          // aria-label the open card is a descendant with aria-hidden="false",
          // so its whole paragraph gets folded into the button's accessible name
          // and the mark announces as an essay.
          aria-label={c.name}
          aria-describedby={popId}
          // Only the first pass is reachable by keyboard and announced. The two
          // duplicates exist to make the loop seamless, so exposing them would
          // read the same seven names three times.
          tabIndex={r === 0 ? 0 : -1}
          aria-hidden={r === 0 ? undefined : true}
          onMouseEnter={() => setActive(key)}
          onMouseLeave={() => setActive((n) => (n === key ? null : n))}
          onFocus={() => setActive(key)}
          onBlur={() => setActive((n) => (n === key ? null : n))}
          // Open, never toggle. onMouseLeave and onBlur already close, and a
          // toggle here meant a mouse click on the mark you are hovering
          // dismissed its own card -- and on touch a tap both focused (open) and
          // clicked (close), so the card never appeared at all.
          onClick={() => setActive(key)}
          onKeyDown={onKeyDown}>
          {c.name}
          <span className="tray-pop" id={popId} role="tooltip" aria-hidden={!isActive}>
            <span className="tray-pop-label">what I built</span>
            <span className="tray-pop-body">{c.built}</span>
            <span className="tray-pop-meta">{c.meta}</span>
          </span>
        </button>);

    });
  }

  return (
    <section className="frame-wrap tray-section">
      <div className="section-label">
        <span className="section-label-tick" />
        Clients
      </div>
      <h2 className="tray-h2">Built it. Then documented it.</h2>
      <p className="tray-dek">
        Systems I shipped for AI infrastructure teams — and the deep-dives I wrote about them afterwards.
      </p>
      <div className={`tray-viewport${active ? ' is-paused' : ''}`}>
        <div className="tray-row">{items}</div>
      </div>
    </section>);

}

window.ClientTray = ClientTray;
window.LatentZoom = LatentZoom;
})();
