// CountUp — ported from React Bits (reactbits.dev) to plain JSX. The prop API is upstream's
// exactly (to, from, direction, delay, duration, className, startWhen, separator, onStart,
// onEnd), so call sites read the same as the documented component.
//
// The one structural deviation: upstream drives the number with `motion`'s useMotionValue +
// useSpring + useInView. This site has no build step and `motion` is ESM-only, so taking it
// would mean a fourth CDN module loaded on the home page to animate three numbers. Its whole
// job here is a spring integrator and an IntersectionObserver, which is what this file is.
// The spring solves the same equation with the same constants, so the motion matches:
//
//   damping = 20 + 40/duration, stiffness = 100/duration, mass = 1
//   a = (-stiffness * (x - target) - damping * v) / mass
//
// At every duration those constants are overdamped (at the default 2s, damping 40 against
// stiffness 50 puts the damping ratio near 2.8), so this reads as a firm ease-out and never
// overshoots the figure. That matters for a statistic: a number that bounces past 24 and
// settles back has told the reader something false, however briefly.
//
// Two further deviations, both required here rather than preferences:
//   * Under prefers-reduced-motion it renders the final value and never animates.
//   * The observer is backed by a rect check on scroll and resize. This project has already
//     had an IntersectionObserver-driven reveal removed for being unreliable (PS.useReveal
//     is a no-op kept only so call sites don't break), and a stat stuck reading 0 is a worse
//     failure than one that animates a beat late. The backstop asks whether the element is
//     actually on screen rather than trusting a timer — a plain "start it after N seconds"
//     watchdog fires while the row is still far below the fold, which is not a safety net,
//     it is the animation never being seen.
const CountUp = ({
  to,
  from = 0,
  direction = "up",
  delay = 0,
  duration = 2,
  className = "",
  startWhen = true,
  separator = "",
  onStart,
  onEnd,
}) => {
  const { useRef, useEffect, useCallback } = React;

  const ref = useRef(null);
  const rafRef = useRef(0);

  const start = direction === "down" ? to : from;
  const target = direction === "down" ? from : to;

  const decimalsOf = (num) => {
    const str = String(num);
    if (str.includes(".")) {
      const decimals = str.split(".")[1];
      if (parseInt(decimals, 10) !== 0) return decimals.length;
    }
    return 0;
  };
  const maxDecimals = Math.max(decimalsOf(from), decimalsOf(to));

  // en-US then a manual separator swap, as upstream does: the grouping character is the
  // caller's choice, not the visitor's locale, so the same figure reads the same in all five
  // languages rather than changing shape with the flag in the nav.
  const format = useCallback((latest) => {
    const opts = {
      useGrouping: !!separator,
      minimumFractionDigits: maxDecimals,
      maximumFractionDigits: maxDecimals,
    };
    const out = Intl.NumberFormat("en-US", opts).format(latest);
    return separator ? out.replace(/,/g, separator) : out;
  }, [maxDecimals, separator]);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

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

    if (reduced || !startWhen) {
      el.textContent = format(reduced ? target : start);
      return;
    }

    el.textContent = format(start);

    let startTimer = 0;
    let endTimer = 0;
    let running = false;

    const run = () => {
      if (running) return;
      running = true;
      teardownTriggers();

      startTimer = setTimeout(() => {
        if (typeof onStart === "function") onStart();

        const damping = 20 + 40 * (1 / duration);
        const stiffness = 100 * (1 / duration);
        // Half a display step. The spring is overdamped, so it approaches the target from
        // one side and never crosses back — once the remaining error is smaller than this,
        // no later frame can render a different string. Upstream instead runs to an absolute
        // 0.001, which at these constants is about seven seconds of rAF for a figure that
        // stopped changing after two: the tail is invisible and costs a frame callback per
        // stat per frame for five seconds. Snap and stop.
        const eps = 0.5 * Math.pow(10, -maxDecimals);
        let x = start;
        let v = 0;
        let last = performance.now();

        const tick = (now) => {
          // Clamped: a backgrounded tab hands back a dt of seconds, which would fling the
          // integrator past the target instead of easing into it.
          const dt = Math.min((now - last) / 1000, 1 / 30);
          last = now;
          const a = -stiffness * (x - target) - damping * v;
          v += a * dt;
          x += v * dt;

          if (Math.abs(target - x) < eps) {
            x = target;
            el.textContent = format(x);
            rafRef.current = 0;
            return;
          }
          el.textContent = format(x);
          rafRef.current = requestAnimationFrame(tick);
        };
        rafRef.current = requestAnimationFrame(tick);
      }, delay * 1000);

      // Upstream's onEnd is a timer at delay+duration, not the spring actually arriving —
      // with these constants the digits are still moving when it fires. Kept as-is so the
      // documented API behaves as documented; don't use it to sequence anything visual.
      endTimer = setTimeout(() => {
        if (typeof onEnd === "function") onEnd();
      }, delay * 1000 + duration * 1000);
    };

    let io = null;
    const onView = () => {
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight || document.documentElement.clientHeight;
      if (r.top < vh && r.bottom > 0) run();
    };
    function teardownTriggers() {
      if (io) { io.disconnect(); io = null; }
      window.removeEventListener("scroll", onView);
      window.removeEventListener("resize", onView);
    }

    if (typeof IntersectionObserver === "function") {
      io = new IntersectionObserver((entries) => {
        if (entries.some((e) => e.isIntersecting)) run();
      }, { rootMargin: "0px" });
      io.observe(el);
    }
    window.addEventListener("scroll", onView, { passive: true });
    window.addEventListener("resize", onView);
    onView();   // already on screen at mount

    return () => {
      teardownTriggers();
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      clearTimeout(startTimer);
      clearTimeout(endTimer);
    };
  }, [start, target, direction, delay, duration, startWhen, format, maxDecimals, onStart, onEnd]);

  return <span className={`count-up ${className}`.trim()} ref={ref} />;
};
window.CountUp = CountUp;
