'use strict'

const assert = require('node:assert')
const { kDestroyed, kBodyUsed, kListeners, kBody } = require('./symbols')
const { IncomingMessage } = require('node:http')
const stream = require('node:stream')
const net = require('node:net')
const { stringify } = require('node:querystring')
const { EventEmitter: EE, addAbortListener: addAbortListenerNative } = require('node:events')
const timers = require('../util/timers')
const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
const { headerNameLowerCasedRecord } = require('./constants')
const { tree } = require('./tree')

class BodyAsyncIterable {
  constructor (body) {
    this[kBody] = body
    this[kBodyUsed] = false
  }

  async * [Symbol.asyncIterator] () {
    assert(!this[kBodyUsed], 'disturbed')
    this[kBodyUsed] = true
    yield * this[kBody]
  }
}

function noop () {}

/**
 * @param {*} body
 * @returns {*}
 */
function wrapRequestBody (body) {
  if (isStream(body)) {
    // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp
    // so that it can be dispatched again?
    // TODO (fix): Do we need 100-expect support to provide a way to do this properly?
    if (bodyLength(body) === 0) {
      body
        .on('data', function () {
          assert(false)
        })
    }

    if (typeof body.readableDidRead !== 'boolean') {
      body[kBodyUsed] = false
      EE.prototype.on.call(body, 'data', function () {
        this[kBodyUsed] = true
      })
    }

    return body
  } else if (body && typeof body.pipeTo === 'function') {
    // TODO (fix): We can't access ReadableStream internal state
    // to determine whether or not it has been disturbed. This is just
    // a workaround.
    return new BodyAsyncIterable(body)
  } else if (body && isFormDataLike(body)) {
    return body
  } else if (
    body &&
    typeof body !== 'string' &&
    !ArrayBuffer.isView(body) &&
    isIterable(body)
  ) {
    // TODO: Should we allow re-using iterable if !this.opts.idempotent
    // or through some other flag?
    return new BodyAsyncIterable(body)
  } else {
    return body
  }
}

/**
 * @param {*} obj
 * @returns {obj is import('node:stream').Stream}
 */
function isStream (obj) {
  return obj && typeof obj === 'object' && typeof obj.pipe === 'function' && typeof obj.on === 'function'
}

/**
 * @param {*} object
 * @returns {object is Blob}
 */
function isBlobLike (object) {
  return object instanceof Blob
}

/**
 * @param {string} url The path to check for query strings or fragments.
 * @returns {boolean} Returns true if the path contains a query string or fragment.
 */
function pathHasQueryOrFragment (url) {
  return (
    url.includes('?') ||
    url.includes('#')
  )
}

/**
 * @param {string} url The URL to add the query params to
 * @param {import('node:querystring').ParsedUrlQueryInput} queryParams The object to serialize into a URL query string
 * @returns {string} The URL with the query params added
 */
function serializePathWithQuery (url, queryParams) {
  if (pathHasQueryOrFragment(url)) {
    throw new Error('Query params cannot be passed when url already contains "?" or "#".')
  }

  const stringified = stringify(queryParams)

  if (stringified) {
    url += '?' + stringified
  }

  return url
}

/**
 * @param {number|string|undefined} port
 * @returns {boolean}
 */
function isValidPort (port) {
  const value = parseInt(port, 10)
  return (
    value === Number(port) &&
    value >= 0 &&
    value <= 65535
  )
}

/**
 * Check if the value is a valid http or https prefixed string.
 *
 * @param {string} value
 * @returns {boolean}
 */
function isHttpOrHttpsPrefixed (value) {
  return (
    value != null &&
    value[0] === 'h' &&
    value[1] === 't' &&
    value[2] === 't' &&
    value[3] === 'p' &&
    (
      value[4] === ':' ||
      (
        value[4] === 's' &&
        value[5] === ':'
      )
    )
  )
}

/**
 * @param {string|URL|Record<string,string>} url
 * @returns {URL}
 */
function parseURL (url) {
  if (typeof url === 'string') {
    /**
     * @type {URL}
     */
    url = new URL(url)

    if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
      throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.')
    }

    return url
  }

  if (!url || typeof url !== 'object') {
    throw new InvalidArgumentError('Invalid URL: The URL argument must be a non-null object.')
  }

  if (!(url instanceof URL)) {
    if (url.port != null && url.port !== '' && isValidPort(url.port) === false) {
      throw new InvalidArgumentError('Invalid URL: port must be a valid integer or a string representation of an integer.')
    }

    if (url.path != null && typeof url.path !== 'string') {
      throw new InvalidArgumentError('Invalid URL path: the path must be a string or null/undefined.')
    }

    if (url.pathname != null && typeof url.pathname !== 'string') {
      throw new InvalidArgumentError('Invalid URL pathname: the pathname must be a string or null/undefined.')
    }

    if (url.hostname != null && typeof url.hostname !== 'string') {
      throw new InvalidArgumentError('Invalid URL hostname: the hostname must be a string or null/undefined.')
    }

    if (url.origin != null && typeof url.origin !== 'string') {
      throw new InvalidArgumentError('Invalid URL origin: the origin must be a string or null/undefined.')
    }

    if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
      throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.')
    }

    const port = url.port != null
      ? url.port
      : (url.protocol === 'https:' ? 443 : 80)
    let origin = url.origin != null
      ? url.origin
      : `${url.protocol || ''}//${url.hostname || ''}:${port}`
    let path = url.path != null
      ? url.path
      : `${url.pathname || ''}${url.search || ''}`

    if (origin[origin.length - 1] === '/') {
      origin = origin.slice(0, origin.length - 1)
    }

    if (path && path[0] !== '/') {
      path = `/${path}`
    }
    // new URL(path, origin) is unsafe when `path` contains an absolute URL
    // From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
    // If first parameter is a relative URL, second param is required, and will be used as the base URL.
    // If first parameter is an absolute URL, a given second param will be ignored.
    return new URL(`${origin}${path}`)
  }

  if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
    throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.')
  }

  return url
}

/**
 * @param {string|URL|Record<string, string>} url
 * @returns {URL}
 */
function parseOrigin (url) {
  url = parseURL(url)

  if (url.pathname !== '/' || url.search || url.hash) {
    throw new InvalidArgumentError('invalid url')
  }

  return url
}

/**
 * @param {string} host
 * @returns {string}
 */
function getHostname (host) {
  if (host[0] === '[') {
    const idx = host.indexOf(']')

    assert(idx !== -1)
    return host.substring(1, idx)
  }

  const idx = host.indexOf(':')
  if (idx === -1) return host

  return host.substring(0, idx)
}

/**
 * IP addresses are not valid server names per RFC6066
 * Currently, the only server names supported are DNS hostnames
 * @param {string|null} host
 * @returns {string|null}
 */
function getServerName (host) {
  if (!host) {
    return null
  }

  assert(typeof host === 'string')

  const servername = getHostname(host)
  if (net.isIP(servername)) {
    return ''
  }

  return servername
}

/**
 * @function
 * @template T
 * @param {T} obj
 * @returns {T}
 */
function deepClone (obj) {
  return JSON.parse(JSON.stringify(obj))
}

/**
 * @param {*} obj
 * @returns {obj is AsyncIterable}
 */
function isAsyncIterable (obj) {
  return !!(obj != null && typeof obj[Symbol.asyncIterator] === 'function')
}

/**
 * @param {*} obj
 * @returns {obj is Iterable}
 */
function isIterable (obj) {
  return !!(obj != null && (typeof obj[Symbol.iterator] === 'function' || typeof obj[Symbol.asyncIterator] === 'function'))
}

/**
 * Checks whether an object has a safe Symbol.iterator — i.e. one that is
 * either own or inherited from a non-Object.prototype chain.  This prevents
 * prototype-pollution attacks from injecting a fake iterator on
 * Object.prototype.
 * @param {object} obj
 * @returns {boolean}
 */
function hasSafeIterator (obj) {
  const prototype = Object.getPrototypeOf(obj)
  const ownIterator = Object.hasOwn(obj, Symbol.iterator)
  return ownIterator || (prototype != null && prototype !== Object.prototype && typeof obj[Symbol.iterator] === 'function')
}

/**
 * @param {Blob|Buffer|import ('stream').Stream} body
 * @returns {number|null}
 */
function bodyLength (body) {
  if (body == null) {
    return 0
  } else if (isStream(body)) {
    const state = body._readableState
    return state && state.objectMode === false && state.ended === true && Number.isFinite(state.length)
      ? state.length
      : null
  } else if (isBlobLike(body)) {
    return body.size != null ? body.size : null
  } else if (isBuffer(body)) {
    return body.byteLength
  }

  return null
}

/**
 * @param {import ('stream').Stream} body
 * @returns {boolean}
 */
function isDestroyed (body) {
  return body && !!(body.destroyed || body[kDestroyed] || (stream.isDestroyed?.(body)))
}

/**
 * @param {import ('stream').Stream} stream
 * @param {Error} [err]
 * @returns {void}
 */
function destroy (stream, err) {
  if (stream == null || !isStream(stream) || isDestroyed(stream)) {
    return
  }

  if (typeof stream.destroy === 'function') {
    if (Object.getPrototypeOf(stream).constructor === IncomingMessage) {
      // See: https://github.com/nodejs/node/pull/38505/files
      stream.socket = null
    }

    try {
      stream.destroy(err)
    } catch {
      // stream.destroy may throw on managed sockets (e.g., http2).
      // Silently ignore — the socket lifecycle is handled by the subsystem.
    }
  } else if (err) {
    queueMicrotask(() => {
      stream.emit('error', err)
    })
  }

  if (stream.destroyed !== true) {
    stream[kDestroyed] = true
  }
}

const KEEPALIVE_TIMEOUT_EXPR = /timeout=(\d+)/
/**
 * @param {string} val
 * @returns {number | null}
 */
function parseKeepAliveTimeout (val) {
  const m = val.match(KEEPALIVE_TIMEOUT_EXPR)
  return m ? parseInt(m[1], 10) * 1000 : null
}

/**
 * Retrieves a header name and returns its lowercase value.
 * @param {string | Buffer} value Header name
 * @returns {string}
 */
function headerNameToString (value) {
  return typeof value === 'string'
    ? headerNameLowerCasedRecord[value] ?? value.toLowerCase()
    : tree.lookup(value) ?? value.toString('latin1').toLowerCase()
}

/**
 * Receive the buffer as a string and return its lowercase value.
 * @param {Buffer} value Header name
 * @returns {string}
 */
function bufferToLowerCasedHeaderName (value) {
  return tree.lookup(value) ?? value.toString('latin1').toLowerCase()
}

/**
 * @param {(Buffer | string)[]} headers
 * @param {Record<string, string | string[]>} [obj]
 * @returns {Record<string, string | string[]>}
 */
function parseHeaders (headers, obj) {
  if (obj === undefined) obj = {}

  for (let i = 0; i < headers.length; i += 2) {
    const key = headerNameToString(headers[i])
    let val = obj[key]

    if (val !== undefined) {
      if (!Object.hasOwn(obj, key)) {
        const headersValue = typeof headers[i + 1] === 'string'
          ? headers[i + 1]
          : Array.isArray(headers[i + 1])
            ? headers[i + 1].map(x => x.toString('latin1'))
            : headers[i + 1].toString('latin1')

        if (key === '__proto__') {
          Object.defineProperty(obj, key, {
            value: headersValue,
            enumerable: true,
            configurable: true,
            writable: true
          })
        } else {
          obj[key] = headersValue
        }
      } else {
        if (typeof val === 'string') {
          val = [val]
          obj[key] = val
        }
        val.push(headers[i + 1].toString('latin1'))
      }
    } else {
      const headersValue = typeof headers[i + 1] === 'string'
        ? headers[i + 1]
        : Array.isArray(headers[i + 1])
          ? headers[i + 1].map(x => x.toString('latin1'))
          : headers[i + 1].toString('latin1')

      obj[key] = headersValue
    }
  }

  return obj
}

/**
 * @param {Buffer[] | string[] | Record<string, string | string[]> | null | undefined} headers
 * @returns {string[]}
 */
function parseRawHeaders (headers) {
  if (headers == null) {
    return []
  }

  if (!Array.isArray(headers)) {
    const rawHeaders = []

    for (const [name, value] of Object.entries(headers)) {
      if (Array.isArray(value)) {
        for (const entry of value) {
          rawHeaders.push(name, `${entry}`)
        }
      } else {
        rawHeaders.push(name, `${value}`)
      }
    }

    return rawHeaders
  }

  const headersLength = headers.length
  /**
   * @type {string[]}
   */
  const ret = new Array(headersLength)

  let key
  let val

  for (let n = 0; n < headersLength; n += 2) {
    key = headers[n]
    val = headers[n + 1]

    typeof key !== 'string' && (key = key.toString())
    typeof val !== 'string' && (val = val.toString('latin1'))

    ret[n] = key
    ret[n + 1] = val
  }

  return ret
}

/**
 * @param {Record<string, string | string[]>} headers
 * @returns {Buffer[]}
 */
function toRawHeaders (headers) {
  const rawHeaders = []

  for (const [name, value] of Object.entries(headers)) {
    if (Array.isArray(value)) {
      for (const entry of value) {
        rawHeaders.push(Buffer.from(name, 'latin1'), Buffer.from(`${entry}`, 'latin1'))
      }
    } else {
      rawHeaders.push(Buffer.from(name, 'latin1'), Buffer.from(`${value}`, 'latin1'))
    }
  }

  return rawHeaders
}

/**
 * @param {string[]} headers
 * @param {Buffer[]} headers
 */
function encodeRawHeaders (headers) {
  if (!Array.isArray(headers)) {
    throw new TypeError('expected headers to be an array')
  }
  return headers.map(x => Buffer.from(x))
}

/**
 * @param {*} buffer
 * @returns {buffer is Buffer}
 */
function isBuffer (buffer) {
  // See, https://github.com/mcollina/undici/pull/319
  return buffer instanceof Uint8Array || Buffer.isBuffer(buffer)
}

/**
 * Asserts that the handler object is a request handler.
 *
 * @param {object} handler
 * @param {string} method
 * @param {string} [upgrade]
 * @returns {asserts handler is import('../api/api-request').RequestHandler}
 */
function assertRequestHandler (handler, method, upgrade) {
  if (!handler || typeof handler !== 'object') {
    throw new InvalidArgumentError('handler must be an object')
  }

  if (typeof handler.onRequestStart !== 'function') {
    throw new InvalidArgumentError('invalid onRequestStart method')
  }

  if (typeof handler.onResponseError !== 'function') {
    throw new InvalidArgumentError('invalid onResponseError method')
  }

  if (typeof handler.onBodySent !== 'function' && handler.onBodySent !== undefined) {
    throw new InvalidArgumentError('invalid onBodySent method')
  }

  if (typeof handler.onRequestSent !== 'function' && handler.onRequestSent !== undefined) {
    throw new InvalidArgumentError('invalid onRequestSent method')
  }

  if (upgrade || method === 'CONNECT') {
    if (typeof handler.onRequestUpgrade !== 'function') {
      throw new InvalidArgumentError('invalid onRequestUpgrade method')
    }
  } else {
    if (typeof handler.onResponseStart !== 'function') {
      throw new InvalidArgumentError('invalid onResponseStart method')
    }

    if (typeof handler.onResponseData !== 'function') {
      throw new InvalidArgumentError('invalid onResponseData method')
    }

    if (typeof handler.onResponseEnd !== 'function') {
      throw new InvalidArgumentError('invalid onResponseEnd method')
    }
  }
}

/**
 * A body is disturbed if it has been read from and it cannot be re-used without
 * losing state or data.
 * @param {import('node:stream').Readable} body
 * @returns {boolean}
 */
function isDisturbed (body) {
  // TODO (fix): Why is body[kBodyUsed] needed?
  return !!(body && (stream.isDisturbed(body) || body[kBodyUsed]))
}

/**
 * @typedef {object} SocketInfo
 * @property {string} [localAddress]
 * @property {number} [localPort]
 * @property {string} [remoteAddress]
 * @property {number} [remotePort]
 * @property {string} [remoteFamily]
 * @property {number} [timeout]
 * @property {number} bytesWritten
 * @property {number} bytesRead
 */

/**
 * @param {import('net').Socket} socket
 * @returns {SocketInfo}
 */
function getSocketInfo (socket) {
  return {
    localAddress: socket.localAddress,
    localPort: socket.localPort,
    remoteAddress: socket.remoteAddress,
    remotePort: socket.remotePort,
    remoteFamily: socket.remoteFamily,
    timeout: socket.timeout,
    bytesWritten: socket.bytesWritten,
    bytesRead: socket.bytesRead
  }
}

/**
 * @param {Iterable} iterable
 * @returns {ReadableStream}
 */
function ReadableStreamFrom (iterable) {
  // We cannot use ReadableStream.from here because it does not return a byte stream.

  let iterator
  return new ReadableStream(
    {
      start () {
        iterator = iterable[Symbol.asyncIterator]()
      },
      pull (controller) {
        return iterator.next().then(({ done, value }) => {
          if (done) {
            return queueMicrotask(() => {
              controller.close()
              controller.byobRequest?.respond(0)
            })
          } else {
            const buf = Buffer.isBuffer(value) ? value : Buffer.from(value)
            if (buf.byteLength) {
              return controller.enqueue(new Uint8Array(buf))
            } else {
              return this.pull(controller)
            }
          }
        })
      },
      cancel () {
        return iterator.return()
      },
      type: 'bytes'
    }
  )
}

/**
 * The object should be a FormData instance and contains all the required
 * methods.
 * @param {*} object
 * @returns {object is FormData}
 */
function isFormDataLike (object) {
  return (
    object &&
    typeof object === 'object' &&
    typeof object.append === 'function' &&
    typeof object.delete === 'function' &&
    typeof object.get === 'function' &&
    typeof object.getAll === 'function' &&
    typeof object.has === 'function' &&
    typeof object.set === 'function' &&
    object[Symbol.toStringTag] === 'FormData'
  )
}

function addAbortListener (signal, listener) {
  if (!signal || 'aborted' in signal) {
    return addAbortListenerNative(signal, listener)[Symbol.dispose]
  }

  if (typeof signal.addEventListener === 'function') {
    signal.addEventListener('abort', listener, { once: true })
    return () => signal.removeEventListener('abort', listener)
  }
  signal.once('abort', listener)
  return () => signal.removeListener('abort', listener)
}

const validTokenChars = new Uint8Array([
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31
  0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32-47 (!"#$%&'()*+,-./)
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48-63 (0-9:;<=>?)
  0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64-79 (@A-O)
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80-95 (P-Z[\]^_)
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96-111 (`a-o)
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, // 112-127 (p-z{|}~)
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 128-143
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 144-159
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 160-175
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 176-191
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 192-207
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 208-223
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 224-239
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0  // 240-255
])

/**
 * @see https://tools.ietf.org/html/rfc7230#section-3.2.6
 * @param {number} c
 * @returns {boolean}
 */
function isTokenCharCode (c) {
  return (validTokenChars[c] === 1)
}

const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/

/**
 * @param {string} characters
 * @returns {boolean}
 */
function isValidHTTPToken (characters) {
  if (characters.length >= 12) return tokenRegExp.test(characters)
  if (characters.length === 0) return false

  for (let i = 0; i < characters.length; i++) {
    if (validTokenChars[characters.charCodeAt(i)] !== 1) {
      return false
    }
  }
  return true
}

// headerCharRegex have been lifted from
// https://github.com/nodejs/node/blob/main/lib/_http_common.js

/**
 * Matches if val contains an invalid field-vchar
 *  field-value    = *( field-content / obs-fold )
 *  field-content  = field-vchar [ 1*( SP / HTAB ) field-vchar ]
 *  field-vchar    = VCHAR / obs-text
 */
const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/

/**
 * @param {string} characters
 * @returns {boolean}
 */
function isValidHeaderValue (characters) {
  return !headerCharRegex.test(characters)
}

const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d+|\*)?$/

/**
 * @typedef {object} RangeHeader
 * @property {number} start
 * @property {number | null} end
 * @property {number | null} size
 */

/**
 * Parse accordingly to RFC 9110
 * @see https://www.rfc-editor.org/rfc/rfc9110#field.content-range
 * @param {string} [range]
 * @returns {RangeHeader|null}
 */
function parseRangeHeader (range) {
  if (range == null || range === '') return { start: 0, end: null, size: null }
  if (!range) return null

  const m = rangeHeaderRegex.exec(range)
  return m
    ? {
        start: parseInt(m[1]),
        end: m[2] ? parseInt(m[2]) : null,
        size: m[3] && m[3] !== '*' ? parseInt(m[3]) : null
      }
    : null
}

/**
 * @template {import("events").EventEmitter} T
 * @param {T} obj
 * @param {string} name
 * @param {(...args: any[]) => void} listener
 * @returns {T}
 */
function addListener (obj, name, listener) {
  const listeners = (obj[kListeners] ??= [])
  listeners.push([name, listener])
  obj.on(name, listener)
  return obj
}

/**
 * @template {import("events").EventEmitter} T
 * @param {T} obj
 * @returns {T}
 */
function removeAllListeners (obj) {
  if (obj[kListeners] != null) {
    for (const [name, listener] of obj[kListeners]) {
      obj.removeListener(name, listener)
    }
    obj[kListeners] = null
  }
  return obj
}

/**
 * @param {import ('../dispatcher/client')} client
 * @param {import ('../core/request')} request
 * @param {Error} err
 */
function errorRequest (client, request, err) {
  try {
    request.onResponseError(err)
    assert(request.aborted)
  } catch (err) {
    client.emit('error', err)
  }
}

/**
 * @param {WeakRef<net.Socket>} socketWeakRef
 * @param {object} opts
 * @param {number} opts.timeout
 * @param {string} opts.hostname
 * @param {number} opts.port
 * @returns {() => void}
 */
const setupConnectTimeout = process.platform === 'win32'
  ? (socketWeakRef, opts) => {
      if (!opts.timeout) {
        return noop
      }

      let s1 = null
      let s2 = null
      const fastTimer = timers.setFastTimeout(() => {
      // setImmediate is added to make sure that we prioritize socket error events over timeouts
        s1 = setImmediate(() => {
        // Windows needs an extra setImmediate probably due to implementation differences in the socket logic
          s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts))
        })
      }, opts.timeout)
      return () => {
        timers.clearFastTimeout(fastTimer)
        clearImmediate(s1)
        clearImmediate(s2)
      }
    }
  : (socketWeakRef, opts) => {
      if (!opts.timeout) {
        return noop
      }

      let s1 = null
      const fastTimer = timers.setFastTimeout(() => {
      // setImmediate is added to make sure that we prioritize socket error events over timeouts
        s1 = setImmediate(() => {
          onConnectTimeout(socketWeakRef.deref(), opts)
        })
      }, opts.timeout)
      return () => {
        timers.clearFastTimeout(fastTimer)
        clearImmediate(s1)
      }
    }

/**
 * @param {net.Socket} socket
 * @param {object} opts
 * @param {number} opts.timeout
 * @param {string} opts.hostname
 * @param {number} opts.port
 */
function onConnectTimeout (socket, opts) {
  // The socket could be already garbage collected
  if (socket == null) {
    return
  }

  let message = 'Connect Timeout Error'
  if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
    message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
  } else {
    message += ` (attempted address: ${opts.hostname}:${opts.port},`
  }

  message += ` timeout: ${opts.timeout}ms)`

  destroy(socket, new ConnectTimeoutError(message))
}

let lastUrlString = null
let lastProtocol = null

/**
 * @param {string} urlString
 * @returns {string}
 */
function getProtocolFromUrlString (urlString) {
  // Requests are typically dispatched against the same origin over and over,
  // so cache the last (urlString, protocol) pair to skip re-parsing.
  if (urlString === lastUrlString) {
    return lastProtocol
  }

  const protocol = getProtocolFromUrlStringSlow(urlString)
  lastUrlString = urlString
  lastProtocol = protocol

  return protocol
}

/**
 * @param {string} urlString
 * @returns {string}
 */
function getProtocolFromUrlStringSlow (urlString) {
  if (
    urlString[0] === 'h' &&
    urlString[1] === 't' &&
    urlString[2] === 't' &&
    urlString[3] === 'p'
  ) {
    switch (urlString[4]) {
      case ':':
        return 'http:'
      case 's':
        if (urlString[5] === ':') {
          return 'https:'
        }
    }
  }
  // fallback if none of the usual suspects
  return urlString.slice(0, urlString.indexOf(':') + 1)
}

const kEnumerableProperty = {
  __proto__: null,
  enumerable: true
}

const normalizedMethodRecordsBase = {
  delete: 'DELETE',
  DELETE: 'DELETE',
  get: 'GET',
  GET: 'GET',
  head: 'HEAD',
  HEAD: 'HEAD',
  options: 'OPTIONS',
  OPTIONS: 'OPTIONS',
  post: 'POST',
  POST: 'POST',
  put: 'PUT',
  PUT: 'PUT',
  query: 'QUERY',
  QUERY: 'QUERY'
}

const normalizedMethodRecords = {
  ...normalizedMethodRecordsBase,
  patch: 'patch',
  PATCH: 'PATCH'
}

// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`.
Object.setPrototypeOf(normalizedMethodRecordsBase, null)
Object.setPrototypeOf(normalizedMethodRecords, null)

module.exports = {
  kEnumerableProperty,
  isDisturbed,
  isBlobLike,
  parseOrigin,
  parseURL,
  getServerName,
  isStream,
  isIterable,
  hasSafeIterator,
  isAsyncIterable,
  isDestroyed,
  headerNameToString,
  bufferToLowerCasedHeaderName,
  addListener,
  removeAllListeners,
  errorRequest,
  parseRawHeaders,
  toRawHeaders,
  encodeRawHeaders,
  parseHeaders,
  parseKeepAliveTimeout,
  destroy,
  bodyLength,
  deepClone,
  ReadableStreamFrom,
  isBuffer,
  assertRequestHandler,
  getSocketInfo,
  isFormDataLike,
  pathHasQueryOrFragment,
  serializePathWithQuery,
  addAbortListener,
  isValidHTTPToken,
  isValidHeaderValue,
  isTokenCharCode,
  parseRangeHeader,
  normalizedMethodRecordsBase,
  normalizedMethodRecords,
  isValidPort,
  isHttpOrHttpsPrefixed,
  safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']),
  wrapRequestBody,
  setupConnectTimeout,
  getProtocolFromUrlString
}
