// VariableProximity — ported from React Bits (reactbits.dev) to plain JSX. Letters near the
// pointer thicken along the font's weight axis and relax back as it leaves.
//
// It only works because Literata is requested as a weight *range*. The site used to ask
// Google Fonts for `wght@...400;500;600;700`, which is served as four pinned instances — a
// `font-variation-settings: 'wght' 812` against that does nothing at all. The link now asks
// for `wght@0,7..72,200..900`, one variable face with a live axis. If anyone "tidies" that
// request back to a list of weights, this component silently stops animating.
//
// Deviations from upstream, all deliberate:
//
//   * No `motion`. Upstream renders each letter as a `motion.span` but passes it no motion
//     props — it is a plain span with extra bytes. There is nothing to port.
//   * Letter positions are measured once and cached relative to the container, not
//     re-measured every frame. Upstream calls getBoundingClientRect on every letter on every
//     frame, which for a 20-letter heading is 20 forced reflows per frame on a page already
//     running a WebGL backdrop and a scroll lerp. Caching also removes a feedback wobble
//     that measuring cannot avoid: a letter that thickens gets wider, which moves its own
//     centre, which changes its distance, which changes how much it thickens.
//   * Driven by rAF-throttled mousemove rather than a permanent rAF loop. The effect can only
//     change when the pointer does, so the loop upstream leaves running forever does no work
//     on all but a handful of frames.
//   * Nothing runs for a coarse pointer or under prefers-reduced-motion; the heading renders
//     as ordinary text.
//
// One cost worth knowing: splitting a heading into per-letter spans gives up kerning between
// them. At display sizes that is visible on tight pairs, and it is inherent to the effect
// rather than to this port.
const VariableProximity = ({
  label,
  fromFontVariationSettings = "'wght' 400",
  toFontVariationSettings = "'wght' 800",
  containerRef,
  radius = 50,
  falloff = "linear",
  startIndex = 0,
  className = "",
  style,
}) => {
  const { useRef, useEffect, useMemo, useState } = React;

  const rootRef = useRef(null);
  const [entered, setEntered] = useState(false);
  const letterRefs = useRef([]);
  const centersRef = useRef([]);
  const rafRef = useRef(0);
  const mouseRef = useRef({ x: 0, y: 0 });
  const activeRef = useRef(false);

  const parsed = useMemo(() => {
    const parse = (str) =>
      new Map(
        String(str)
          .split(",")
          .map((s) => s.trim())
          .filter(Boolean)
          .map((s) => {
            const i = s.lastIndexOf(" ");
            return [s.slice(0, i).replace(/['"]/g, ""), parseFloat(s.slice(i + 1))];
          })
      );
    const from = parse(fromFontVariationSettings);
    const to = parse(toFontVariationSettings);
    return Array.from(from.entries()).map(([axis, fromValue]) => ({
      axis, fromValue, toValue: to.has(axis) ? to.get(axis) : fromValue,
    }));
  }, [fromFontVariationSettings, toFontVariationSettings]);

  useEffect(() => {
    const container = containerRef && containerRef.current;
    if (!container) return;
    const mq = window.matchMedia;
    if (mq && (mq("(prefers-reduced-motion: reduce)").matches || mq("(pointer: coarse)").matches)) return;

    const measure = () => {
      // Drop refs left over from a longer label, so a language switch cannot leave a stale
      // centre pointing at a letter that no longer exists.
      letterRefs.current.length = Array.from(String(label).replace(/ /g, "")).length;
      const c = container.getBoundingClientRect();
      centersRef.current = letterRefs.current.map((el) => {
        if (!el) return null;
        const r = el.getBoundingClientRect();
        return { x: r.left + r.width / 2 - c.left, y: r.top + r.height / 2 - c.top };
      });
    };

    const ease = (distance) => {
      const norm = Math.min(Math.max(1 - distance / radius, 0), 1);
      if (falloff === "exponential") return norm * norm;
      if (falloff === "gaussian") return Math.exp(-(((distance / (radius / 2)) ** 2) / 2));
      return norm;
    };

    const apply = () => {
      rafRef.current = 0;
      const c = container.getBoundingClientRect();
      const mx = mouseRef.current.x - c.left;
      const my = mouseRef.current.y - c.top;

      // Outside the container plus a radius of slack, nothing can be lit. Reset once, then
      // stop doing work until the pointer comes back.
      const near = mx > -radius && my > -radius && mx < c.width + radius && my < c.height + radius;
      if (!near) {
        if (activeRef.current) {
          letterRefs.current.forEach((el) => { if (el) el.style.fontVariationSettings = fromFontVariationSettings; });
          activeRef.current = false;
        }
        return;
      }
      activeRef.current = true;

      const centers = centersRef.current;
      letterRefs.current.forEach((el, i) => {
        const p = centers[i];
        if (!el || !p) return;
        const d = Math.hypot(p.x - mx, p.y - my);
        if (d >= radius) { el.style.fontVariationSettings = fromFontVariationSettings; return; }
        const t = ease(d);
        el.style.fontVariationSettings = parsed
          .map(({ axis, fromValue, toValue }) => `'${axis}' ${fromValue + (toValue - fromValue) * t}`)
          .join(", ");
      });
    };

    const onMove = (e) => {
      mouseRef.current.x = e.clientX;
      mouseRef.current.y = e.clientY;
      if (!rafRef.current) rafRef.current = requestAnimationFrame(apply);
    };

    measure();
    // The heading is set in a webfont that arrives after first paint, and its metrics decide
    // where every letter sits. Measuring before it lands caches the fallback's positions.
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure);

    window.addEventListener("mousemove", onMove, { passive: true });
    const ro = new ResizeObserver(measure);
    ro.observe(container);
    return () => {
      window.removeEventListener("mousemove", onMove);
      ro.disconnect();
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, [label, radius, falloff, parsed, fromFontVariationSettings, containerRef]);

  // The entrance. It reuses BlurText's `ps-blur-in` keyframe on the letters that already
  // exist here — these are split for the hover effect and cannot be split a second time by
  // nesting BlurText inside, so the animation comes to them instead. Kept separate from the
  // proximity effect above, which needs a fine pointer; an entrance is for everyone.
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setEntered(true);
      return;
    }
    document.documentElement.classList.add("ps-anim");
    let done = false;
    const fire = () => { if (done) return; done = true; cleanup(); setEntered(true); };
    const check = () => {
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight || document.documentElement.clientHeight;
      if (r.top < vh && r.bottom > 0) fire();
    };
    let io = null;
    function cleanup() {
      if (io) { io.disconnect(); io = null; }
      window.removeEventListener("scroll", check);
      window.removeEventListener("resize", check);
    }
    if (typeof IntersectionObserver === "function") {
      io = new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) fire(); }, { threshold: 0.1 });
      io.observe(el);
    }
    window.addEventListener("scroll", check, { passive: true });
    window.addEventListener("resize", check);
    check();
    return cleanup;
  }, [label]);

  // Indexed, not pushed. React re-invokes an inline ref callback on every update, so a
  // push-based array would double up the moment the dictionary changes under a language
  // switch, and every cached centre would then belong to the wrong letter.
  const words = String(label).split(" ");
  let letterIndex = 0;
  const entranceClass = entered ? "is-in" : "bi-pending";
  return (
    <span ref={rootRef}
          className={`variable-proximity ${entranceClass} ${className}`.trim()}
          style={style}>
      {words.map((word, wi) => (
        <React.Fragment key={wi}>
          <span style={{ display: "inline-block", whiteSpace: "nowrap" }} aria-hidden="true">
            {/* `bi-unit` goes on the same element as --bi-delay. The entrance CSS used to
                target the word wrapper instead, which carries no delay, so every letter
                animated at 0ms and the heading arrived as one block. */}
            {Array.from(word).map((ch, ci) => {
              const at = letterIndex++;
              return (
                <span key={ci}
                      className="bi-unit"
                      ref={(el) => { letterRefs.current[at] = el; }}
                      style={{ display: "inline-block",
                               fontVariationSettings: fromFontVariationSettings,
                               "--bi-delay": `${(startIndex + at) * 26}ms` }}>
                  {ch}
                </span>
              );
            })}
          </span>
          {/* A real space, not upstream's &nbsp;. Between inline-block words a non-breaking
              space removes every break opportunity in the heading, and these are multi-word
              Greek lines that have to wrap on a phone. */}
          {wi < words.length - 1 ? " " : null}
        </React.Fragment>
      ))}
      {/* The visible letters are aria-hidden, so the heading's accessible name comes from
          here — without it a screen reader would read the h1 as nothing at all. */}
      <span className="sr-only">{label}</span>
    </span>
  );
};
window.VariableProximity = VariableProximity;
