/**
 * Options to pass to the EventClient constructor
 * @typedef {Object} EventClient~Options
 * @prop {string} [channel] - Filter for specific channel events (format: `#channel`)
 * @prop {Object} handlers - Map event types to callback functions `(event, fields, time, live) => {...}`
 * @prop {number} [maxReplayAge=-1] - Number of hours to replay the events for (-1 = infinite)
 * @prop {boolean} replay - Request a replay at connect (requires channel to be set to a channel name)
 * @prop {string} [token] - API access token to use to connect to the WebSocket
 */

const HOUR = 3600 * 1000

const initialSocketBackoff = 500
const maxSocketBackoff = 10000
const socketBackoffMultiplier = 1.25

/**
 * @class EventClient abstracts the connection to the bot websocket for events
 */
export default class EventClient {
  /**
   * Creates, initializes and connects the EventClient
   *
   * @param {EventClient~Options} opts {@link EventClient~Options} for the EventClient
   */
  constructor(opts) {
    this.params = new URLSearchParams(window.location.hash.substr(1))
    this.handlers = { ...opts.handlers || {} }
    this.options = { ...opts }

    this.token = this.paramOptionFallback('token')
    if (!this.token) {
      throw new Error('token for socket not present in hash or opts')
    }

    this.socketBackoff = initialSocketBackoff

    this.connect()

    // If reply is enabled and channel is provided, fetch the replay
    if (this.paramOptionFallback('replay', false) && this.paramOptionFallback('channel')) {
      this.fetchReplayForChannel(
        this.paramOptionFallback('channel'),
        Number(this.paramOptionFallback('maxReplayAge', -1)),
      )
    }
  }

  /**
   * Returns the API base URL without trailing slash
   *
   * @returns {string} API base URL
   */
  apiBase() {
    return window.location.href.substr(0, window.location.href.indexOf('/overlays/'))
  }

  /**
   * Connects the EventClient to the socket
   *
   * @private
   */
  connect() {
    if (this.socket) {
      this.socket.close()
      this.socket = null
    }

    this.socket = new WebSocket(this.socketAddr())

    this.socket.onclose = () => {
      this.socketBackoff = Math.min(this.socketBackoff * socketBackoffMultiplier, maxSocketBackoff)
      window.setTimeout(() => this.connect(), this.socketBackoff)
    }

    this.socket.onmessage = evt => {
      const data = JSON.parse(evt.data)

      if (data.type === '_auth') {
        // Special handling for auth confirmation
        this.socketBackoff = initialSocketBackoff
        return
      }

      if (this.paramOptionFallback('channel') && !data.fields?.channel?.match(this.paramOptionFallback('channel'))) {
        // Channel filter is active and channel does not match
        return
      }

      for (const fn of [this.handlers[data.type], this.handlers._].filter(fn => fn)) {
        fn(data.type, data.fields, new Date(data.time), data.is_live)
      }
    }

    this.socket.onopen = () => {
      this.socket.send(JSON.stringify({
        fields: { token: this.token },
        type: '_auth',
      }))
    }
  }

  /*
   * Requests past events from the API and feed them through the registered handlers
   *
   * @params {string} channel The channel to fetch the events for
   * @params {number} hours The amount of hours to fetch into the past (-1 = infinite)
   * @returns {Promise} Can be listened for failures using `.catch`
   */
  fetchReplayForChannel(channel, hours = -1) {
    const params = new URLSearchParams()
    if (hours > -1) {
      params.set('since', new Date(new Date().getTime() - hours * HOUR).toISOString())
    }

    return fetch(`${this.apiBase()}/overlays/events/${encodeURIComponent(channel)}?${params.toString()}`, {
      headers: {
        authorization: this.paramOptionFallback('token'),
      },
    })
      .then(resp => resp.json())
      .then(data => {
        const handlers = []

        for (const msg of data) {
          for (const fn of [this.handlers[msg.type], this.handlers._].filter(fn => fn)) {
            handlers.push(fn(msg.type, msg.fields, new Date(msg.time), msg.is_live))
          }
        }

        return Promise.all(handlers)
      })
  }

  /**
   * Resolves the given key through url hash parameters with fallback to constructor options
   *
   * @params {string} key The key to resolve
   * @params {*=} fallback=null Fallback to return if neither params nor options contained that key
   * @returns {*} Value of the key or null
   */
  paramOptionFallback(key, fallback = null) {
    return this.params.get(key) || this.options[key] || fallback
  }

  /**
   * Renders a given template using the bots msgformat API (supports all templating you can use in bot messages)
   *
   * @params {string} template The template to render
   * @returns {Promise} Promise resolving to the rendered output of the template
   */
  renderTemplate(template) {
    return fetch(`${this.apiBase()}/msgformat/format?template=${encodeURIComponent(template)}`, {
      headers: {
        authorization: this.paramOptionFallback('token'),
      },
    })
      .then(resp => resp.text())
  }

  /**
   * Modifies the overlay address to the websocket address the bot listens to
   *
   * @private
   * @returns {string} Websocket address in form ws://... or wss://...
   */
  socketAddr() {
    return `${this.apiBase().replace(/^http/, 'ws')}/overlays/events.sock`
  }
}