// fork from https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts
// 文档可查看：https://es-toolkit.dev/reference/function/debounce.html
// 如需要 throttle 功能，可 copy https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts

interface DebounceOptions {
  /**
   * An optional AbortSignal to cancel the debounced function.
   */
  signal?: AbortSignal

  /**
   * An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both.
   * If `edges` includes "leading", the function will be invoked at the start of the delay period.
   * If `edges` includes "trailing", the function will be invoked at the end of the delay period.
   * If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period.
   * @default ["trailing"]
   */
  edges?: Array<'leading' | 'trailing'>
}

export interface DebouncedFunction<F extends (...args: any[]) => void> {
  (...args: Parameters<F>): void

  /**
   * Schedules the execution of the debounced function after the specified debounce delay.
   * This method resets any existing timer, ensuring that the function is only invoked
   * after the delay has elapsed since the last call to the debounced function.
   * It is typically called internally whenever the debounced function is invoked.
   *
   * @returns {void}
   */
  schedule: () => void

  /**
   * Cancels any pending execution of the debounced function.
   * This method clears the active timer and resets any stored context or arguments.
   */
  cancel: () => void

  /**
   * Immediately invokes the debounced function if there is a pending execution.
   * This method executes the function right away if there is a pending execution.
   */
  flush: () => void
}

/**
 * Creates a debounced function that delays invoking the provided function until after `debounceMs` milliseconds
 * have elapsed since the last time the debounced function was invoked. The debounced function also has a `cancel`
 * method to cancel any pending execution.
 *
 * @template F - The type of function.
 * @param {F} func - The function to debounce.
 * @param {number} debounceMs - The number of milliseconds to delay.
 * @param {DebounceOptions} options - The options object
 * @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function.
 * @returns A new debounced function with a `cancel` method.
 *
 * @example
 * const debouncedFunction = debounce(() => {
 *   console.log('Function executed');
 * }, 1000);
 *
 * // Will log 'Function executed' after 1 second if not called again in that time
 * debouncedFunction();
 *
 * // Will not log anything as the previous call is canceled
 * debouncedFunction.cancel();
 *
 * // With AbortSignal
 * const controller = new AbortController();
 * const signal = controller.signal;
 * const debouncedWithSignal = debounce(() => {
 *  console.log('Function executed');
 * }, 1000, { signal });
 *
 * debouncedWithSignal();
 *
 * // Will cancel the debounced function call
 * controller.abort();
 */
export function debounce<F extends (...args: any[]) => void>(
  func: F,
  debounceMs: number,
  { signal, edges }: DebounceOptions = {},
): DebouncedFunction<F> {
  let pendingThis: any
  let pendingArgs: Parameters<F> | null = null

  const leading = edges != null && edges.includes('leading')
  const trailing = edges == null || edges.includes('trailing')

  const invoke = () => {
    if (pendingArgs !== null) {
      func.apply(pendingThis, pendingArgs)
      pendingThis = undefined
      pendingArgs = null
    }
  }

  const onTimerEnd = () => {
    if (trailing) {
      invoke()
    }

    // eslint-disable-next-line ts/no-use-before-define
    cancel()
  }

  let timeoutId: ReturnType<typeof setTimeout> | null = null

  const schedule = () => {
    if (timeoutId != null) {
      clearTimeout(timeoutId)
    }

    timeoutId = setTimeout(() => {
      timeoutId = null

      onTimerEnd()
    }, debounceMs)
  }

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

  const cancel = () => {
    cancelTimer()
    pendingThis = undefined
    pendingArgs = null
  }

  const flush = () => {
    invoke()
  }

  const debounced = function (this: any, ...args: Parameters<F>) {
    if (signal?.aborted) {
      return
    }

    // eslint-disable-next-line ts/no-this-alias
    pendingThis = this
    pendingArgs = args

    const isFirstCall = timeoutId == null

    schedule()

    if (leading && isFirstCall) {
      invoke()
    }
  }

  debounced.schedule = schedule
  debounced.cancel = cancel
  debounced.flush = flush

  signal?.addEventListener('abort', cancel, { once: true })

  return debounced
}
