"use client";

import { useLocale } from "next-intl";
import { useEffect, useMemo, useRef } from "react";

import { useInView } from "@/hooks/useInView";
import { cn } from "@/lib/utils";

type CountUpProps = {
  /** Final value, e.g. 37, 500 or 3.5 */
  target: number;
  decimals?: number;
  className?: string;
};

const DURATION_MS = 1800;
/** Visible fraction of the numeral that starts the count. */
const IN_VIEW_RATIO = 0.3;

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

/**
 * Animated stat counter: counts from 0 to `target` with an ease-out cubic
 * once the element scrolls into view.
 *
 * The count is a *transient* value — 108 intermediate numerals over 1800 ms
 * that nothing else in the tree ever reads. It therefore never enters React
 * state: the final value is what renders (server and client alike), and the
 * animation writes straight into the DOM node through a ref, one text-node
 * assignment per frame. Zero re-renders, and the numeral stays
 * correct and crawlable without JavaScript, with `prefers-reduced-motion`, or
 * before the element ever scrolls in.
 *
 * The counting numeral is meaningless to assistive tech; callers that need it
 * announced pair the `aria-hidden` numeral with a visually hidden twin holding
 * the final, formatted figure (see `StatsSection`).
 */
export default function CountUp({ target, decimals = 0, className }: CountUpProps) {
  const locale = useLocale();
  const [ref, inView] = useInView<HTMLSpanElement>({ threshold: IN_VIEW_RATIO, once: true });
  const valueRef = useRef<HTMLSpanElement>(null);

  // Built once per locale/precision instead of once per frame: `Intl.NumberFormat`
  // wraps an ICU allocation, and the animation would otherwise construct one on
  // every render it triggered.
  const format = useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
      }),
    [locale, decimals],
  );

  useEffect(() => {
    const node = valueRef.current;
    if (!inView || !node) {
      return;
    }
    // The rendered numeral is already the final value, so honouring the motion
    // preference is simply a matter of leaving it alone.
    if (prefersReducedMotion()) {
      return;
    }

    // Write into the very text node React rendered instead of assigning
    // `textContent`, which would drop it and insert a fresh one: React would
    // keep its reference to the detached node, and its own re-renders (a locale
    // switch, so a new formatted string) would then land nowhere visible.
    const text = node.firstChild;
    if (!(text instanceof Text)) {
      return;
    }

    let raf = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const progress = Math.min((now - start) / DURATION_MS, 1);
      const eased = 1 - (1 - progress) ** 3;
      text.data = format.format(target * eased);
      if (progress < 1) {
        raf = requestAnimationFrame(tick);
        return;
      }
      // Land exactly on the target: the eased value only approaches it.
      text.data = format.format(target);
    };
    // The rewind to 0 happens inside the first frame rather than on mount, so a
    // band that is already on screen at load never flashes its answer first.
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [inView, target, format]);

  return (
    // The invisible target value reserves the final width up front so the
    // count-up never reflows surrounding content (no layout shift).
    <span className={cn("relative inline-block whitespace-nowrap", className)} ref={ref}>
      <span aria-hidden="true" className="invisible">
        {format.format(target)}
      </span>
      <span className="absolute inset-0" ref={valueRef}>
        {format.format(target)}
      </span>
    </span>
  );
}
