/**
 * @packageDocumentation
 *
 * Race an event against an AbortSignal, taking care to remove any event
 * listeners that were added.
 *
 * @example Getting started
 *
 * ```TypeScript
 * import { raceEvent } from 'race-event'
 *
 * const controller = new AbortController()
 * const emitter = new EventTarget()
 *
 * setTimeout(() => {
 *   controller.abort()
 * }, 500)
 *
 * setTimeout(() => {
 *   // too late
 *   emitter.dispatchEvent(new CustomEvent('event'))
 * }, 1000)
 *
 * // throws an AbortError
 * const resolve = await raceEvent(emitter, 'event', controller.signal)
 * ```
 *
 * @example Aborting the promise with an error event
 *
 * ```TypeScript
 * import { raceEvent } from 'race-event'
 *
 * const emitter = new EventTarget()
 *
 * setTimeout(() => {
 *   emitter.dispatchEvent(new CustomEvent('failure', {
 *     detail: new Error('Oh no!')
 *   }))
 * }, 1000)
 *
 * // throws 'Oh no!' error
 * const resolve = await raceEvent(emitter, 'success', AbortSignal.timeout(5000), {
 *   errorEvent: 'failure'
 * })
 * ```
 *
 * @example Customising the thrown AbortError
 *
 * The error message and `.code` property of the thrown `AbortError` can be
 * specified by passing options:
 *
 * ```TypeScript
 * import { raceEvent } from 'race-event'
 *
 * const controller = new AbortController()
 * const emitter = new EventTarget()
 *
 * setTimeout(() => {
 *   controller.abort()
 * }, 500)
 *
 * // throws a Error: Oh no!
 * const resolve = await raceEvent(emitter, 'event', controller.signal, {
 *   errorMessage: 'Oh no!',
 *   errorCode: 'ERR_OH_NO'
 * })
 * ```
 *
 * @example Only resolving on specific events
 *
 * Where multiple events with the same type are emitted, a `filter` function can
 * be passed to only resolve on one of them:
 *
 * ```TypeScript
 * import { raceEvent } from 'race-event'
 *
 * const controller = new AbortController()
 * const emitter = new EventTarget()
 *
 * // throws a Error: Oh no!
 * const resolve = await raceEvent(emitter, 'event', controller.signal, {
 *   filter: (evt: Event) => {
 *     return evt.detail.foo === 'bar'
 *   }
 * })
 * ```
 *
 * @example Terminating early by throwing from the filter
 *
 * You can cause listening for the event to cease and all event listeners to be
 * removed by throwing from the filter:
 *
 * ```TypeScript
 * import { raceEvent } from 'race-event'
 *
 * const controller = new AbortController()
 * const emitter = new EventTarget()
 *
 * // throws Error: Cannot continue
 * const resolve = await raceEvent(emitter, 'event', controller.signal, {
 *   filter: (evt) => {
 *     if (...reasons) {
 *       throw new Error('Cannot continue')
 *     }
 *
 *     return true
 *   }
 * })
 * ```
 */

import { AbortError } from 'abort-error'
import type { EventEmitter } from 'node:events'

export interface RaceEventOptions<T> {
  /**
   * The message for the error thrown if the signal aborts
   */
  errorMessage?: string

  /**
   * The code for the error thrown if the signal aborts
   */
  errorCode?: string

  /**
   * The name of an event emitted on the emitter that should cause the returned
   * promise to reject. The rejection reason will be the `.detail` field of the
   * event.
   *
   * @default "error"
   */
  errorEvent?: string

  /**
   * If the 'errorEvent' option has been passed, and the emitted event has no
   * `.detail` field, reject the promise with this error instead.
   */
  error?: Error

  /**
   * When multiple events with the same name may be emitted, pass a filter
   * function here to allow ignoring ones that should not cause the returned
   * promise to resolve.
   */
  filter?(evt: T): boolean
}

/**
 * Race a promise against an abort signal
 */
export async function raceEvent <T> (emitter: EventTarget | EventEmitter, eventName: string, signal?: AbortSignal, opts?: RaceEventOptions<T>): Promise<T> {
  // create the error here so we have more context in the stack trace
  const error = new AbortError(opts?.errorMessage)

  if (opts?.errorCode != null) {
    // @ts-expect-error not a field of AbortError
    error.code = opts.errorCode
  }

  const errorEvent = opts?.errorEvent ?? 'error'

  if (signal?.aborted === true) {
    return Promise.reject(error)
  }

  return new Promise((resolve, reject) => {
    function removeListeners (): void {
      removeListener(signal, 'abort', abortListener)
      removeListener(emitter, eventName, eventListener)
      removeListener(emitter, errorEvent, errorEventListener)
    }

    const eventListener = (evt: any): void => {
      try {
        if (opts?.filter?.(evt) === false) {
          return
        }
      } catch (err: any) {
        removeListeners()
        reject(err)
        return
      }

      removeListeners()
      resolve(evt)
    }

    const errorEventListener = (evt: any): void => {
      removeListeners()

      if (evt instanceof Error) {
        reject(evt)
        return
      }

      reject(evt.detail ?? opts?.error ?? new Error(`The "${opts?.errorEvent}" event was emitted but the event had no '.detail' field. Pass an 'error' option to race-event to change this message.`))
    }

    const abortListener = (): void => {
      removeListeners()
      reject(error)
    }

    addListener(signal, 'abort', abortListener)
    addListener(emitter, eventName, eventListener)
    addListener(emitter, errorEvent, errorEventListener)
  })
}

function addListener (emitter: EventEmitter | EventTarget | undefined, event: string, listener: any): void {
  if (emitter == null) {
    return
  }

  if (isEventTarget(emitter)) {
    emitter.addEventListener(event, listener)
  } else {
    emitter.addListener(event, listener)
  }
}

function removeListener (emitter: EventEmitter | EventTarget | undefined, event: string, listener: any): void {
  if (emitter == null) {
    return
  }

  if (isEventTarget(emitter)) {
    emitter.removeEventListener(event, listener)
  } else {
    emitter.removeListener(event, listener)
  }
}

function isEventTarget (emitter: any): emitter is EventTarget {
  return typeof emitter.addEventListener === 'function' && typeof emitter.removeEventListener === 'function'
}
