/**
 * The replay transport.
 *
 * A recorded rollout is played back on the timings the model actually took.
 * That is not decoration: "the second guess took four seconds and 900 tokens"
 * is one of the few things on this page an executive can feel rather than read.
 *
 * So the hard rule here is that **no code path invents a duration silently**.
 * When a step's `call.durationMs` is null the player falls back to
 * `FALLBACK_STEP_MS` and says so — `timingIsReal` goes false and `invented[i]`
 * marks the step — so the UI can label the timeline as approximate instead of
 * quietly presenting a made-up number as a measurement.
 *
 * Playback always starts PAUSED. A board that animates itself the moment the
 * page loads has already played its best moment to a visitor who was still
 * reading the headline.
 */

import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import type { DemoStep } from './types';

/** 1x, 2x, 4x, or straight to the end. */
export type PlaybackSpeed = 1 | 2 | 4 | 'instant';

export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant'];

export function isPlaybackSpeed(value: unknown): value is PlaybackSpeed {
  return value === 1 || value === 2 || value === 4 || value === 'instant';
}

/**
 * Dwell for a step whose real duration was not recorded. Exported and named so
 * that when it appears on screen it can be labelled as the estimate it is.
 */
export const FALLBACK_STEP_MS = 900;

/**
 * How often the fractional progress within a step is pushed into React state.
 *
 * Not every frame, on purpose: `progress` re-renders the whole demo page, and
 * at 60 Hz on a phone that is the difference between a smooth board and a warm
 * one. ~15 Hz is plenty as long as whatever consumes it has a CSS transition on
 * the property it drives — give your progress bar `transition-[width]
 * duration-1` and the gaps disappear.
 */
const PROGRESS_TICK_MS = 66;

const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';

function subscribeReducedMotion(onChange: () => void): () => void {
  if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
    return () => undefined;
  }
  const query = window.matchMedia(REDUCED_MOTION_QUERY);
  query.addEventListener('change', onChange);
  return () => query.removeEventListener('change', onChange);
}

function readReducedMotion(): boolean {
  if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
  return window.matchMedia(REDUCED_MOTION_QUERY).matches;
}

/** Live `prefers-reduced-motion`. Re-renders when the OS setting changes. */
export function usePrefersReducedMotion(): boolean {
  return useSyncExternalStore(subscribeReducedMotion, readReducedMotion, () => false);
}

/** The recorded duration, or `null` when the trace did not carry a usable one. */
function recordedDurationMs(step: DemoStep<unknown> | undefined): number | null {
  const recorded = step?.call?.durationMs;
  if (recorded === null || recorded === undefined) return null;
  // A zero or negative duration is a recording artefact, not a measurement.
  if (!Number.isFinite(recorded) || recorded <= 0) return null;
  return recorded;
}

export interface PlayerOptions {
  /** Where to start — typically the `step` URL param. Clamped. */
  initialIndex?: number;
  initialSpeed?: PlaybackSpeed;
  /** Fired on every index change, including seeks. Used to sync the permalink. */
  onIndexChange?: (index: number) => void;
  /** Override the invented dwell. Still reported as invented. */
  fallbackStepMs?: number;
}

export interface Player<TState> {
  index: number;
  /** `undefined` only when there are no steps at all. */
  step: DemoStep<TState> | undefined;
  stepCount: number;
  isPlaying: boolean;
  speed: PlaybackSpeed;
  /** 0..1 through the current step. Pinned to 0 under reduced motion. */
  progress: number;
  elapsedMs: number;
  totalMs: number;
  atStart: boolean;
  atEnd: boolean;
  /** The OS setting, surfaced so surfaces can skip their own animations too. */
  reducedMotion: boolean;
  /** False when ANY step's dwell was invented. Label the timeline when false. */
  timingIsReal: boolean;
  /** Per step: was this dwell invented? */
  invented: readonly boolean[];
  /** The dwell actually used per step, real or invented. */
  durationsMs: readonly number[];
  fallbackStepMs: number;
  play: () => void;
  pause: () => void;
  toggle: () => void;
  /** Jump to a step. Does NOT change play/pause — a scrubber drag keeps playing. */
  seek: (index: number) => void;
  /** Manual stepping pauses: you asked to look at this one. */
  next: () => void;
  prev: () => void;
  restart: () => void;
  setSpeed: (speed: PlaybackSpeed) => void;
}

export function usePlayer<TState>(
  steps: readonly DemoStep<TState>[],
  options: PlayerOptions = {},
): Player<TState> {
  const fallbackStepMs = options.fallbackStepMs ?? FALLBACK_STEP_MS;
  const stepCount = steps.length;
  const lastIndex = Math.max(0, stepCount - 1);
  const reducedMotion = usePrefersReducedMotion();

  const [index, setIndexState] = useState(() =>
    clamp(options.initialIndex ?? 0, 0, Math.max(0, steps.length - 1)),
  );
  const [isPlaying, setIsPlaying] = useState(false);
  const [speed, setSpeedState] = useState<PlaybackSpeed>(options.initialSpeed ?? 1);
  const [progress, setProgress] = useState(0);

  const indexRef = useRef(index);
  const elapsedRef = useRef(0);
  const lastProgressPushRef = useRef(0);

  // Held in a ref so changing the callback never restarts the animation loop.
  const onIndexChangeRef = useRef(options.onIndexChange);
  onIndexChangeRef.current = options.onIndexChange;

  const durationsMs = useMemo(
    () => steps.map((step) => recordedDurationMs(step) ?? fallbackStepMs),
    [steps, fallbackStepMs],
  );
  const invented = useMemo(() => steps.map((step) => recordedDurationMs(step) === null), [steps]);
  const timingIsReal = useMemo(() => !invented.includes(true), [invented]);

  const cumulativeMs = useMemo(() => {
    let running = 0;
    return durationsMs.map((duration) => {
      const start = running;
      running += duration;
      return start;
    });
  }, [durationsMs]);
  const totalMs = useMemo(() => durationsMs.reduce((sum, d) => sum + d, 0), [durationsMs]);

  const dwellAt = useCallback(
    (at: number) => durationsMs[at] ?? fallbackStepMs,
    [durationsMs, fallbackStepMs],
  );

  const commitIndex = useCallback((next: number) => {
    if (indexRef.current === next) return;
    indexRef.current = next;
    setIndexState(next);
    onIndexChangeRef.current?.(next);
  }, []);

  // A new steps array means a new run. Rewind rather than leaving the transport
  // pointing at step 7 of a rollout that only has four turns.
  const stepsRef = useRef(steps);
  useEffect(() => {
    if (stepsRef.current === steps) return;
    stepsRef.current = steps;
    elapsedRef.current = 0;
    setIsPlaying(false);
    setProgress(0);
    commitIndex(0);
  }, [steps, commitIndex]);

  const seek = useCallback(
    (to: number) => {
      elapsedRef.current = 0;
      setProgress(0);
      commitIndex(clamp(to, 0, Math.max(0, stepsRef.current.length - 1)));
    },
    [commitIndex],
  );

  const play = useCallback(() => {
    if (stepsRef.current.length === 0) return;
    // Pressing play on the final step replays from the top; the alternative is
    // a button that visibly does nothing.
    if (indexRef.current >= stepsRef.current.length - 1) {
      elapsedRef.current = 0;
      setProgress(0);
      commitIndex(0);
    }
    setIsPlaying(true);
  }, [commitIndex]);

  const pause = useCallback(() => setIsPlaying(false), []);
  const toggle = useCallback(() => {
    if (isPlaying) pause();
    else play();
  }, [isPlaying, pause, play]);

  const next = useCallback(() => {
    setIsPlaying(false);
    seek(indexRef.current + 1);
  }, [seek]);

  const prev = useCallback(() => {
    setIsPlaying(false);
    seek(indexRef.current - 1);
  }, [seek]);

  const restart = useCallback(() => {
    setIsPlaying(false);
    seek(0);
  }, [seek]);

  const setSpeed = useCallback((nextSpeed: PlaybackSpeed) => setSpeedState(nextSpeed), []);

  useEffect(() => {
    if (!isPlaying || stepCount === 0) return;

    let raf = 0;
    let previous = performance.now();

    const frame = (now: number): void => {
      // `instant` is expressed as infinite elapsed time rather than an infinite
      // rate: `(now - previous) * Infinity` is NaN on the very first frame,
      // where `now === previous`, and NaN would freeze the transport forever.
      const gained = speed === 'instant' ? Number.POSITIVE_INFINITY : (now - previous) * speed;
      previous = now;

      const startIndex = indexRef.current;
      let elapsed = elapsedRef.current + gained;
      let at = startIndex;

      while (at < lastIndex && elapsed >= dwellAt(at)) {
        elapsed -= dwellAt(at);
        at += 1;
      }

      if (at >= lastIndex && elapsed >= dwellAt(lastIndex)) {
        elapsedRef.current = dwellAt(lastIndex);
        commitIndex(lastIndex);
        setProgress(1);
        setIsPlaying(false);
        return; // Run over. Deliberately not scheduling another frame.
      }

      elapsedRef.current = elapsed;

      if (at !== startIndex) {
        commitIndex(at);
        lastProgressPushRef.current = now;
        // Reduced motion still advances on real time — the content is not the
        // animation — but no fractional progress is emitted, so nothing on the
        // page is being driven frame by frame.
        setProgress(reducedMotion ? 0 : Math.min(1, elapsed / dwellAt(at)));
      } else if (!reducedMotion && now - lastProgressPushRef.current >= PROGRESS_TICK_MS) {
        lastProgressPushRef.current = now;
        setProgress(Math.min(1, elapsed / dwellAt(at)));
      }

      raf = requestAnimationFrame(frame);
    };

    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [isPlaying, speed, stepCount, lastIndex, dwellAt, reducedMotion, commitIndex]);

  const elapsedMs = (cumulativeMs[index] ?? 0) + progress * dwellAt(index);

  return {
    index,
    step: steps[index],
    stepCount,
    isPlaying,
    speed,
    progress,
    elapsedMs,
    totalMs,
    atStart: index === 0,
    atEnd: stepCount === 0 || index >= lastIndex,
    reducedMotion,
    timingIsReal,
    invented,
    durationsMs,
    fallbackStepMs,
    play,
    pause,
    toggle,
    seek,
    next,
    prev,
    restart,
    setSpeed,
  };
}

function clamp(value: number, min: number, max: number): number {
  if (!Number.isFinite(value)) return min;
  return Math.min(max, Math.max(min, Math.trunc(value)));
}
