// AnimatedNumber — counts UP to the target value when scrolled into view.
// Mounts an IntersectionObserver, runs ONE rAF loop for ~700ms, then stops.
// No re-trigger.
//
// Use for the stats row on the landing — gives the impression of
// "live numbers" without actually polling anything.
//
// Respects prefers-reduced-motion (jumps straight to the final value).
//
// IT RENDERS THE FINAL VALUE, AND THAT IS NOT A DETAIL. The first version
// initialised its state to 0, so every statically rendered page SHIPPED a zero:
// the landing's own stats row went out as "0 … 0 … 0% … 0" in the markup, and
// that is what a crawler, an answer engine and a reader with JavaScript off all
// saw. A number that only exists after hydration is not a number on the page.
//
// So the initial state is the TARGET — which also means the server's markup and
// the first client render agree, so there is no hydration mismatch — and the
// count-up starts from zero in an effect, after mount, when the element scrolls
// into view. The animation is decoration layered onto a correct page, rather
// than the only way to see the value.

import { useEffect, useRef, useState, type ReactNode } from 'react'

interface AnimatedNumberProps {
  /** Target value. */
  readonly value: number
  /** Formatting — `'integer'` (default), `'percent'`, or a custom
   *  formatter. */
  readonly format?: 'integer' | 'percent' | ((n: number) => string)
  /** Animation duration in ms. Default 800. */
  readonly duration?: number
}

const fmt = (n: number, format: AnimatedNumberProps['format']): string => {
  if (typeof format === 'function') return format(n)
  if (format === 'percent') return `${n.toFixed(0)}%`
  return Math.round(n).toLocaleString('en-US')
}

export const AnimatedNumber = ({
  value, format = 'integer', duration = 800,
}: AnimatedNumberProps): ReactNode => {
  const ref = useRef<HTMLSpanElement>(null)
  // Seeded with the final value — see the header. Never with 0.
  const [current, setCurrent] = useState(value)

  useEffect(() => {
    // No observer (SSR-ish runtimes, old browsers) and reduced motion both keep
    // the value exactly as rendered. Nothing to do in either case.
    if (typeof IntersectionObserver === 'undefined') return
    const prefersReduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
    if (prefersReduced) return
    const node = ref.current
    if (!node) return
    const observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          observer.disconnect()
          // Drop to zero HERE, not at render — this is the first moment the
          // animation is actually going to run.
          setCurrent(0)
          const start = performance.now()
          const tick = (now: number): void => {
            const t = Math.min((now - start) / duration, 1)
            // ease-out cubic — fast at first, gentle stop
            const eased = 1 - Math.pow(1 - t, 3)
            setCurrent(value * eased)
            if (t < 1) requestAnimationFrame(tick)
            else setCurrent(value)
          }
          requestAnimationFrame(tick)
          return
        }
      }
    }, { threshold: 0.4 })
    observer.observe(node)
    return () => observer.disconnect()
  }, [value, duration])

  return (
    <span ref={ref} className="font-tabular">
      {fmt(current, format)}
    </span>
  )
}
