/* CountUp — rAF counter, no external libs. Counts 0 → end once, on first viewport
   entry (observer disconnects — no repeat on re-scroll). If the element is jumped
   PAST between frames (anchor/End-key teleport — IO never reports an intersection),
   a passive scroll check snaps it to the final value, so the number is never left
   at 0. Respects prefers-reduced-motion by rendering the final value immediately. */
function CountUp({ end, decimals = 0, decimalSep = '.', prefix = '', suffix = '', duration = 1300, delay = 0, style }) {
  const ref = React.useRef(null);
  const started = React.useRef(false);
  const [val, setVal] = React.useState(0);

  React.useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || !('IntersectionObserver' in window)) {
      setVal(end);
      return;
    }
    const el = ref.current;
    let io = null;
    let timer = null;

    function cleanup() {
      if (io) io.disconnect();
      if (timer) clearTimeout(timer);
      window.removeEventListener('scroll', onScroll);
    }
    /* Jumped past without ever being seen — snap straight to the final value. */
    function finish() {
      if (started.current) return;
      started.current = true;
      cleanup();
      setVal(end);
    }
    function begin() {
      if (started.current) return;
      started.current = true;
      cleanup();
      /* Anchor timing to the first EXECUTED frame — rAF is frozen in hidden
         documents (background tabs, thumbnail/export renderers), so anchoring at
         begin-time would make the count never run or instant-snap. */
      let t0 = null;
      const tick = (now) => {
        if (t0 === null) t0 = now + delay;
        const t = Math.min(Math.max((now - t0) / duration, 0), 1);
        const eased = 1 - Math.pow(1 - t, 3); /* easeOutCubic */
        setVal(end * eased);
        if (t < 1) requestAnimationFrame(tick);
      };
      requestAnimationFrame(tick);
      /* Hidden captures: timers DO fire in hidden documents — guarantee the final
         value is never lost there. Idempotent (visible runs end at `end` anyway;
         near-complete eased values render the same formatted string). */
      timer = setTimeout(() => setVal(end), delay + duration + 100);
    }
    function onScroll() {
      const r = el.getBoundingClientRect();
      if (r.bottom < 0) finish();                 /* fully above the viewport: passed */
      else if (r.top < 0 && r.bottom > 0) begin(); /* straddling the top edge: visible */
    }

    io = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (started.current) return;
        if (entry.isIntersecting) begin();
        else if (entry.boundingClientRect.top < 0) finish();
      });
    }, { rootMargin: '0px 0px -8% 0px' });
    io.observe(el);
    window.addEventListener('scroll', onScroll, { passive: true });
    return cleanup;
  }, []);

  const text = prefix + val.toFixed(decimals).replace('.', decimalSep) + suffix;
  return <span ref={ref} style={{ fontVariantNumeric: 'tabular-nums', ...style }}>{text}</span>;
}
window.CountUp = CountUp;
