'use strict'

const http = require('node:http')
const https = require('node:https')
const dns = require('node:dns')

const { FSTDEP011 } = require('./warnings')
const { kState, kOptions, kServerBindings } = require('./symbols')
const { onListenHookRunner } = require('./hooks')
const {
  FST_ERR_HTTP2_INVALID_VERSION,
  FST_ERR_REOPENED_CLOSE_SERVER,
  FST_ERR_REOPENED_SERVER,
  FST_ERR_LISTEN_OPTIONS_INVALID
} = require('./errors')

module.exports.createServer = createServer
module.exports.compileValidateHTTPVersion = compileValidateHTTPVersion

function defaultResolveServerListeningText (address) {
  return `Server listening at ${address}`
}

function createServer (options, httpHandler) {
  const server = getServerInstance(options, httpHandler)

  // `this` is the Fastify object
  function listen (listenOptions, ...args) {
    let cb = args.slice(-1).pop()
    // When the variadic signature deprecation is complete, the function
    // declaration should become:
    //   function listen (listenOptions = { port: 0, host: 'localhost' }, cb = undefined)
    // Upon doing so, the `normalizeListenArgs` function is no longer needed,
    // and all of this preamble to feed it correctly also no longer needed.
    const firstArgType = Object.prototype.toString.call(arguments[0])
    if (arguments.length === 0) {
      listenOptions = normalizeListenArgs([])
    } else if (arguments.length > 0 && (firstArgType !== '[object Object]' && firstArgType !== '[object Function]')) {
      FSTDEP011()
      listenOptions = normalizeListenArgs(Array.from(arguments))
      cb = listenOptions.cb
    } else if (args.length > 1) {
      // `.listen(obj, a, ..., n, callback )`
      FSTDEP011()
      // Deal with `.listen(port, host, backlog, [cb])`
      const hostPath = listenOptions.path ? [listenOptions.path] : [listenOptions.port ?? 0, listenOptions.host ?? 'localhost']
      Object.assign(listenOptions, normalizeListenArgs([...hostPath, ...args]))
    } else {
      listenOptions.cb = cb
    }
    if (listenOptions.signal) {
      if (typeof listenOptions.signal.on !== 'function' && typeof listenOptions.signal.addEventListener !== 'function') {
        throw new FST_ERR_LISTEN_OPTIONS_INVALID('Invalid options.signal')
      }

      if (listenOptions.signal.aborted) {
        this.close()
      } else {
        const onAborted = () => {
          this.close()
        }
        listenOptions.signal.addEventListener('abort', onAborted, { once: true })
      }
    }

    // If we have a path specified, don't default host to 'localhost' so we don't end up listening
    // on both path and host
    // See https://github.com/fastify/fastify/issues/4007
    let host
    if (listenOptions.path == null) {
      host = listenOptions.host ?? 'localhost'
    } else {
      host = listenOptions.host
    }
    if (Object.prototype.hasOwnProperty.call(listenOptions, 'host') === false ||
      listenOptions.host == null) {
      listenOptions.host = host
    }
    if (host === 'localhost') {
      listenOptions.cb = (err, address) => {
        if (err) {
          // the server did not start
          cb(err, address)
          return
        }

        multipleBindings.call(this, server, httpHandler, options, listenOptions, () => {
          this[kState].listening = true
          cb(null, address)
          onListenHookRunner(this)
        })
      }
    } else {
      listenOptions.cb = (err, address) => {
        // the server did not start
        if (err) {
          cb(err, address)
          return
        }
        this[kState].listening = true
        cb(null, address)
        onListenHookRunner(this)
      }
    }

    // https://github.com/nodejs/node/issues/9390
    // If listening to 'localhost', listen to both 127.0.0.1 or ::1 if they are available.
    // If listening to 127.0.0.1, only listen to 127.0.0.1.
    // If listening to ::1, only listen to ::1.

    if (cb === undefined) {
      const listening = listenPromise.call(this, server, listenOptions)
      /* istanbul ignore else */
      return listening.then(address => {
        return new Promise((resolve, reject) => {
          if (host === 'localhost') {
            multipleBindings.call(this, server, httpHandler, options, listenOptions, () => {
              this[kState].listening = true
              resolve(address)
              onListenHookRunner(this)
            })
          } else {
            resolve(address)
            onListenHookRunner(this)
          }
        })
      })
    }

    this.ready(listenCallback.call(this, server, listenOptions))
  }

  return { server, listen }
}

function multipleBindings (mainServer, httpHandler, serverOpts, listenOptions, onListen) {
  // the main server is started, we need to start the secondary servers
  this[kState].listening = false

  // let's check if we need to bind additional addresses
  dns.lookup(listenOptions.host, { all: true }, (dnsErr, addresses) => {
    if (dnsErr) {
      // not blocking the main server listening
      // this.log.warn('dns.lookup error:', dnsErr)
      onListen()
      return
    }

    const isMainServerListening = mainServer.listening && serverOpts.serverFactory

    let binding = 0
    let bound = 0
    if (!isMainServerListening) {
      const primaryAddress = mainServer.address()
      for (const adr of addresses) {
        if (adr.address !== primaryAddress.address) {
          binding++
          const secondaryOpts = Object.assign({}, listenOptions, {
            host: adr.address,
            port: primaryAddress.port,
            cb: (_ignoreErr) => {
              bound++

              /* istanbul ignore next: the else won't be taken unless listening fails */
              if (!_ignoreErr) {
                this[kServerBindings].push(secondaryServer)
              }

              if (bound === binding) {
                // regardless of the error, we are done
                onListen()
              }
            }
          })

          const secondaryServer = getServerInstance(serverOpts, httpHandler)
          const closeSecondary = () => {
            // To avoid fall into situations where the close of the
            // secondary server is triggered before the preClose hook
            // is done running, we better wait until the main server
            // is closed.
            // No new TCP connections are accepted
            // We swallow any error from the secondary
            // server
            secondaryServer.close(() => {})
            if (serverOpts.forceCloseConnections === 'idle') {
              // Not needed in Node 19
              secondaryServer.closeIdleConnections()
            } else if (typeof secondaryServer.closeAllConnections === 'function' && serverOpts.forceCloseConnections) {
              secondaryServer.closeAllConnections()
            }
          }

          secondaryServer.on('upgrade', mainServer.emit.bind(mainServer, 'upgrade'))
          mainServer.on('unref', closeSecondary)
          mainServer.on('close', closeSecondary)
          mainServer.on('error', closeSecondary)
          this[kState].listening = false
          listenCallback.call(this, secondaryServer, secondaryOpts)()
        }
      }
    }
    // no extra bindings are necessary
    if (binding === 0) {
      onListen()
      return
    }

    // in test files we are using unref so we need to propagate the unref event
    // to the secondary servers. It is valid only when the user is
    // listening on localhost
    const originUnref = mainServer.unref
    /* c8 ignore next 4 */
    mainServer.unref = function () {
      originUnref.call(mainServer)
      mainServer.emit('unref')
    }
  })
}

function listenCallback (server, listenOptions) {
  const wrap = (err) => {
    server.removeListener('error', wrap)
    server.removeListener('listening', wrap)
    if (!err) {
      const address = logServerAddress.call(this, server, listenOptions.listenTextResolver || defaultResolveServerListeningText)
      listenOptions.cb(null, address)
    } else {
      this[kState].listening = false
      listenOptions.cb(err, null)
    }
  }

  return (err) => {
    if (err != null) return listenOptions.cb(err)

    if (this[kState].listening && this[kState].closing) {
      return listenOptions.cb(new FST_ERR_REOPENED_CLOSE_SERVER(), null)
    } else if (this[kState].listening) {
      return listenOptions.cb(new FST_ERR_REOPENED_SERVER(), null)
    }

    server.once('error', wrap)
    if (!this[kState].closing) {
      server.once('listening', wrap)
      server.listen(listenOptions)
      this[kState].listening = true
    }
  }
}

function listenPromise (server, listenOptions) {
  if (this[kState].listening && this[kState].closing) {
    return Promise.reject(new FST_ERR_REOPENED_CLOSE_SERVER())
  } else if (this[kState].listening) {
    return Promise.reject(new FST_ERR_REOPENED_SERVER())
  }

  return this.ready().then(() => {
    let errEventHandler
    let listeningEventHandler
    function cleanup () {
      server.removeListener('error', errEventHandler)
      server.removeListener('listening', listeningEventHandler)
    }
    const errEvent = new Promise((resolve, reject) => {
      errEventHandler = (err) => {
        cleanup()
        this[kState].listening = false
        reject(err)
      }
      server.once('error', errEventHandler)
    })
    const listeningEvent = new Promise((resolve, reject) => {
      listeningEventHandler = () => {
        cleanup()
        this[kState].listening = true
        resolve(logServerAddress.call(this, server, listenOptions.listenTextResolver || defaultResolveServerListeningText))
      }
      server.once('listening', listeningEventHandler)
    })

    server.listen(listenOptions)

    return Promise.race([
      errEvent, // e.g invalid port range error is always emitted before the server listening
      listeningEvent
    ])
  })
}

/**
 * Creates a function that, based upon initial configuration, will
 * verify that every incoming request conforms to allowed
 * HTTP versions for the Fastify instance, e.g. a Fastify HTTP/1.1
 * server will not serve HTTP/2 requests upon the result of the
 * verification function.
 *
 * @param {object} options fastify option
 * @param {function} [options.serverFactory] If present, the
 * validator function will skip all checks.
 * @param {boolean} [options.http2 = false] If true, the validator
 * function will allow HTTP/2 requests.
 * @param {object} [options.https = null] https server options
 * @param {boolean} [options.https.allowHTTP1] If true and use
 * with options.http2 the validator function will allow HTTP/1
 * request to http2 server.
 *
 * @returns {function} HTTP version validator function.
 */
function compileValidateHTTPVersion (options) {
  let bypass = false
  // key-value map to store valid http version
  const map = new Map()
  if (options.serverFactory) {
    // When serverFactory is passed, we cannot identify how to check http version reliably
    // So, we should skip the http version check
    bypass = true
  }
  if (options.http2) {
    // HTTP2 must serve HTTP/2.0
    map.set('2.0', true)
    if (options.https && options.https.allowHTTP1 === true) {
      // HTTP2 with HTTPS.allowHTTP1 allow fallback to HTTP/1.1 and HTTP/1.0
      map.set('1.1', true)
      map.set('1.0', true)
    }
  } else {
    // HTTP must server HTTP/1.1 and HTTP/1.0
    map.set('1.1', true)
    map.set('1.0', true)
  }
  // The compiled function here placed in one of the hottest path inside fastify
  // the implementation here must be as performant as possible
  return function validateHTTPVersion (httpVersion) {
    // `bypass` skip the check when custom server factory provided
    // `httpVersion in obj` check for the valid http version we should support
    return bypass || map.has(httpVersion)
  }
}

function getServerInstance (options, httpHandler) {
  let server = null
  // node@20 do not accepts options as boolean
  // we need to provide proper https option
  const httpsOptions = options.https === true ? {} : options.https
  if (options.serverFactory) {
    server = options.serverFactory(httpHandler, options)
  } else if (options.http2) {
    if (typeof httpsOptions === 'object') {
      server = http2().createSecureServer(httpsOptions, httpHandler)
    } else {
      server = http2().createServer(httpHandler)
    }
    server.on('session', sessionTimeout(options.http2SessionTimeout))
  } else {
    // this is http1
    if (httpsOptions) {
      server = https.createServer(httpsOptions, httpHandler)
    } else {
      server = http.createServer(options.http, httpHandler)
    }
    server.keepAliveTimeout = options.keepAliveTimeout
    server.requestTimeout = options.requestTimeout
    // we treat zero as null
    // and null is the default setting from nodejs
    // so we do not pass the option to server
    if (options.maxRequestsPerSocket > 0) {
      server.maxRequestsPerSocket = options.maxRequestsPerSocket
    }
  }

  if (!options.serverFactory) {
    server.setTimeout(options.connectionTimeout)
  }
  return server
}

function normalizeListenArgs (args) {
  if (args.length === 0) {
    return { port: 0, host: 'localhost' }
  }

  const cb = typeof args[args.length - 1] === 'function' ? args.pop() : undefined
  const options = { cb }

  const firstArg = args[0]
  const argsLength = args.length
  const lastArg = args[argsLength - 1]
  if (typeof firstArg === 'string' && isNaN(firstArg)) {
    /* Deal with listen (pipe[, backlog]) */
    options.path = firstArg
    options.backlog = argsLength > 1 ? lastArg : undefined
  } else {
    /* Deal with listen ([port[, host[, backlog]]]) */
    options.port = argsLength >= 1 && Number.isInteger(firstArg) ? firstArg : normalizePort(firstArg)
    // This will listen to what localhost is.
    // It can be 127.0.0.1 or ::1, depending on the operating system.
    // Fixes https://github.com/fastify/fastify/issues/1022.
    options.host = argsLength >= 2 && args[1] ? args[1] : 'localhost'
    options.backlog = argsLength >= 3 ? args[2] : undefined
  }

  return options
}

function normalizePort (firstArg) {
  const port = Number(firstArg)
  return port >= 0 && !Number.isNaN(port) && Number.isInteger(port) ? port : 0
}

function logServerAddress (server, listenTextResolver) {
  let address = server.address()
  const isUnixSocket = typeof address === 'string'
  /* istanbul ignore next */
  if (!isUnixSocket) {
    if (address.address.indexOf(':') === -1) {
      address = address.address + ':' + address.port
    } else {
      address = '[' + address.address + ']:' + address.port
    }
  }
  /* istanbul ignore next */
  address = (isUnixSocket ? '' : ('http' + (this[kOptions].https ? 's' : '') + '://')) + address

  const serverListeningText = listenTextResolver(address)
  this.log.info(serverListeningText)
  return address
}

function http2 () {
  try {
    return require('node:http2')
  } catch (err) {
    throw new FST_ERR_HTTP2_INVALID_VERSION()
  }
}

function sessionTimeout (timeout) {
  return function (session) {
    session.setTimeout(timeout, close)
  }
}

function close () {
  this.close()
}
