import {createParser, type EventSourceMessage, type EventSourceParser} from 'eventsource-parser'

import {ErrorEvent, flattenError, syntaxError} from './errors.js'
import type {
  AddEventListenerOptions,
  EventListenerOptions,
  EventListenerOrEventListenerObject,
  EventSourceEventMap,
  EventSourceInit,
  FetchLike,
  FetchLikeInit,
  FetchLikeResponse,
} from './types.js'

/**
 * An `EventSource` instance opens a persistent connection to an HTTP server, which sends events
 * in `text/event-stream` format. The connection remains open until closed by calling `.close()`.
 *
 * @public
 * @example
 * ```js
 * const eventSource = new EventSource('https://example.com/stream')
 * eventSource.addEventListener('error', (error) => {
 *   console.error(error)
 * })
 * eventSource.addEventListener('message', (event) => {
 *  console.log('Received message:', event.data)
 * })
 * ```
 */
export class EventSource extends EventTarget {
  /**
   * ReadyState representing an EventSource currently trying to connect
   *
   * @public
   */
  static CONNECTING = 0 as const

  /**
   * ReadyState representing an EventSource connection that is open (eg connected)
   *
   * @public
   */
  static OPEN = 1 as const

  /**
   * ReadyState representing an EventSource connection that is closed (eg disconnected)
   *
   * @public
   */
  static CLOSED = 2 as const

  /**
   * ReadyState representing an EventSource currently trying to connect
   *
   * @public
   */
  readonly CONNECTING = 0 as const

  /**
   * ReadyState representing an EventSource connection that is open (eg connected)
   *
   * @public
   */
  readonly OPEN = 1 as const

  /**
   * ReadyState representing an EventSource connection that is closed (eg disconnected)
   *
   * @public
   */
  readonly CLOSED = 2 as const

  /**
   * Returns the state of this EventSource object's connection. It can have the values described below.
   *
   * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState)
   *
   * Note: typed as `number` instead of `0 | 1 | 2` for compatibility with the `EventSource` interface,
   * defined in the TypeScript `dom` library.
   *
   * @public
   */
  public get readyState(): number {
    return this.#readyState
  }

  /**
   * Returns the URL providing the event stream.
   *
   * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url)
   *
   * @public
   */
  public get url(): string {
    return this.#url.href
  }

  /**
   * Returns true if the credentials mode for connection requests to the URL providing the event stream is set to "include", and false otherwise.
   *
   * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials)
   */
  public get withCredentials(): boolean {
    return this.#withCredentials
  }

  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */
  public get onerror(): ((ev: ErrorEvent) => unknown) | null {
    return this.#onError
  }
  public set onerror(value: ((ev: ErrorEvent) => unknown) | null) {
    this.#onError = value
  }

  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */
  public get onmessage(): ((ev: MessageEvent) => unknown) | null {
    return this.#onMessage
  }
  public set onmessage(value: ((ev: MessageEvent) => unknown) | null) {
    this.#onMessage = value
  }

  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */
  public get onopen(): ((ev: Event) => unknown) | null {
    return this.#onOpen
  }
  public set onopen(value: ((ev: Event) => unknown) | null) {
    this.#onOpen = value
  }

  override addEventListener<K extends keyof EventSourceEventMap>(
    type: K,
    listener: (this: EventSource, ev: EventSourceEventMap[K]) => unknown,
    options?: boolean | AddEventListenerOptions,
  ): void
  override addEventListener(
    type: string,
    listener: (this: EventSource, event: MessageEvent) => unknown,
    options?: boolean | AddEventListenerOptions,
  ): void
  override addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
  ): void
  override addEventListener(
    type: string,
    listener:
      | ((this: EventSource, event: MessageEvent) => unknown)
      | EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
  ): void {
    const listen = listener as (this: EventSource, event: Event) => unknown
    super.addEventListener(type, listen, options)
  }

  override removeEventListener<K extends keyof EventSourceEventMap>(
    type: K,
    listener: (this: EventSource, ev: EventSourceEventMap[K]) => unknown,
    options?: boolean | EventListenerOptions,
  ): void
  override removeEventListener(
    type: string,
    listener: (this: EventSource, event: MessageEvent) => unknown,
    options?: boolean | EventListenerOptions,
  ): void
  override removeEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | EventListenerOptions,
  ): void
  override removeEventListener(
    type: string,
    listener:
      | ((this: EventSource, event: MessageEvent) => unknown)
      | EventListenerOrEventListenerObject,
    options?: boolean | EventListenerOptions,
  ): void {
    const listen = listener as (this: EventSource, event: Event) => unknown
    super.removeEventListener(type, listen, options)
  }

  constructor(url: string | URL, eventSourceInitDict?: EventSourceInit) {
    super()

    try {
      if (url instanceof URL) {
        this.#url = url
      } else if (typeof url === 'string') {
        this.#url = new URL(url, getBaseURL())
      } else {
        throw new Error('Invalid URL')
      }
    } catch (err) {
      throw syntaxError('An invalid or illegal string was specified')
    }

    this.#parser = createParser({
      onEvent: this.#onEvent,
      onRetry: this.#onRetryChange,
    })

    this.#readyState = this.CONNECTING
    this.#reconnectInterval = 3000
    this.#fetch = eventSourceInitDict?.fetch ?? globalThis.fetch
    this.#withCredentials = eventSourceInitDict?.withCredentials ?? false

    this.#connect()
  }

  /**
   * Aborts any instances of the fetch algorithm started for this EventSource object, and sets the readyState attribute to CLOSED.
   *
   * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close)
   *
   * @public
   */
  close(): void {
    if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer)
    if (this.#readyState === this.CLOSED) return
    if (this.#controller) this.#controller.abort()
    this.#readyState = this.CLOSED
    this.#controller = undefined
  }

  // PRIVATES FOLLOW

  /**
   * Current connection state
   *
   * @internal
   */
  #readyState: number

  /**
   * Original URL used to connect.
   *
   * Note that this will stay the same even after a redirect.
   *
   * @internal
   */
  #url: URL

  /**
   * The destination URL after a redirect. Is reset on reconnection.
   *
   * @internal
   */
  #redirectUrl: URL | undefined

  /**
   * Whether to include credentials in the request
   *
   * @internal
   */
  #withCredentials: boolean

  /**
   * The fetch implementation to use
   *
   * @internal
   */
  #fetch: FetchLike

  /**
   * The reconnection time in milliseconds
   *
   * @internal
   */
  #reconnectInterval: number

  /**
   * Reference to an ongoing reconnect attempt, if any
   *
   * @internal
   */
  #reconnectTimer: ReturnType<typeof setTimeout> | undefined

  /**
   * The last event ID seen by the EventSource, which will be sent as `Last-Event-ID` in the
   * request headers on a reconnection attempt.
   *
   * @internal
   */
  #lastEventId: string | null = null

  /**
   * The AbortController instance used to abort the fetch request
   *
   * @internal
   */
  #controller: AbortController | undefined

  /**
   * Instance of an EventSource parser (`eventsource-parser` npm module)
   *
   * @internal
   */
  #parser: EventSourceParser

  /**
   * Holds the current error handler, attached through `onerror` property directly.
   * Note that `addEventListener('error', …)` will not be stored here.
   *
   * @internal
   */
  #onError: ((ev: ErrorEvent) => unknown) | null = null

  /**
   * Holds the current message handler, attached through `onmessage` property directly.
   * Note that `addEventListener('message', …)` will not be stored here.
   *
   * @internal
   */
  #onMessage: ((ev: MessageEvent) => unknown) | null = null

  /**
   * Holds the current open handler, attached through `onopen` property directly.
   * Note that `addEventListener('open', …)` will not be stored here.
   *
   * @internal
   */
  #onOpen: ((ev: Event) => unknown) | null = null

  /**
   * Connect to the given URL and start receiving events
   *
   * @internal
   */
  #connect() {
    this.#readyState = this.CONNECTING
    this.#controller = new AbortController()

    // Browser tests are failing if we directly call `this.#fetch()`, thus the indirection.
    const fetch = this.#fetch
    fetch(this.#url, this.#getRequestOptions())
      .then(this.#onFetchResponse)
      .catch(this.#onFetchError)
  }

  /**
   * Handles the fetch response
   *
   * @param response - The Fetch(ish) response
   * @internal
   */
  #onFetchResponse = async (response: FetchLikeResponse) => {
    this.#parser.reset()

    const {body, redirected, status, headers} = response

    // [spec] a client can be told to stop reconnecting using the HTTP 204 No Content response code.
    if (status === 204) {
      // We still need to emit an error event - this mirrors the browser behavior,
      // and without it there is no way to tell the user that the connection was closed.
      this.#failConnection('Server sent HTTP 204, not reconnecting', 204)
      this.close()
      return
    }

    // [spec] …Event stream requests can be redirected using HTTP 301 and 307 redirects as with
    // [spec] normal HTTP requests.
    // Spec does not say anything about other redirect codes (302, 308), but this seems an
    // unintended omission, rather than a feature. Browsers will happily redirect on other 3xxs's.
    if (redirected) {
      this.#redirectUrl = new URL(response.url)
    } else {
      this.#redirectUrl = undefined
    }

    // [spec] if res's status is not 200, …, then fail the connection.
    if (status !== 200) {
      this.#failConnection(`Non-200 status code (${status})`, status)
      return
    }

    // [spec] …or if res's `Content-Type` is not `text/event-stream`, then fail the connection.
    const contentType = headers.get('content-type') || ''
    if (!contentType.startsWith('text/event-stream')) {
      this.#failConnection('Invalid content type, expected "text/event-stream"', status)
      return
    }

    // [spec] …if the readyState attribute is set to a value other than CLOSED…
    if (this.#readyState === this.CLOSED) {
      return
    }

    // [spec] …sets the readyState attribute to OPEN and fires an event
    // [spec] …named open at the EventSource object.
    this.#readyState = this.OPEN

    const openEvent = new Event('open')
    this.#onOpen?.(openEvent)
    this.dispatchEvent(openEvent)

    // Ensure that the response stream is a web stream
    if (typeof body !== 'object' || !body || !('getReader' in body)) {
      this.#failConnection('Invalid response body, expected a web ReadableStream', status)
      this.close() // This should only happen if `fetch` provided is "faulty" - don't reconnect
      return
    }

    const decoder = new TextDecoder()

    const reader = body.getReader()
    let open = true

    do {
      const {done, value} = await reader.read()
      if (value) {
        this.#parser.feed(decoder.decode(value, {stream: !done}))
      }

      if (!done) {
        continue
      }

      open = false
      this.#parser.reset()

      this.#scheduleReconnect()
    } while (open)
  }

  /**
   * Handles rejected requests for the EventSource endpoint
   *
   * @param err - The error from `fetch()`
   * @internal
   */
  #onFetchError = (err: Error & {type?: string}) => {
    this.#controller = undefined

    // We expect abort errors when the user manually calls `close()` - ignore those
    if (err.name === 'AbortError' || err.type === 'aborted') {
      return
    }

    this.#scheduleReconnect(flattenError(err))
  }

  /**
   * Get request options for the `fetch()` request
   *
   * @returns The request options
   * @internal
   */
  #getRequestOptions(): FetchLikeInit {
    const lastEvent = this.#lastEventId ? {'Last-Event-ID': this.#lastEventId} : undefined
    const headers = {Accept: 'text/event-stream', ...lastEvent}

    const init: FetchLikeInit = {
      // [spec] Let `corsAttributeState` be `Anonymous`…
      // [spec] …will have their mode set to "cors"…
      mode: 'cors',
      redirect: 'follow',
      headers,
      cache: 'no-store',
      signal: this.#controller?.signal,
    }

    // Some environments crash if attempting to set `credentials` where it is not supported,
    // eg on Cloudflare Workers. To avoid this, we only set it in browser-like environments.
    if ('window' in globalThis) {
      // [spec] …and their credentials mode set to "same-origin"
      // [spec] …if the `withCredentials` attribute is `true`, set the credentials mode to "include"…
      init.credentials = this.withCredentials ? 'include' : 'same-origin'
    }

    return init
  }

  /**
   * Called by EventSourceParser instance when an event has successfully been parsed
   * and is ready to be processed.
   *
   * @param event - The parsed event
   * @internal
   */
  #onEvent = (event: EventSourceMessage) => {
    if (typeof event.id === 'string') {
      this.#lastEventId = event.id
    }

    const messageEvent = new MessageEvent(event.event || 'message', {
      data: event.data,
      origin: this.#redirectUrl ? this.#redirectUrl.origin : this.#url.origin,
      lastEventId: event.id || '',
    })

    // The `onmessage` property of the EventSource instance only triggers on messages without an
    // `event` field, or ones that explicitly set `message`.
    if (this.#onMessage && (!event.event || event.event === 'message')) {
      this.#onMessage(messageEvent)
    }

    this.dispatchEvent(messageEvent)
  }

  /**
   * Called by EventSourceParser instance when a new reconnection interval is received
   * from the EventSource endpoint.
   *
   * @param value - The new reconnection interval in milliseconds
   * @internal
   */
  #onRetryChange = (value: number) => {
    this.#reconnectInterval = value
  }

  /**
   * Handles the process referred to in the EventSource specification as "failing a connection".
   *
   * @param error - The error causing the connection to fail
   * @param code - The HTTP status code, if available
   * @internal
   */
  #failConnection(message?: string, code?: number) {
    // [spec] …if the readyState attribute is set to a value other than CLOSED,
    // [spec] sets the readyState attribute to CLOSED…
    if (this.#readyState !== this.CLOSED) {
      this.#readyState = this.CLOSED
    }

    // [spec] …and fires an event named `error` at the `EventSource` object.
    // [spec] Once the user agent has failed the connection, it does not attempt to reconnect.
    // [spec] > Implementations are especially encouraged to report detailed information
    // [spec] > to their development consoles whenever an error event is fired, since little
    // [spec] > to no information can be made available in the events themselves.
    // Printing to console is not very programatically helpful, though, so we emit a custom event.
    const errorEvent = new ErrorEvent('error', {code, message})

    this.#onError?.(errorEvent)
    this.dispatchEvent(errorEvent)
  }

  /**
   * Schedules a reconnection attempt against the EventSource endpoint.
   *
   * @param message - The error causing the connection to fail
   * @param code - The HTTP status code, if available
   * @internal
   */
  #scheduleReconnect(message?: string, code?: number) {
    // [spec] If the readyState attribute is set to CLOSED, abort the task.
    if (this.#readyState === this.CLOSED) {
      return
    }

    // [spec] Set the readyState attribute to CONNECTING.
    this.#readyState = this.CONNECTING

    // [spec] Fire an event named `error` at the EventSource object.
    const errorEvent = new ErrorEvent('error', {code, message})
    this.#onError?.(errorEvent)
    this.dispatchEvent(errorEvent)

    // [spec] Wait a delay equal to the reconnection time of the event source.
    this.#reconnectTimer = setTimeout(this.#reconnect, this.#reconnectInterval)
  }

  /**
   * Reconnects to the EventSource endpoint after a disconnect/failure
   *
   * @internal
   */
  #reconnect = () => {
    this.#reconnectTimer = undefined

    // [spec] If the EventSource's readyState attribute is not set to CONNECTING, then return.
    if (this.#readyState !== this.CONNECTING) {
      return
    }

    this.#connect()
  }
}

/**
 * According to spec, when constructing a URL:
 * > 1. Let baseURL be environment's base URL, if environment is a Document object
 * > 2. Return the result of applying the URL parser to url, with baseURL.
 *
 * Thus we should use `document.baseURI` if available, since it can be set through a base tag.
 *
 * @returns The base URL, if available - otherwise `undefined`
 * @internal
 */
function getBaseURL(): string | undefined {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const doc = 'document' in globalThis ? (globalThis as any).document : undefined
  return doc && typeof doc === 'object' && 'baseURI' in doc && typeof doc.baseURI === 'string'
    ? doc.baseURI
    : undefined
}
