// ScrollStack — ported from React Bits (reactbits.dev) to plain JSX. Cards pin at the top of
// the viewport in turn and stack up behind one another, each one a little smaller than the one
// in front. Drives the services page's list of services.
//
// Deviations from upstream, all deliberate:
//
//   * **No Lenis.** Upstream's only uses for it are smoothing the scroll and getting a scroll
//     callback. This site already owns its scroll twice over — `smooth-scroll.js` runs a wheel
//     lerp and `scrollbar.js` draws the bar — so a third scroll owner would fight both, and it
//     would be a new CDN dependency on a page that has no build step to tree-shake it. The same
//     maths runs off the site's own scroll and resize events instead, rAF-throttled.
//   * **Window scroll only.** Upstream defaults to `useWindowScroll: false`, which nests an
//     `overflow-y: auto` scroller. On this site that is actively wrong: `smooth-scroll.js`
//     stands down for any scrollable ancestor, so the whole section would lose the site's
//     scrolling, and the custom scrollbar would not track it either. The inner-scroller branch
//     is gone rather than left as a trap.
//   * **Card offsets come from `offsetTop`, and are cached.** Upstream's window-scroll branch
//     measures `getBoundingClientRect().top + scrollY` every frame — on cards it has already
//     translated, so `cardTop` drifts the moment a transform is applied, and it costs one
//     forced reflow per card per frame. `offsetTop` is a layout position and ignores
//     transforms, which is exactly what this needs. Re-measured on resize and after
//     `document.fonts.ready`, since the webfont's metrics decide how tall each card is.
//   * **It renders a plain stack under `prefers-reduced-motion`** — no pinning, no scaling, no
//     listeners. Cards that pin and shrink as you scroll are the thing that setting is about.
//   * **It also stands down below `minWidth` (900 by default), and that is not a performance
//     call.** The effect pins a card to the top of the viewport; a card taller than the
//     viewport therefore has a bottom you cannot reach while it is pinned. On this site's
//     content the cards are 420px at 1440 and 840-1416px at 375, against a phone viewport of
//     ~670-810, so every one of them would be unreadable. Below the breakpoint the same cards
//     simply flow. Same `minWidth` idea as `Loop` in components.jsx.
//   * The end spacer is a ref rather than a `querySelector`, so two stacks on one page cannot
//     read each other's.

// Layout position relative to the document. Walks offsetParent rather than using a rect,
// because offsetTop is unaffected by the transforms this component applies.
const ssDocTop = (el) => {
  let y = 0, n = el;
  while (n) { y += n.offsetTop; n = n.offsetParent; }
  return y;
};
const ssPct = (value, of) =>
  typeof value === "string" && value.includes("%")
    ? (parseFloat(value) / 100) * of
    : parseFloat(value);

const ScrollStackItem = ({ children, itemClassName = "", id }) => (
  <div id={id} className={`scroll-stack-card ${itemClassName}`.trim()}>{children}</div>
);
window.ScrollStackItem = ScrollStackItem;

const ScrollStack = ({
  children,
  className = "",
  itemDistance = 100,
  itemScale = 0.03,
  itemStackDistance = 30,
  stackPosition = "20%",
  scaleEndPosition = "10%",
  baseScale = 0.85,
  rotationAmount = 0,
  blurAmount = 0,
  minWidth = 900,
  onStackComplete,
  scrollToRef,
}) => {
  const { useRef, useState, useEffect, useLayoutEffect } = React;
  const rootRef = useRef(null);
  const endRef = useRef(null);

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

  // Re-evaluated on resize rather than read once: a tablet turned to portrait crosses this.
  const [wide, setWide] = useState(
    () => typeof window === "undefined" || window.innerWidth >= minWidth);
  useEffect(() => {
    const mq = window.matchMedia(`(min-width: ${minWidth}px)`);
    const on = () => setWide(mq.matches);
    on();
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, [minWidth]);

  const off = reduced || !wide;

  // Deep-linking. The caller gets a `(index) => void` that lands a card exactly where it
  // pins, and it lives here because the pin offset is this component's maths — duplicating
  // the clamp at a call site is how the two drift apart. It measures on call rather than
  // reading cached state, so it works before the first paint and in the static mode too.
  useEffect(() => {
    if (!scrollToRef) return;
    scrollToRef.current = (i) => {
      const root = rootRef.current;
      if (!root) return;
      const card = root.querySelectorAll(".scroll-stack-card")[i];
      if (!card) return;
      let y;
      if (off) {
        // Flowing, not pinned: just bring the card up under the nav.
        y = ssDocTop(card) - 100;
      } else {
        const vh = window.innerHeight;
        const room = vh - card.offsetHeight - 8;
        const pinOffset = Math.max(0, Math.min(ssPct(stackPosition, vh) + itemStackDistance * i, room));
        y = ssDocTop(card) - pinOffset;
      }
      window.scrollTo(0, Math.max(0, Math.round(y)));
      // The site drives its own scroll; without this the wheel lerp drags the page back.
      if (window.PS && window.PS.syncScroll) window.PS.syncScroll();
      window.dispatchEvent(new Event("scroll"));
    };
    return () => { scrollToRef.current = null; };
  }, [scrollToRef, off, stackPosition, itemStackDistance]);

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

    const cards = Array.from(root.querySelectorAll(".scroll-stack-card"));
    if (!cards.length) return;

    cards.forEach((card, i) => {
      if (i < cards.length - 1) card.style.marginBottom = `${itemDistance}px`;
      card.style.willChange = "transform, filter";
      card.style.transformOrigin = "top center";
      card.style.backfaceVisibility = "hidden";
    });

    const docTop = ssDocTop;

    let tops = [];
    let heights = [];
    let endTop = 0;
    const measure = () => {
      tops = cards.map(docTop);
      // offsetHeight, like offsetTop, is a layout box and ignores the transforms applied here.
      heights = cards.map((c) => c.offsetHeight);
      endTop = endRef.current ? docTop(endRef.current) : 0;
    };

    const pct = ssPct;

    const progress = (v, start, end) =>
      v < start ? 0 : v > end ? 1 : (v - start) / (end - start);

    const last = new Map();
    let stackDone = false;

    const paint = () => {
      const scrollTop = window.scrollY;
      const vh = window.innerHeight;
      const stackPx = pct(stackPosition, vh);
      const scaleEndPx = pct(scaleEndPosition, vh);
      const pinEnd = endTop - vh / 2;

      // Which card is currently on top of the stack; only needed when blurring.
      let topIdx = 0;
      if (blurAmount) {
        for (let j = 0; j < cards.length; j++) {
          const off = Math.min(stackPx + itemStackDistance * j, vh - heights[j] - 8);
          if (scrollTop >= tops[j] - off) topIdx = j;
        }
      }

      for (let i = 0; i < cards.length; i++) {
        const cardTop = tops[i];
        // How far down the viewport this card pins. Upstream uses `stackPx + itemStackDistance
        // * i` unconditionally, which silently cuts off any card taller than the space left
        // below that point — the last service card is 754px against 646px of room at 1213x788,
        // so 108px of it could never be scrolled to. Clamping the offset lets a tall card pin
        // higher instead, so its whole height fits; `fits` catches the case where even pinning
        // at the very top is not enough, and that card simply scrolls rather than pinning.
        const roomIfPinnedAtTop = vh - heights[i] - 8;
        const pinOffset = Math.min(stackPx + itemStackDistance * i, roomIfPinnedAtTop);
        const fits = roomIfPinnedAtTop > 0;
        const pinStart = cardTop - pinOffset;
        // Keep the scale ramp non-degenerate: a clamped pinOffset can push pinStart past the
        // scale-end point, which would make progress() flip 0 to 1 in a single frame.
        const scaleEndAt = Math.max(cardTop - scaleEndPx, pinStart + 1);
        const scaleProgress = progress(scrollTop, pinStart, scaleEndAt);
        const targetScale = baseScale + i * itemScale;
        const scale = 1 - scaleProgress * (1 - targetScale);
        const rotation = rotationAmount ? i * rotationAmount * scaleProgress : 0;
        const blur = blurAmount && i < topIdx ? Math.max(0, (topIdx - i) * blurAmount) : 0;

        let translateY = 0;
        if (fits && scrollTop >= pinStart && scrollTop <= pinEnd) {
          translateY = scrollTop - cardTop + pinOffset;
        } else if (fits && scrollTop > pinEnd) {
          translateY = pinEnd - cardTop + pinOffset;
        }

        const next = {
          y: Math.round(translateY * 100) / 100,
          s: Math.round(scale * 1000) / 1000,
          r: Math.round(rotation * 100) / 100,
          b: Math.round(blur * 100) / 100,
        };
        const prev = last.get(i);
        const changed = !prev
          || Math.abs(prev.y - next.y) > 0.1
          || Math.abs(prev.s - next.s) > 0.001
          || Math.abs(prev.r - next.r) > 0.1
          || Math.abs(prev.b - next.b) > 0.1;

        if (changed) {
          cards[i].style.transform =
            `translate3d(0, ${next.y}px, 0) scale(${next.s})` + (next.r ? ` rotate(${next.r}deg)` : "");
          cards[i].style.filter = next.b > 0 ? `blur(${next.b}px)` : "";
          last.set(i, next);
        }

        if (i === cards.length - 1 && typeof onStackComplete === "function") {
          const inView = scrollTop >= pinStart && scrollTop <= pinEnd;
          if (inView && !stackDone) { stackDone = true; onStackComplete(); }
          else if (!inView && stackDone) { stackDone = false; }
        }
      }
    };

    let raf = 0;
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; paint(); }); };
    const onResize = () => { measure(); paint(); };

    measure();
    paint();
    // The webfont arrives after first paint and changes how tall every card is, which moves
    // every offset below it.
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(onResize);

    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onResize);
    const ro = new ResizeObserver(onResize);
    ro.observe(root);

    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onResize);
      ro.disconnect();
      if (raf) cancelAnimationFrame(raf);
      cards.forEach((c) => {
        c.style.transform = "";
        c.style.filter = "";
        c.style.willChange = "";
        c.style.marginBottom = "";
      });
    };
  }, [off, itemDistance, itemScale, itemStackDistance, stackPosition,
      scaleEndPosition, baseScale, rotationAmount, blurAmount, onStackComplete]);

  return (
    <div ref={rootRef} className={`scroll-stack ${off ? "is-static" : ""} ${className}`.trim()}>
      <div className="scroll-stack-inner">
        {children}
        {/* Spacer so the last card has somewhere to release its pin. */}
        <div ref={endRef} className="scroll-stack-end" aria-hidden="true" />
      </div>
    </div>
  );
};
window.ScrollStack = ScrollStack;
