'use strict'

const { pipeline } = require('node:stream')
const { fetching } = require('../fetch')
const { webidl } = require('../webidl')
const { EventSourceStream } = require('./eventsource-stream')
const { parseMIMEType } = require('../fetch/data-url')
const { createFastMessageEvent } = require('../websocket/events')
const { isNetworkError } = require('../fetch/response')
const { isValidHeaderValue, kEnumerableProperty } = require('../../core/util')
const { environmentSettingsObject } = require('../fetch/util')
const { createPotentialCORSRequest } = require('./util')
const { getGlobalDispatcher } = require('../../global')
const { isomorphicDecode } = require('../infra')

const textEncoder = new TextEncoder()

let experimentalWarned = false

/**
 * A reconnection time, in milliseconds. This must initially be an implementation-defined value,
 * probably in the region of a few seconds.
 *
 * In Comparison:
 * - Chrome uses 3000ms.
 * - Deno uses 5000ms.
 *
 * @type {3000}
 */
const defaultReconnectionTime = 3000

/**
 * The readyState attribute represents the state of the connection.
 * @typedef ReadyState
 * @type {0|1|2}
 * @readonly
 * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#dom-eventsource-readystate-dev
 */

/**
 * The connection has not yet been established, or it was closed and the user
 * agent is reconnecting.
 * @type {0}
 */
const CONNECTING = 0

/**
 * The user agent has an open connection and is dispatching events as it
 * receives them.
 * @type {1}
 */
const OPEN = 1

/**
 * The connection is not open, and the user agent is not trying to reconnect.
 * @type {2}
 */
const CLOSED = 2

/**
 * Requests for the element will have their mode set to "cors" and their credentials mode set to "same-origin".
 * @type {'anonymous'}
 */
const ANONYMOUS = 'anonymous'

/**
 * Requests for the element will have their mode set to "cors" and their credentials mode set to "include".
 * @type {'use-credentials'}
 */
const USE_CREDENTIALS = 'use-credentials'

/**
 * The EventSource interface is used to receive server-sent events. It
 * connects to a server over HTTP and receives events in text/event-stream
 * format without closing the connection.
 * @extends {EventTarget}
 * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
 * @api public
 */
class EventSource extends EventTarget {
  #events = {
    open: null,
    error: null,
    message: null
  }

  #url
  #withCredentials = false

  /**
   * @type {ReadyState}
   */
  #readyState = CONNECTING

  #request = null
  #controller = null

  #dispatcher

  /**
   * @type {import('./eventsource-stream').eventSourceSettings}
   */
  #state

  /**
   * Creates a new EventSource object.
   * @param {string} url
   * @param {EventSourceInit} [eventSourceInitDict={}]
   * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface
   */
  constructor (url, eventSourceInitDict = {}) {
    // 1. Let ev be a new EventSource object.
    super()

    webidl.util.markAsUncloneable(this)

    const prefix = 'EventSource constructor'
    webidl.argumentLengthCheck(arguments, 1, prefix)

    if (!experimentalWarned) {
      experimentalWarned = true
      process.emitWarning('EventSource is experimental, expect them to change at any time.', {
        code: 'UNDICI-ES'
      })
    }

    url = webidl.converters.USVString(url)
    eventSourceInitDict = webidl.converters.EventSourceInitDict(eventSourceInitDict, prefix, 'eventSourceInitDict')

    this.#dispatcher = eventSourceInitDict.node.dispatcher || eventSourceInitDict.dispatcher
    this.#state = {
      lastEventId: '',
      reconnectionTime: eventSourceInitDict.node.reconnectionTime
    }

    // 2. Let settings be ev's relevant settings object.
    // https://html.spec.whatwg.org/multipage/webappapis.html#environment-settings-object
    const settings = environmentSettingsObject

    let urlRecord

    try {
      // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings.
      urlRecord = new URL(url, settings.settingsObject.baseUrl)
      this.#state.origin = urlRecord.origin
    } catch (e) {
      // 4. If urlRecord is failure, then throw a "SyntaxError" DOMException.
      throw new DOMException(e, 'SyntaxError')
    }

    // 5. Set ev's url to urlRecord.
    this.#url = urlRecord.href

    // 6. Let corsAttributeState be Anonymous.
    let corsAttributeState = ANONYMOUS

    // 7. If the value of eventSourceInitDict's withCredentials member is true,
    // then set corsAttributeState to Use Credentials and set ev's
    // withCredentials attribute to true.
    if (eventSourceInitDict.withCredentials === true) {
      corsAttributeState = USE_CREDENTIALS
      this.#withCredentials = true
    }

    // 8. Let request be the result of creating a potential-CORS request given
    // urlRecord, the empty string, and corsAttributeState.
    const request = createPotentialCORSRequest(urlRecord, '', corsAttributeState)

    // 9. Set request's client to settings.
    request.client = environmentSettingsObject.settingsObject

    // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list.
    request.headersList.set('Accept', 'text/event-stream')

    // 11. Set request's cache mode to "no-store".
    request.cache = 'no-store'

    // 12. Set request's initiator type to "other".
    request.initiator = 'other'

    // 13. Set ev's request to request.
    this.#request = request

    this.#connect()
  }

  /**
   * Returns the state of this EventSource object's connection. It can have the
   * values described below.
   * @returns {ReadyState}
   * @readonly
   */
  get readyState () {
    return this.#readyState
  }

  /**
   * Returns the URL providing the event stream.
   * @readonly
   * @returns {string}
   */
  get url () {
    return this.#url
  }

  /**
   * Returns a boolean indicating whether the EventSource object was
   * instantiated with CORS credentials set (true), or not (false, the default).
   */
  get withCredentials () {
    return this.#withCredentials
  }

  #connect () {
    if (this.#readyState === CLOSED) return

    this.#readyState = CONNECTING

    const fetchParams = {
      request: this.#request,
      dispatcher: this.#dispatcher
    }

    // 14. Let processEventSourceEndOfBody given response res be the following step: if res is not a network error, then reestablish the connection.
    const processEventSourceEndOfBody = (response) => {
      if (!isNetworkError(response)) {
        return this.#reconnect()
      }
    }

    // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody...
    fetchParams.processResponseEndOfBody = processEventSourceEndOfBody

    // and processResponse set to the following steps given response res:
    fetchParams.processResponse = (response) => {
      // 1. If res is an aborted network error, then fail the connection.

      if (isNetworkError(response)) {
        // 1. When a user agent is to fail the connection, the user agent
        // must queue a task which, if the readyState attribute is set to a
        // value other than CLOSED, sets the readyState attribute to CLOSED
        // and fires an event named error at the EventSource object. Once the
        // user agent has failed the connection, it does not attempt to
        // reconnect.
        if (response.aborted) {
          this.close()
          this.dispatchEvent(new Event('error'))
          return
          // 2. Otherwise, if res is a network error, then reestablish the
          // connection, unless the user agent knows that to be futile, in
          // which case the user agent may fail the connection.
        } else {
          this.#reconnect()
          return
        }
      }

      // 3. Otherwise, if res's status is not 200, or if res's `Content-Type`
      // is not `text/event-stream`, then fail the connection.
      const contentType = response.headersList.get('content-type', true)
      const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure'
      const contentTypeValid = mimeType !== 'failure' && mimeType.essence === 'text/event-stream'
      if (
        response.status !== 200 ||
        contentTypeValid === false
      ) {
        this.close()
        this.dispatchEvent(new Event('error'))
        return
      }

      // 4. Otherwise, announce the connection and interpret res's body
      // line by line.

      // When a user agent is to announce the connection, the user agent
      // must queue a task which, if the readyState attribute is set to a
      // value other than CLOSED, sets the readyState attribute to OPEN
      // and fires an event named open at the EventSource object.
      // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
      this.#readyState = OPEN
      this.dispatchEvent(new Event('open'))

      // If redirected to a different origin, set the origin to the new origin.
      this.#state.origin = response.urlList[response.urlList.length - 1].origin

      const eventSourceStream = new EventSourceStream({
        eventSourceSettings: this.#state,
        maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize,
        push: (event) => {
          this.dispatchEvent(createFastMessageEvent(
            event.type,
            event.options
          ))
        }
      })

      pipeline(response.body.stream,
        eventSourceStream,
        (error) => {
          if (
            error?.aborted === false
          ) {
            this.close()
            this.dispatchEvent(new Event('error'))
          }
        })
    }

    this.#controller = fetching(fetchParams)
  }

  /**
   * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
   * @returns {void}
   */
  #reconnect () {
    // When a user agent is to reestablish the connection, the user agent must
    // run the following steps. These steps are run in parallel, not as part of
    // a task. (The tasks that it queues, of course, are run like normal tasks
    // and not themselves in parallel.)

    // 1. Queue a task to run the following steps:

    //   1. If the readyState attribute is set to CLOSED, abort the task.
    if (this.#readyState === CLOSED) return

    //   2. Set the readyState attribute to CONNECTING.
    this.#readyState = CONNECTING

    //   3. Fire an event named error at the EventSource object.
    this.dispatchEvent(new Event('error'))

    // 2. Wait a delay equal to the reconnection time of the event source.
    setTimeout(() => {
      // 5. Queue a task to run the following steps:

      //   1. If the EventSource object's readyState attribute is not set to
      //      CONNECTING, then return.
      if (this.#readyState !== CONNECTING) return

      //   2. Let request be the EventSource object's request.
      //   3. If the EventSource object's last event ID string is not the empty
      //      string, then:
      //      1. Let lastEventIDValue be the EventSource object's last event ID
      //         string, encoded as UTF-8.
      //      2. Set (`Last-Event-ID`, lastEventIDValue) in request's header
      //         list.
      this.#request.headersList.delete('last-event-id', true)
      if (this.#state.lastEventId.length) {
        const lastEventId = isomorphicDecode(textEncoder.encode(this.#state.lastEventId))
        if (isValidHeaderValue(lastEventId)) {
          this.#request.headersList.set('last-event-id', lastEventId, true)
        }
      }

      //   4. Fetch request and process the response obtained in this fashion, if any, as described earlier in this section.
      this.#connect()
    }, this.#state.reconnectionTime)?.unref()
  }

  /**
   * Closes the connection, if any, and sets the readyState attribute to
   * CLOSED.
   */
  close () {
    webidl.brandCheck(this, EventSource)

    if (this.#readyState === CLOSED) return
    this.#readyState = CLOSED
    this.#controller.abort()
    this.#request = null
  }

  get onopen () {
    return this.#events.open
  }

  set onopen (fn) {
    if (this.#events.open) {
      this.removeEventListener('open', this.#events.open)
    }

    const listener = webidl.converters.EventHandlerNonNull(fn)

    if (listener !== null) {
      this.addEventListener('open', listener)
      this.#events.open = fn
    } else {
      this.#events.open = null
    }
  }

  get onmessage () {
    return this.#events.message
  }

  set onmessage (fn) {
    if (this.#events.message) {
      this.removeEventListener('message', this.#events.message)
    }

    const listener = webidl.converters.EventHandlerNonNull(fn)

    if (listener !== null) {
      this.addEventListener('message', listener)
      this.#events.message = fn
    } else {
      this.#events.message = null
    }
  }

  get onerror () {
    return this.#events.error
  }

  set onerror (fn) {
    if (this.#events.error) {
      this.removeEventListener('error', this.#events.error)
    }

    const listener = webidl.converters.EventHandlerNonNull(fn)

    if (listener !== null) {
      this.addEventListener('error', listener)
      this.#events.error = fn
    } else {
      this.#events.error = null
    }
  }
}

const constantsPropertyDescriptors = {
  CONNECTING: {
    __proto__: null,
    configurable: false,
    enumerable: true,
    value: CONNECTING,
    writable: false
  },
  OPEN: {
    __proto__: null,
    configurable: false,
    enumerable: true,
    value: OPEN,
    writable: false
  },
  CLOSED: {
    __proto__: null,
    configurable: false,
    enumerable: true,
    value: CLOSED,
    writable: false
  }
}

Object.defineProperties(EventSource, constantsPropertyDescriptors)
Object.defineProperties(EventSource.prototype, constantsPropertyDescriptors)

Object.defineProperties(EventSource.prototype, {
  close: kEnumerableProperty,
  onerror: kEnumerableProperty,
  onmessage: kEnumerableProperty,
  onopen: kEnumerableProperty,
  readyState: kEnumerableProperty,
  url: kEnumerableProperty,
  withCredentials: kEnumerableProperty
})

webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([
  {
    key: 'withCredentials',
    converter: webidl.converters.boolean,
    defaultValue: () => false
  },
  {
    key: 'dispatcher', // undici only
    converter: webidl.converters.any,
    defaultValue: () => getGlobalDispatcher()
  },
  {
    key: 'node', // undici only
    converter: webidl.dictionaryConverter([
      {
        key: 'reconnectionTime',
        converter: webidl.converters['unsigned long'],
        defaultValue: () => defaultReconnectionTime
      },
      {
        key: 'dispatcher',
        converter: webidl.converters.any
      }
    ]),
    defaultValue: () => ({})
  }
])

module.exports = {
  EventSource,
  defaultReconnectionTime
}
