// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

const { EventEmitter } = require('node:events')
const WebSocket = require('ws')

const RESPONSE_TIMEOUT = 1000 * 30

class Index extends EventEmitter {
  id = 0
  connected = false
  events = []
  browsingContexts = []

  /**
   * Create a new websocket connection
   * @param _webSocketUrl
   */
  constructor(_webSocketUrl) {
    super()
    this.connected = false
    this._closed = false
    this._pending = new Map()
    this._connectWaiters = new Set()
    this._ws = new WebSocket(_webSocketUrl)
    this._ws.on('open', () => {
      // The handshake can complete after close()/_failPending() has already
      // marked the connection closed. Don't flip connected back to true and
      // proactively close the now-orphan socket so it does not leak.
      if (this._closed) {
        try {
          this._ws.close()
        } catch {
          /* socket already closing */
        }
        return
      }
      this.connected = true
      for (const { resolve } of this._connectWaiters) {
        resolve()
      }
      this._connectWaiters.clear()
    })
    // Single shared response dispatcher. Avoids attaching a new 'message'
    // listener for every in-flight send(), which previously caused
    // MaxListenersExceededWarning under concurrent BiDi traffic
    // (e.g. network interception during a page navigation).
    this._ws.on('message', (data) => {
      // Frames can arrive after close() has cleared _pending; ignore them
      // rather than re-emitting parse errors or dispatching to nothing.
      if (this._closed) {
        return
      }
      let payload
      try {
        payload = JSON.parse(data.toString())
      } catch (err) {
        // Surface protocol parse failures rather than silently dropping —
        // otherwise callers see misleading send() timeouts.
        const wrapped = new Error(`Failed to parse BiDi message: ${err.message}`)
        if (this.listenerCount('error') > 0) {
          this.emit('error', wrapped)
        } else {
          process.emitWarning(wrapped.message, 'BiDiProtocolWarning')
        }
        return
      }
      // Messages without a numeric id are BiDi events, not command responses.
      // Re-emit them on this EventEmitter by method name (e.g.
      // 'browsingContext.contextCreated') so that generated domain classes can
      // subscribe via bidi.on(methodName, callback) instead of each attaching
      // a new raw ws.on('message', ...) listener.  The existing hand-written
      // modules (logInspector, network, etc.) continue to use their own
      // ws.on('message', ...) listeners unchanged — this emission is purely
      // additive and does not affect those code paths.
      if (payload == null || typeof payload.id !== 'number') {
        if (payload != null && typeof payload.method === 'string') {
          // 'error' is a reserved EventEmitter event — emitting it without a
          // listener throws and crashes the process. Route any peer-supplied
          // method named 'error' through the same guarded path used for JSON
          // parse failures rather than forwarding it directly.
          if (payload.method === 'error') {
            const err = new Error(`BiDi protocol error event: ${JSON.stringify(payload.params)}`)
            if (this.listenerCount('error') > 0) {
              this.emit('error', err)
            } else {
              process.emitWarning(err.message, 'BiDiProtocolWarning')
            }
          } else {
            this.emit(payload.method, payload.params)
          }
        }
        return
      }
      const entry = this._pending.get(payload.id)
      if (entry === undefined) {
        return
      }
      clearTimeout(entry.timeoutId)
      this._pending.delete(payload.id)
      entry.resolve(payload)
    })
    // Fail any in-flight send() calls promptly when the peer disconnects
    // or the socket errors, instead of waiting for RESPONSE_TIMEOUT.
    this._ws.on('close', () => {
      this._failPending(new Error('BiDi connection closed unexpectedly'))
    })
    this._ws.on('error', (err) => {
      this._failPending(new Error(`BiDi connection error: ${err.message}`))
    })
  }

  /**
   * Reject any in-flight sends and mark the connection failed. Idempotent so
   * that close() and the underlying 'close'/'error' events do not double-reject.
   * @param {Error} error
   * @private
   */
  _failPending(error) {
    if (this._closed) {
      return
    }
    this._closed = true
    this.connected = false
    for (const { reject, timeoutId } of this._pending.values()) {
      clearTimeout(timeoutId)
      reject(error)
    }
    this._pending.clear()
    // Reject any callers parked in waitForConnection() so close() (or an
    // unexpected disconnect) cannot leave them hanging forever.
    for (const { reject } of this._connectWaiters) {
      reject(error)
    }
    this._connectWaiters.clear()
  }

  /**
   * @returns {WebSocket}
   */
  get socket() {
    return this._ws
  }

  /**
   * @returns {boolean|*}
   */
  get isConnected() {
    return this.connected
  }

  /**
   * Get Bidi Status
   * @returns {Promise<*>}
   */
  get status() {
    return this.send({
      method: 'session.status',
      params: {},
    })
  }

  /**
   * Resolve connection
   * @returns {Promise<unknown>}
   */
  async waitForConnection() {
    return new Promise((resolve, reject) => {
      if (this._closed) {
        reject(new Error('BiDi connection is closed'))
        return
      }
      if (this.connected) {
        resolve()
        return
      }
      // Park the waiter in a Set so the constructor's 'open' handler can
      // resolve it and _failPending() can reject it. Avoids attaching socket
      // listeners that close()'s removeAllListeners('close') would strip.
      this._connectWaiters.add({ resolve, reject })
    })
  }

  /**
   * Sends a bidi request
   * @param params
   * @returns {Promise<unknown>}
   */
  async send(params) {
    if (this._closed) {
      throw new Error('BiDi connection is closed')
    }
    if (!this.connected) {
      await this.waitForConnection()
    }
    // Defense in depth: even after waitForConnection() resolves, the socket
    // may have transitioned to CLOSING/CLOSED (e.g. caller closed the raw
    // socket). Refuse rather than throwing from inside ws.send().
    if (this._ws.readyState !== WebSocket.OPEN) {
      throw new Error('BiDi connection is not open')
    }

    const id = ++this.id

    this._ws.send(JSON.stringify({ id, ...params }))

    return new Promise((resolve, reject) => {
      const timeoutId = setTimeout(() => {
        this._pending.delete(id)
        reject(new Error(`Request with id ${id} timed out`))
      }, RESPONSE_TIMEOUT)

      this._pending.set(id, { resolve, reject, timeoutId })
    })
  }

  /**
   * Subscribe to events
   * @param events
   * @param browsingContexts
   * @returns {Promise<void>}
   */
  async subscribe(events, browsingContexts) {
    function toArray(arg) {
      if (arg === undefined) {
        return []
      }

      return Array.isArray(arg) ? [...arg] : [arg]
    }

    const eventsArray = toArray(events)
    const contextsArray = toArray(browsingContexts)

    const params = {
      method: 'session.subscribe',
      params: {},
    }

    if (eventsArray.length && eventsArray.some((event) => typeof event !== 'string')) {
      throw new TypeError('events should be string or string array')
    }

    if (contextsArray.length && contextsArray.some((context) => typeof context !== 'string')) {
      throw new TypeError('browsingContexts should be string or string array')
    }

    if (eventsArray.length) {
      params.params.events = eventsArray
    }

    if (contextsArray.length) {
      params.params.contexts = contextsArray
    }

    this.events.push(...eventsArray)

    await this.send(params)
  }

  /**
   * Unsubscribe to events
   * @param events
   * @param browsingContexts
   * @returns {Promise<void>}
   */
  async unsubscribe(events, browsingContexts) {
    const eventsToRemove = typeof events === 'string' ? [events] : events

    // Check if the eventsToRemove are in the subscribed events array
    // Filter out events that are not in this.events before filtering
    const existingEvents = eventsToRemove.filter((event) => this.events.includes(event))

    // Remove the events from the subscribed events array
    this.events = this.events.filter((event) => !existingEvents.includes(event))

    if (typeof browsingContexts === 'string') {
      this.browsingContexts.pop()
    } else if (Array.isArray(browsingContexts)) {
      this.browsingContexts = this.browsingContexts.filter((id) => !browsingContexts.includes(id))
    }

    if (existingEvents.length === 0) {
      return
    }
    const params = {
      method: 'session.unsubscribe',
      params: {
        events: existingEvents,
      },
    }

    if (this.browsingContexts.length > 0) {
      params.params.contexts = this.browsingContexts
    }

    await this.send(params)
  }

  /**
   * Close ws connection.
   * @returns {Promise<unknown>}
   */
  close() {
    this._failPending(new Error('BiDi connection closed before response was received'))

    const closeWebSocket = (callback) => {
      // don't close if it's already closed
      if (this._ws.readyState === 3) {
        callback()
      } else {
        // don't notify on user-initiated shutdown ('disconnect' event)
        this._ws.removeAllListeners('close')
        this._ws.once('close', () => {
          this._ws.removeAllListeners()
          callback()
        })
        this._ws.close()
      }
    }
    return new Promise((fulfill, _) => {
      closeWebSocket(fulfill)
    })
  }
}

/**
 * API
 * @type {function(*): Promise<Index>}
 */
module.exports = Index
