import { computed, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { clamp, isFunction, isNumber, lerp, noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useRafFn } from '@/composables/animation/useRafFn';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';

/**
 * Cubic bezier control points `[x1, y1, x2, y2]` (the implied endpoints are
 * `(0, 0)` and `(1, 1)`), matching the CSS `cubic-bezier()` argument order.
 */
export type CubicBezierPoints = [number, number, number, number];

/**
 * An easing function mapping linear progress in `[0, 1]` to eased progress.
 */
export type EasingFunction = (n: number) => number;

/**
 * Interpolates between two values of `T` given an eased progress `alpha`.
 */
export type TransitionInterpolation<T> = (from: T, to: T, alpha: number) => T;

/**
 * The transition easing: either a cubic bezier tuple or a custom easing function.
 */
export type TransitionEasing = CubicBezierPoints | EasingFunction;

/**
 * Values that can be transitioned: a single number or a fixed-length number array.
 */
export type TransitionValue = number | number[];

/**
 * Common cubic bezier easing presets (same curves as CSS / VueUse).
 */
export const TransitionPresets = {
  linear: [0, 0, 1, 1],
  easeInSine: [0.12, 0, 0.39, 0],
  easeOutSine: [0.61, 1, 0.88, 1],
  easeInOutSine: [0.37, 0, 0.63, 1],
  easeInQuad: [0.11, 0, 0.5, 0],
  easeOutQuad: [0.5, 1, 0.89, 1],
  easeInOutQuad: [0.45, 0, 0.55, 1],
  easeInCubic: [0.32, 0, 0.67, 0],
  easeOutCubic: [0.33, 1, 0.68, 1],
  easeInOutCubic: [0.65, 0, 0.35, 1],
  easeInQuart: [0.5, 0, 0.75, 0],
  easeOutQuart: [0.25, 1, 0.5, 1],
  easeInOutQuart: [0.76, 0, 0.24, 1],
  easeInQuint: [0.64, 0, 0.78, 0],
  easeOutQuint: [0.22, 1, 0.36, 1],
  easeInOutQuint: [0.83, 0, 0.17, 1],
  easeInExpo: [0.7, 0, 0.84, 0],
  easeOutExpo: [0.16, 1, 0.3, 1],
  easeInOutExpo: [0.87, 0, 0.13, 1],
  easeInCirc: [0.55, 0, 1, 0.45],
  easeOutCirc: [0, 0.55, 0.45, 1],
  easeInOutCirc: [0.85, 0, 0.15, 1],
  easeInBack: [0.36, 0, 0.66, -0.56],
  easeOutBack: [0.34, 1.56, 0.64, 1],
  easeInOutBack: [0.68, -0.6, 0.32, 1.6],
} satisfies Record<string, CubicBezierPoints>;

export interface UseTransitionOptions {
  /**
   * Transition duration in milliseconds. Accepts a reactive value (resolved
   * at the start of each transition).
   *
   * @default 1000
   */
  duration?: MaybeRefOrGetter<number>;

  /**
   * Easing applied to the progress: a cubic bezier tuple (e.g. one of
   * {@link TransitionPresets}) or a custom easing function.
   *
   * @default identity (linear)
   */
  transition?: MaybeRefOrGetter<TransitionEasing>;

  /**
   * Delay in milliseconds before a transition begins after the source changes.
   *
   * @default 0
   */
  delay?: MaybeRefOrGetter<number>;

  /**
   * When `true`, transitions are skipped and the output tracks the source
   * value directly (no animation). Reactive.
   *
   * @default false
   */
  disabled?: MaybeRefOrGetter<boolean>;

  /**
   * Called when a transition starts.
   */
  onStarted?: () => void;

  /**
   * Called when a transition finishes (not called when aborted by a new change).
   */
  onFinished?: () => void;
}

export type UseTransitionReturn<T> = Readonly<Ref<T>>;

const identity: EasingFunction = n => n;

interface BezierCoefficients {
  a: (a1: number, a2: number) => number;
  b: (a1: number, a2: number) => number;
  c: (a1: number) => number;
}

function createEasingFunction(points: CubicBezierPoints): EasingFunction {
  const [p0, p1, p2, p3] = points;

  const coeffs: BezierCoefficients = {
    a: (a1, a2) => 1 - 3 * a2 + 3 * a1,
    b: (a1, a2) => 3 * a2 - 6 * a1,
    c: a1 => 3 * a1,
  };

  const calcBezier = (t: number, a1: number, a2: number): number =>
    ((coeffs.a(a1, a2) * t + coeffs.b(a1, a2)) * t + coeffs.c(a1)) * t;

  const getSlope = (t: number, a1: number, a2: number): number =>
    3 * coeffs.a(a1, a2) * t * t + 2 * coeffs.b(a1, a2) * t + coeffs.c(a1);

  const getTForX = (x: number): number => {
    let guess = x;

    for (let i = 0; i < 4; ++i) {
      const slope = getSlope(guess, p0, p2);

      if (slope === 0)
        return guess;

      const currentX = calcBezier(guess, p0, p2) - x;
      guess -= currentX / slope;
    }

    return guess;
  };

  return n => (p0 === p1 && p2 === p3) ? n : calcBezier(getTForX(n), p1, p3);
}

function resolveEasing(transition: TransitionEasing | undefined): EasingFunction {
  if (!transition)
    return identity;

  if (isFunction(transition))
    return transition;

  return createEasingFunction(transition);
}

// Interpolate a single number or a (fixed-length) numeric array.
function interpolate(from: TransitionValue, to: TransitionValue, alpha: number): TransitionValue {
  if (isNumber(from) && isNumber(to))
    return lerp(from, to, alpha);

  const source = from as number[];
  const target = to as number[];

  return source.map((value, index) => lerp(value, target[index] ?? value, alpha));
}

function snapshot<T extends TransitionValue>(value: T): T {
  return (isNumber(value) ? value : (value as number[]).slice()) as T;
}

function valuesEqual(a: TransitionValue, b: TransitionValue): boolean {
  if (isNumber(a) && isNumber(b))
    return a === b;

  if (isNumber(a) || isNumber(b))
    return false;

  if (a.length !== b.length)
    return false;

  for (let i = 0; i < a.length; ++i) {
    if (a[i] !== b[i])
      return false;
  }

  return true;
}

/**
 * @name useTransition
 * @category Animation
 * @description Reactively transition between numeric values (or numeric arrays)
 * over a duration with configurable easing. Wraps a single, paused
 * `requestAnimationFrame` loop that only runs while a transition is in flight,
 * so it is cheaper than re-creating an RAF loop per change. SSR-safe: without a
 * `window` the output tracks the source synchronously.
 *
 * @param {MaybeRefOrGetter<T>} source The reactive source value (number or number[])
 * @param {UseTransitionOptions & ConfigurableWindow} [options={}] Transition options
 * @returns {UseTransitionReturn<T>} A readonly ref of the transitioned value
 *
 * @example
 * const progress = ref(0);
 * const output = useTransition(progress, {
 *   duration: 500,
 *   transition: TransitionPresets.easeOutCubic,
 * });
 *
 * @example
 * // Transition a tuple (e.g. an RGB color)
 * const color = ref([0, 0, 0]);
 * const animated = useTransition(color, { duration: 1000 });
 *
 * @since 0.0.14
 */
export function useTransition<T extends TransitionValue>(
  source: MaybeRefOrGetter<T>,
  options: UseTransitionOptions & ConfigurableWindow = {},
): UseTransitionReturn<T> {
  const {
    duration = 1000,
    transition = identity,
    delay = 0,
    disabled = false,
    onStarted = noop,
    onFinished = noop,
  } = options;

  const window = 'window' in options ? options.window : defaultWindow;

  // The animated output. Seeded with a snapshot of the current source.
  const outputRef = ref(snapshot(toValue(source))) as Ref<T>;

  // Active-transition state. `endpoints` are detached snapshots so that later
  // source mutations cannot bleed into an in-flight transition.
  let fromValue: T = outputRef.value;
  let toValue_: T = outputRef.value;
  let startedAt = 0;
  let durationMs = 0;
  let easing: EasingFunction = identity;
  let delayTimer: ReturnType<typeof setTimeout> | null = null;
  let finishPending = false;

  const { pause, resume, isActive } = useRafFn(tick, { immediate: false, window });

  function clearDelay() {
    if (delayTimer !== null && window) {
      window.clearTimeout(delayTimer);
      delayTimer = null;
    }
  }

  function settle(value: T) {
    outputRef.value = snapshot(value);

    if (isActive.value)
      pause();

    if (finishPending) {
      finishPending = false;
      onFinished();
    }
  }

  function tick() {
    const now = Date.now();
    const alpha = durationMs <= 0 ? 1 : clamp((now - startedAt) / durationMs, 0, 1);

    outputRef.value = interpolate(fromValue, toValue_, easing(alpha)) as T;

    if (alpha >= 1)
      settle(toValue_);
  }

  function begin(target: T) {
    fromValue = snapshot(outputRef.value);
    toValue_ = snapshot(target);

    durationMs = Math.max(0, toValue(duration));
    easing = resolveEasing(toValue(transition));
    startedAt = Date.now();
    finishPending = true;

    onStarted();

    if (durationMs <= 0 || !window) {
      settle(toValue_);
      return;
    }

    if (!isActive.value)
      resume();
  }

  function start(target: T) {
    clearDelay();

    const delayMs = Math.max(0, toValue(delay));

    if (delayMs > 0 && window) {
      delayTimer = window.setTimeout(() => {
        delayTimer = null;
        begin(target);
      }, delayMs);

      return;
    }

    begin(target);
  }

  watch(
    () => toValue(source),
    (value) => {
      // When disabled, mirror the source instantly and abort any animation.
      if (toValue(disabled)) {
        clearDelay();

        if (isActive.value)
          pause();

        finishPending = false;
        outputRef.value = snapshot(value);

        return;
      }

      // Skip no-op changes so we don't restart an identical transition.
      if (valuesEqual(value, outputRef.value) && !isActive.value && delayTimer === null)
        return;

      start(value);
    },
    { deep: true },
  );

  // Reacting to `disabled` flipping to true mid-transition: snap to source.
  watch(
    () => toValue(disabled),
    (off) => {
      if (off) {
        clearDelay();

        if (isActive.value)
          pause();

        finishPending = false;
        outputRef.value = snapshot(toValue(source));
      }
    },
  );

  // The RAF loop is torn down by useRafFn on scope dispose, but a pending start
  // delay (window.setTimeout) is not — clear it so the timer can't fire into a
  // disposed scope.
  tryOnScopeDispose(clearDelay);

  return computed(() => outputRef.value);
}
