type Maybe<T> = T | undefined
type Variadic<T = unknown, U extends unknown[] = unknown[]> = (...args: U) => T

/**
 * Applies a number of milliseconds of throttling to a function
 *
 * @param {Variadic<T, U>} fn The function to be throttled
 * @param {number} ms The number of milliseconds between throttling
 * @returns {Variadic<Maybe<T>, U>} The input function with throttling
 */
export const throttle = <T, U extends unknown[] = unknown[]>(
  fn: Variadic<T, U>,
  ms: number
): Variadic<Maybe<T>, U> => {
  let timeout: Maybe<NodeJS.Timeout>

  return (...args) => {
    if (timeout) {
      return
    }
    timeout = setTimeout(() => {
      timeout = undefined
    }, ms)

    return fn(...args)
  }
}

/**
 * Applies a number of milliseconds of throttling to a function asynchronously
 *
 * @param {Variadic<T, U>} call The function to be throttled
 * @param {number} ms The number of milliseconds between throttling
 * @returns {Variadic<Promise<Maybe<T>>, U>} The input function with throttling
 */
export const throttleAsync = <T, U extends unknown[] = unknown[]>(
  call: Variadic<Promise<T>, U> | Promise<T>,
  ms: number,
  { isLeading = false, isTrailing = true } = {}
): Variadic<Promise<Maybe<T>>, U> => {
  let timeout: Maybe<NodeJS.Timeout>

  return (...args) =>
    new Promise(resolve => {
      const callable = () => (typeof call === 'function' ? call(...args) : call)

      // Return if it's not time to call again
      if (timeout) {
        return
      }

      timeout = setTimeout(() => {
        timeout = undefined

        // Call only if trailing
        if (isTrailing) {
          resolve(callable())
        }
      }, ms)

      // Call only if leading
      if (isLeading) {
        resolve(callable())
      }
    })
}

/**
 * Applies a number of milliseconds of debouncing to a function
 *
 * @param {Variadic<T>} fn The function to be debounced
 * @param {number} ms The number of milliseconds between debouncing
 * @returns {Variadic<Maybe<T>>} The returned input function with debouncing
 */
export const debounce = <T, U extends unknown[] = unknown[]>(
  fn: Variadic<T, U>,
  ms: number
): Variadic<Maybe<T>, U> => {
  let timeout: Maybe<NodeJS.Timeout>

  return (...args) => {
    // Prevent the exceedingly unlikely event that we've unset the previous timeout
    // and that the debounce is being called again before the timeout is set again
    const prevTimeout = timeout

    timeout = setTimeout(() => {
      timeout = undefined
    }, ms)

    if (!prevTimeout) {
      return fn(...args)
    }

    clearTimeout(prevTimeout)
  }
}

/**
 * Applies a number of milliseconds of debouncing to a function asynchronously
 *
 * @param {Variadic<T, U> | Promise<T>} call The function or promise to be debounced
 * @param {number} ms The number of milliseconds between debouncing
 * @returns {Variadic<Maybe<T>, U>} The returned input function with debouncing
 */
export const debounceAsync = <T, U extends unknown[] = unknown[]>(
  call: Variadic<Promise<T>, U> | Promise<T>,
  ms: number,
  { isLeading = false, isTrailing = true } = {}
): ((...args: U) => Promise<T>) => {
  let timeout: Maybe<NodeJS.Timeout>

  return (...args) =>
    new Promise<T>(resolve => {
      // Normalize call types
      const callable = () => (typeof call === 'function' ? call(...args) : call)

      const prevTimeout = timeout

      // Memoize the timeout ID for cancellation
      timeout = setTimeout(async () => {
        // Unset the memoized ID on call
        timeout = undefined

        if (isTrailing) {
          resolve(callable())
        }
      }, ms)

      // If there's a priorly memoized ID, reset and return
      if (prevTimeout) {
        clearTimeout(prevTimeout)
      } else if (isLeading) {
        resolve(callable())
      }
    })
}
