// BlurText — ported from React Bits (reactbits.dev) to plain JSX. Letters blur and rise into
// place in sequence, the first time their heading scrolls into view.
//
// Deviations from upstream, all deliberate:
//
//   * Letters, not upstream's words, and at a 26ms cadence rather than 200ms — the same
//     cadence the page `h1`s run at, so every heading on the site sweeps at one speed. Words
//     is still available via `animateBy`, but two-word Greek headings staggered by word read
//     as the whole line arriving at once, which is the thing this is meant to avoid.
//   * No `motion`. The animation is three keyframes with a per-letter delay, which is what CSS
//     keyframes are; `ps-blur-in` in main.css carries upstream's own from/to snapshots
//     (blur 10 -> 5 -> 0, opacity 0 -> 0.5 -> 1). The same keyframe drives the entrance on
//     the page `h1`s, whose letters VariableProximity has already split — see that file.
//   * It renders a `span`, not upstream's `p`. These are used inside `h2`, which takes
//     phrasing content only; a `<p>` in a heading is invalid and browsers will close the
//     heading early to recover from it.
//   * Inline flow, not `display: flex`. Upstream's flex row cannot be interrupted by the
//     `<br />` and the italic `.serif` span that every heading on this site is built from.
//     Per-word `inline-block` inside normal inline flow keeps both, and keeps wrapping.
//   * `startIndex` lets a second instance continue the first one's stagger, so a heading
//     split across two lines still reads as one sweep rather than two that restart.
//   * The hidden start state lives behind `html.ps-anim`, added by this file. If the script
//     never runs, nothing is hidden — an entrance that fails must degrade to no animation,
//     never to no text.
//   * An IntersectionObserver with a rect check on scroll behind it, for the same reason
//     CountUp has one: this project has already had an IO-driven reveal removed as
//     unreliable, and text that never arrives is the worst outcome here.
const BlurText = ({
  text = "",
  animateBy = "letters",
  direction = "top",
  delay = 26,
  stepDuration = 0.62,
  threshold = 0.1,
  rootMargin = "0px",
  startIndex = 0,
  className = "",
  onAnimationComplete,
}) => {
  const { useRef, useEffect, useState } = React;
  const ref = useRef(null);
  const [inView, setInView] = useState(false);

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

  useEffect(() => {
    if (reduced) { setInView(true); return; }
    document.documentElement.classList.add("ps-anim");
    const el = ref.current;
    if (!el) return;

    let done = false;
    const fire = () => {
      if (done) return;
      done = true;
      cleanup();
      setInView(true);
      if (typeof onAnimationComplete === "function") {
        const units = animateBy === "words"
          ? String(text).split(" ").length
          : Array.from(String(text).replace(/ /g, "")).length;
        const total = delay * Math.max(0, units - 1) + stepDuration * 1000;
        setTimeout(onAnimationComplete, total);
      }
    };
    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((entries) => {
        if (entries.some((e) => e.isIntersecting)) fire();
      }, { threshold, rootMargin });
      io.observe(el);
    }
    window.addEventListener("scroll", check, { passive: true });
    window.addEventListener("resize", check);
    check();
    return cleanup;
  }, [text, threshold, rootMargin, reduced]);

  const from = direction === "top" ? "-14px" : "14px";
  const mid = direction === "top" ? "3px" : "-3px";
  const vars = (n) => ({
    "--bi-delay": `${(startIndex + n) * delay}ms`,
    "--bi-dur": `${stepDuration * 1000}ms`,
    "--bi-from": from,
    "--bi-mid": mid,
  });

  // Words are the layout unit in both modes, and the letters are staggered inside them.
  // Splitting straight into letters would mean either inline-block spans wrapped around bare
  // spaces — which collapse to zero width, closing every gap in the heading — or upstream's
  // `&nbsp;`, which removes the line-break opportunity and stops a multi-word Greek heading
  // wrapping at all. This keeps both the spaces and the wrap points.
  const words = String(text).split(" ");
  let n = 0;
  return (
    <span ref={ref} className={`blur-text ${inView ? "is-in" : ""} ${className}`.trim()}>
      {words.map((word, wi) => {
        const at = n;
        n += animateBy === "words" ? 1 : Array.from(word).length;
        const byWord = animateBy === "words";
        return (
          <React.Fragment key={wi}>
            <span aria-hidden="true"
                  className={byWord ? "bi-unit" : ""}
                  style={byWord
                    ? Object.assign({ whiteSpace: "nowrap" }, vars(at))
                    : { display: "inline-block", whiteSpace: "nowrap" }}>
              {byWord
                ? word
                : Array.from(word).map((ch, ci) => (
                    <span key={ci} className="bi-unit" style={vars(at + ci)}>{ch}</span>
                  ))}
            </span>
            {wi < words.length - 1 ? " " : null}
          </React.Fragment>
        );
      })}
      {/* The split letters are aria-hidden, so this carries the readable copy. Without it a
          screen reader gets a heading made of single-character spans, which some announce
          one letter at a time. */}
      <span className="sr-only">{text}</span>
    </span>
  );
};
window.BlurText = BlurText;
