import { Assert } from '@japa/assert'
import type { EventPlugin } from './EventPlugin.js'
import type { WaitingOptions } from './types.js'

declare module '@japa/assert' {
  interface Assert {
    events: EventPlugin
    /**
     * Asserts that an event was dispatched on the target.
     * It should be used with asynchronous events dispatched outside of the test's event loop.
     *
     * @param target The event target to listen for the event on.
     * @param type The type of event to listen for.
     * @param options The options to pass to the event listener.
     * @param message The message to display if the assertion fails.
     */
    dispatches(
      target: EventTarget,
      type: string,
      options?: AddEventListenerOptions & WaitingOptions,
      message?: string
    ): Promise<void>

    /**
     * Asserts that an event was NOT dispatched on the target.
     * It should be used with asynchronous events dispatched outside of the test's event loop.
     *
     * @param target The event target to listen for the event on.
     * @param type The type of event to listen for.
     * @param options The options to pass to the event listener.
     * @param message The message to display if the assertion fails.
     */
    notDispatches(
      target: EventTarget,
      type: string,
      options?: AddEventListenerOptions & WaitingOptions,
      message?: string
    ): Promise<void>

    /**
     * Asserts that a specific number of events were captured on the target.
     *
     * @param target The event target to listen for the event on.
     * @param type The type of event to listen for.
     * @param count The expected number of events.
     * @param options The options to pass to the event listener.
     * @param message The message to display if the assertion fails.
     */
    dispatchCount(
      target: EventTarget,
      type: string,
      count: number,
      options?: AddEventListenerOptions & WaitingOptions,
      message?: string
    ): Promise<void>

    /**
     * Asserts that all captured events are of a specific type.
     *
     * @param target The event target to listen for the event on.
     * @param type The type of event to listen for.
     * @param eventConstructor The expected event constructor (e.g., Event, CustomEvent).
     * @param options The options to pass to the event listener.
     * @param message The message to display if the assertion fails.
     */
    dispatchesOfType<T extends Event>(
      target: EventTarget,
      type: string,
      eventConstructor: new (...args: unknown[]) => T,
      options?: AddEventListenerOptions & WaitingOptions,
      message?: string
    ): Promise<void>
  }
}

Assert.macro(
  'dispatches',
  async function (
    this: Assert,
    target: EventTarget,
    type: string,
    options?: AddEventListenerOptions & WaitingOptions,
    message?: string
  ) {
    this.incrementAssertionsCount()
    let event: Event | undefined
    try {
      event = await this.events.until(target, type, options)
    } catch (e) {
      const error = e as Error
      if (error.cause === 'E_TIMEOUT') {
        this.evaluate(false, `event "${type}" was not dispatched on ${target.constructor.name} within the timeout`, {
          thisObject: target,
          expected: true,
          actual: false,
          showDiff: false,
          operator: 'ok',
          prefix: message,
        })
        return
      }
      throw error
    }
    this.evaluate(!!event, `event "${type}" to be dispatched on ${target.constructor.name}`, {
      thisObject: target,
      expected: !!event,
      actual: !event,
      showDiff: false,
      operator: 'ok',
      prefix: message,
    })
  }
)

Assert.macro(
  'notDispatches',
  async function (
    this: Assert,
    target: EventTarget,
    type: string,
    options?: AddEventListenerOptions & WaitingOptions,
    message?: string
  ) {
    this.incrementAssertionsCount()
    let event: Event | undefined
    try {
      event = await this.events.until(target, type, options)
    } catch (e) {
      const error = e as Error
      if (error.cause !== 'E_TIMEOUT') {
        throw error
      }
      // Timeout is expected, so we don't need to handle it here.
    }
    this.evaluate(!event, `event "${type}" was dispatched on ${target.constructor.name}`, {
      thisObject: target,
      expected: !event,
      actual: !!event,
      showDiff: false,
      operator: 'ok',
      prefix: message,
    })
  }
)

Assert.macro(
  'dispatchCount',
  async function (
    this: Assert,
    target: EventTarget,
    type: string,
    count: number,
    options?: AddEventListenerOptions & WaitingOptions,
    message?: string
  ) {
    this.incrementAssertionsCount()
    const events = await this.events.capture(target, type, options)
    this.evaluate(
      events.length === count,
      `Expected ${count} events of type "${type}" on ${target.constructor.name}, but got ${events.length}`,
      {
        thisObject: target,
        expected: count,
        actual: events.length,
        showDiff: false,
        operator: 'equal',
        prefix: message,
      }
    )
  }
)

Assert.macro('dispatchesOfType', async function <
  T extends Event,
  // eslint-disable-next-line max-len
>(this: Assert, target: EventTarget, type: string, eventConstructor: new (...args: unknown[]) => T, options?: AddEventListenerOptions & WaitingOptions, message?: string) {
  this.incrementAssertionsCount()
  const events = await this.events.capture<T>(target, type, options)
  for (const event of events) {
    this.evaluate(
      event instanceof eventConstructor,
      `Expected event of type "${eventConstructor.name}" but got "${event.constructor.name}"`,
      {
        thisObject: target,
        expected: eventConstructor.name,
        actual: event.constructor.name,
        showDiff: false,
        operator: 'instanceOf',
        prefix: message,
      }
    )
  }
})
