// https://github.com/lodash/lodash/blob/main/src/debounce.ts
// https://github.com/xnimorz/use-debounce/blob/master/src/useDebouncedCallback.ts

export interface CallOptions {
  leading?: boolean;
  trailing?: boolean;
}

export interface ControlFunctions {
  cancel: () => void;
  flush: () => void;
  isPending: () => boolean;
}

export interface DebouncedFunction<T extends (...args: any[]) => ReturnType<T>>
  extends ControlFunctions {
  (...args: Parameters<T>): ReturnType<T> | undefined;
}

export function debounce<T extends (...args: any[]) => ReturnType<T>>(
  func: T,
  wait: number,
  options: CallOptions = {},
): DebouncedFunction<T> {
  let lastArgs: Parameters<T> | null = null;
  let result: ReturnType<T> | undefined;
  let timerId: ReturnType<typeof setTimeout> | null = null;
  let lastCallTime: number | null = null;
  let lastInvokeTime = 0;
  const leading = !!options.leading;
  const trailing = "trailing" in options ? !!options.trailing : true;

  const invokeFunc = (time: number) => {
    if (lastArgs) {
      result = func(...lastArgs);
      lastArgs = null;
    }
    lastInvokeTime = time;
    return result;
  };

  const startTimer = (pendingFunc: () => void, wait: number) => {
    cancelTimer();
    timerId = setTimeout(pendingFunc, wait);
  };

  const cancelTimer = () => {
    if (timerId !== null) {
      clearTimeout(timerId);
      timerId = null;
    }
  };

  const shouldInvoke = (time: number) => {
    const timeSinceLastCall = time - (lastCallTime ?? 0);
    const timeSinceLastInvoke = time - lastInvokeTime;

    return (
      lastCallTime === null ||
      timeSinceLastCall >= wait ||
      timeSinceLastCall < 0
    );
  };

  const timerExpired = () => {
    const time = Date.now();
    if (shouldInvoke(time)) {
      return trailingEdge(time);
    }
    startTimer(timerExpired, wait);
  };

  const trailingEdge = (time: number) => {
    timerId = null;
    if (trailing && lastArgs) {
      return invokeFunc(time);
    }
    lastArgs = null;
    return result;
  };

  const leadingEdge = (time: number) => {
    lastInvokeTime = time;
    timerId = setTimeout(timerExpired, wait);
    return leading ? invokeFunc(time) : result;
  };

  const debounced: DebouncedFunction<T> = (...args: Parameters<T>) => {
    const time = Date.now();
    lastArgs = args;
    lastCallTime = time;

    if (shouldInvoke(time)) {
      if (timerId === null) {
        return leadingEdge(lastCallTime);
      }
    }
    if (timerId === null) {
      startTimer(timerExpired, wait);
    }
    return result;
  };

  debounced.cancel = () => {
    if (timerId !== null) {
      cancelTimer();
    }
    lastInvokeTime = 0;
    lastArgs = lastCallTime = timerId = null;
  };

  debounced.flush = () => {
    return timerId === null ? result : trailingEdge(Date.now());
  };

  debounced.isPending = () => {
    return timerId !== null;
  };

  return debounced;
}
