import { isNull, isNumber, isUndefined, noop } from '#lodash'
import type {
  MaybeAsync,
  NonNullableProperties,
  NullableKeysToOptional
} from './utilityTypes.js'
import { ensureError } from './error.js'

export class TimeoutError extends Error {}
export class WaitIntervalUntilCanceledError extends Error {}

/**
 * Build promise that can be resolved/rejected manually outside of the promise's execution scope
 */
export const buildManualPromise = <T>() => {
  let resolve: (value: T) => void
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let reject: (reason?: any) => void
  const promise = new Promise<T>((res, rej) => {
    resolve = res
    reject = rej
  })

  const resolveWrapper: typeof resolve = (...args) => resolve(...args)
  const rejectWrapper: typeof reject = (...args) => reject(...args)

  return { promise, resolve: resolveWrapper, reject: rejectWrapper }
}

export type ManualPromise<T> = ReturnType<typeof buildManualPromise<T>>

export const isNullOrUndefined = (val: unknown): val is null | undefined =>
  isNull(val) || isUndefined(val)

export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export const waitIntervalUntil = (ms: number, predicate: () => boolean) => {
  const { promise, resolve, reject } = buildManualPromise<void>()
  const interval = setInterval(() => {
    if (predicate()) {
      clearInterval(interval)
      resolve()
    }
  }, ms)

  const ret = promise as typeof promise & { cancel: () => void }
  ret.cancel = () => {
    clearInterval(interval)
    reject(new WaitIntervalUntilCanceledError())
  }

  return ret
}

/**
 * Not nullable type guard, useful in `.filter()` calls for proper TS typed
 * results
 */
export const isNonNullable = <V>(v: V): v is NonNullable<typeof v> => !!v

/**
 * Make the promise throw after enough time has passed. Useful for implementing timeout functionality in various flows.
 */
export const timeoutAt = (ms: number, optionalMessage?: string) => {
  // create error beforehand, so we have a better stack trace
  const err = new TimeoutError(optionalMessage || 'timeoutAt() timed out')

  return new Promise<never>((_resolve, reject) =>
    setTimeout(() => {
      reject(err)
    }, ms)
  )
}

/**
 * Invoke and return fn(), but retry it up to n times if it throws
 */
export const retry = async <V = unknown>(
  fn: () => MaybeAsync<V>,
  n: number,
  delayMs?: number | ((attempt: number, error: Error) => number)
) => {
  let lastError: Error | undefined
  for (let i = 0; i < n; i++) {
    try {
      const res = await Promise.resolve(fn())
      return res
    } catch (error) {
      lastError = ensureError(error)

      if (delayMs && i + 1 < n) {
        if (isNumber(delayMs)) {
          await wait(delayMs)
        } else {
          await wait(delayMs(i + 1, lastError))
        }
      }
    }
  }
  throw lastError || new Error('Unexpected retry() failure')
}

/**
 * For quickly profiling a function
 */
export const profile = async <V = unknown>(
  fn: () => MaybeAsync<V>,
  label?: string,
  extra?: unknown
) => {
  const start = performance.now()
  const res = await Promise.resolve(fn())
  const end = performance.now()
  console.log(
    `[${label || 'profile'}] took ${end - start}ms`,
    ...(extra ? [extra] : [])
  )
  return res
}

/**
 * For quickly profiling a sync function
 */
export const profileSync = <V = unknown>(
  fn: () => V,
  label?: string,
  extra?: unknown
) => {
  const start = performance.now()
  const res = fn()
  const end = performance.now()
  console.log(
    `[${label || 'profile'}] took ${end - start}ms`,
    ...(extra ? [extra] : [])
  )
  return res
}

export const removeNullOrUndefinedKeys = <T extends Record<string, unknown>>(
  obj: T
) => {
  const ret = {} as T
  for (const key in obj) {
    if (!isNullOrUndefined(obj[key])) {
      ret[key] = obj[key]
    }
  }
  return ret as NonNullableProperties<NullableKeysToOptional<T>>
}

export const coerceUndefinedValuesToNull = <T extends Record<string, unknown>>(
  obj: T
) => {
  const ret = {} as Record<string, Exclude<T[keyof T], undefined> | null>
  for (const [key, value] of Object.entries(obj)) {
    ret[key] = isUndefined(value) ? null : (value as Exclude<T[keyof T], undefined>)
  }
  return ret
}

export const isArrayOf = <T>(arr: unknown, guard: (v: unknown) => v is T): arr is T[] =>
  Array.isArray(arr) && arr.every(guard)

export const waitForever = (): Promise<never> => new Promise<never>(noop)

/**
 * Returns true if only one of the arguments is truthy
 */
export const xor = (a: unknown, b: unknown) => !!((a || b) && !(a && b))

/**
 * Shortcut for creating string key & value enums. Native TS Enums are problematic in many ways
 * (transform code, instead of just adding types; you cant put a string literal matching the enum value
 * in a variable typed as the enum, etc.)
 */
export const StringEnum = <T extends string>(args: T[]) => {
  const enumObj = Object.fromEntries(args.map((arg) => [arg, arg])) as {
    [K in T]: K
  }
  return Object.freeze(enumObj)
}

/**
 * Shortcut for (typeof StringEnum)[keyof typeof StringEnum]
 */
export type StringEnumValues<T extends Record<string, string>> = {
  [K in keyof T]: T[K] extends string ? T[K] : never
}[keyof T]

/**
 * Get first non-undefined/null value, or undefined if none found
 */
export const firstDefinedValue = <T>(
  ...args: (T | undefined | null)[]
): T | undefined => {
  for (const arg of args) {
    if (!isNullOrUndefined(arg)) return arg
  }
  return undefined
}
