import Bluebird from 'bluebird'
import compression from 'compression'
import Debug from 'debug'
import EventEmitter from 'events'
import evilDns from 'evil-dns'
import * as ensureUrl from './util/ensure-url'
import express, { Express } from 'express'
import http from 'http'
import httpProxy from 'http-proxy'
import _ from 'lodash'
import type { AddressInfo } from 'net'
import url from 'url'
import la from 'lazy-ass'
import { createProxy as createHttpsProxy } from '@packages/https-proxy'
import type { Server as HttpsProxyServer } from '@packages/https-proxy'
import { getRoutesForRequest, netStubbingState, NetStubbingState } from '@packages/net-stubbing'
import { agent, clientCertificates, httpUtils, concatStream } from '@packages/network'
import { DocumentDomainInjection, getPath, getSupportedAcceptEncoding, parseUrlIntoHostProtocolDomainTldPort, removeDefaultPort } from '@packages/network-tools'
import { NetworkProxy, BrowserPreRequest } from '@packages/proxy'
import type { SocketCt } from './socket-ct'
import * as errors from './errors'
import { Request } from './request'
import type { SocketE2E } from './socket-e2e'
import templateEngine from './template_engine'
import { ensureProp } from './util/class-helpers'
import { allowDestroy, DestroyableHttpServer } from './util/server_destroy'
import { SocketAllowed } from './util/socket_allowed'
import { createInitialWorkers } from '@packages/rewriter'
import type { Cfg } from './project-base'
import type { Browser } from './browsers/types'
import { InitializeRoutes, createCommonRoutes } from './routes'
import type { FoundSpec, ProtocolManagerShape, TestingType } from '@packages/types'
import { RemoteStates } from '@packages/network-tools'
import type { RemoteState } from '@packages/network-tools'
import { cookieJar, SerializableAutomationCookie } from './util/cookies'
import * as fileServer from './file_server'
import type { FileServer } from './file_server'
import appData from './util/app_data'
import { graphqlWS } from '@packages/data-context/graphql/makeGraphQLServer'
import type { GraphqlWsHandle } from '@packages/data-context/graphql/makeGraphQLServer'
import * as statusCode from './util/status_code'
import { getContentType } from './util/headers'
import stream from 'stream'
import isHtml from 'is-html'
import type Protocol from 'devtools-protocol'
import type { ServiceWorkerClientEvent } from '@packages/proxy/lib/http/util/service-worker-manager'
import type { Automation } from './automation'
import type { AutomationCookie } from './automation/cookies'
import type { ResourceType, RequestCredentialLevel } from '@packages/proxy'
import { GracefulExit } from './util/graceful-exit'

const debug = Debug('cypress:server:server-base')

const fullyQualifiedRe = /^https?:\/\//
const htmlContentTypesRe = /^(text\/html|application\/xhtml)/i

const isResponseHtml = function (contentType, responseBuffer) {
  if (contentType) {
    // want to match anything starting with 'text/html'
    // including 'text/html;charset=utf-8' and 'Text/HTML'
    // https://github.com/cypress-io/cypress/issues/8506
    return htmlContentTypesRe.test(contentType)
  }

  const body = _.invoke(responseBuffer, 'toString')

  if (body) {
    return isHtml(body)
  }

  return false
}

const _isNonProxiedRequest = (req) => {
  // proxied HTTP requests have a URL like: "http://example.com/foo"
  // non-proxied HTTP requests have a URL like: "/foo"
  return req.proxiedUrl.startsWith('/')
}

const _forceProxyMiddleware = function (clientRoute, namespace = '__cypress') {
  const ALLOWED_PROXY_BYPASS_URLS = [
    '/',
    `/${namespace}/runner/cypress_runner.css`,
    `/${namespace}/runner/cypress_runner.js`, // TODO: fix this
    `/${namespace}/runner/favicon.ico`,
  ]

  // normalize clientRoute to help with comparison
  const trimmedClientRoute = _.trimEnd(clientRoute, '/')

  return function (req, res, next) {
    const trimmedUrl = _.trimEnd(req.proxiedUrl, '/')

    // if this request is a non-proxied cy-in-cy request,
    // we need to update the proxiedUrl and allow it to pass through
    if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF && _isNonProxiedRequest(req) && req.headers.referer) {
      const referrerUrl = new URL(req.headers.referer)

      req.proxiedUrl = `${referrerUrl.origin}${req.proxiedUrl}`

      return next()
    }

    if (_isNonProxiedRequest(req) && !ALLOWED_PROXY_BYPASS_URLS.includes(trimmedUrl) && (trimmedUrl !== trimmedClientRoute)) {
      // this request is non-proxied and non-allowed, redirect to the runner error page
      return res.redirect(clientRoute)
    }

    return next()
  }
}

const setProxiedUrl = function (req) {
  // proxiedUrl is the full URL with scheme, host, and port
  // it will only be fully-qualified if the request was proxied.

  // this function will set the URL of the request to be the path
  // only, which can then be used to proxy the request.

  // bail if we've already proxied the url
  if (req.proxiedUrl) {
    return
  }

  // backup the original proxied url
  // and slice out the host/origin
  // and only leave the path which is
  // how browsers would normally send
  // use their url
  req.proxiedUrl = removeDefaultPort(req.url).format()

  req.url = getPath(req.url)
}

const notSSE = (req, res) => {
  return (req.headers.accept !== 'text/event-stream') && compression.filter(req, res)
}

type WarningErr = Record<string, any>

interface OpenServerOptions {
  SocketCtor: typeof SocketE2E | typeof SocketCt
  testingType: Cypress.TestingType
  onError: any
  onWarning: any
  getCurrentBrowser: () => Browser
  getSpec: () => FoundSpec | null
  shouldCorrelatePreRequests: () => boolean
}

export class ServerBase<TSocket extends SocketE2E | SocketCt> {
  private _middleware
  protected request: Request
  protected isListening: boolean
  protected socketAllowed: SocketAllowed
  protected _fileServer: FileServer | null
  protected _baseUrl: string | null
  protected _server?: DestroyableHttpServer
  protected _socket?: TSocket
  protected _nodeProxy?: httpProxy
  protected _networkProxy?: NetworkProxy
  protected _netStubbingState?: NetStubbingState
  // @ts-ignore - this is currently affecting the v8-snapshot type checking job as we are importing the file directly from the server package
  // After some package refactoring, we should be able to remove this.
  protected _httpsProxy?: httpsProxy
  protected _graphqlWS?: GraphqlWsHandle
  protected _eventBus: EventEmitter
  protected _remoteStates: RemoteStates
  private getCurrentBrowser: undefined | (() => Browser)
  private _urlResolver: Bluebird<Record<string, any>> | null = null
  private testingType?: TestingType
  private _documentDomainInjection: DocumentDomainInjection

  constructor (config: Cfg) {
    this.isListening = false
    this.request = new Request()
    this.socketAllowed = new SocketAllowed()
    this._eventBus = new EventEmitter()
    this._middleware = null
    this._baseUrl = null
    this._fileServer = null

    this._documentDomainInjection = DocumentDomainInjection.InjectionBehavior(config)

    const remoteStatePorts = () => {
      return {
        server: this._port(),
        fileServer: this._fileServer?.port(),
      }
    }

    this._remoteStates = new RemoteStates(remoteStatePorts, this._documentDomainInjection)
  }

  ensureProp = ensureProp

  get server () {
    return this.ensureProp(this._server, 'open')
  }

  get socket () {
    return this.ensureProp(this._socket, 'open')
  }

  get nodeProxy () {
    return this.ensureProp(this._nodeProxy, 'open')
  }

  get networkProxy () {
    return this.ensureProp(this._networkProxy, 'open')
  }

  get netStubbingState () {
    return this.ensureProp(this._netStubbingState, 'open')
  }

  get httpsProxy () {
    return this.ensureProp(this._httpsProxy, 'open')
  }

  get remoteStates () {
    return this._remoteStates
  }

  setProtocolManager (protocolManager: ProtocolManagerShape | undefined) {
    this._socket?.setProtocolManager(protocolManager)
    this._networkProxy?.setProtocolManager(protocolManager)
  }

  setPreRequestTimeout (timeout: number) {
    this._networkProxy?.setPreRequestTimeout(timeout)
  }

  setupCrossOriginRequestHandling () {
    this._eventBus.on('cross:origin:cookies', (cookies: SerializableAutomationCookie[]) => {
      this.socket.localBus.once('cross:origin:cookies:received', () => {
        this._eventBus.emit('cross:origin:cookies:received')
      })

      this.socket.toDriver('cross:origin:cookies', cookies)
    })

    this.socket.localBus.on('request:sent:with:credentials', (credentials: { url: string, resourceType: ResourceType, credentialStatus: RequestCredentialLevel }) => {
      this._networkProxy?.setCredentials(credentials)
    })
  }

  async createServer (
    app: Express,
    config: Cfg,
    onWarning: unknown,
  ): Promise<[number, WarningErr?]> {
    const { port, fileServerFolder, socketIoRoute, baseUrl } = config

    this._server = this._createHttpServer(app)

    debug('createServer connecting to server')

    this.server.on('connect', this.onConnect.bind(this))
    this.server.on('upgrade', (req, socket, head) => this.onUpgrade(req, socket, head, socketIoRoute))

    this._graphqlWS = graphqlWS(this.server, `${socketIoRoute}-graphql`)

    // Start the file server first so its port is known before we begin
    // listening for proxied requests on the main server. The primary
    // remote state's `<root>` strategy reads `_fileServer.port()`
    // synchronously, so the fileServer must exist before the primary
    // is computed. The httpsProxy comes after — it depends on the main
    // server's port.
    this._fileServer = await fileServer.create(fileServerFolder as string) as FileServer

    const listenedPort = await this._listen(port)

    this._remoteStates.set(baseUrl != null ? baseUrl : '<root>')

    this._httpsProxy = await createHttpsProxy(appData.path('proxy'), listenedPort, {
      onRequest: this.callListeners.bind(this),
      onUpgrade: this.onSniUpgrade.bind(this),
    }) as HttpsProxyServer

    let warning: WarningErr | undefined

    // if we have a baseUrl let's go ahead and make sure the server is
    // connectable!
    if (baseUrl) {
      this._baseUrl = baseUrl

      if (config.isTextTerminal) {
        try {
          await this._retryBaseUrlCheck(baseUrl, onWarning)
        } catch (e) {
          debug(e)
          throw errors.get('CANNOT_CONNECT_BASE_URL')
        }
      } else {
        try {
          await ensureUrl.isListening(baseUrl)
        } catch (err) {
          debug('ensuring baseUrl (%s) errored: %o', baseUrl, err)
          warning = errors.get('CANNOT_CONNECT_BASE_URL_WARNING', baseUrl) as WarningErr
        }
      }
    }

    return [listenedPort, warning]
  }

  open (config: Cfg, {
    getSpec,
    getCurrentBrowser,
    onError,
    onWarning,
    shouldCorrelatePreRequests,
    testingType,
    SocketCtor,
  }: OpenServerOptions) {
    debug('server open')
    this.testingType = testingType

    la(_.isPlainObject(config), 'expected plain config object', config)

    if (!config.baseUrl && testingType === 'component') {
      throw new Error('Server#open called without config.baseUrl.')
    }

    const app = this.createExpressApp(config)

    this._nodeProxy = httpProxy.createProxyServer({
      target: config.baseUrl && testingType === 'component' ? config.baseUrl : undefined,
    })

    this._socket = new SocketCtor(config) as TSocket

    clientCertificates.loadClientCertificateConfig(config)

    this.createNetworkProxy({
      config,
      remoteStates: this._remoteStates,
      shouldCorrelatePreRequests,
      getCurrentBrowser,
    })

    if (config.experimentalSourceRewriting) {
      createInitialWorkers()
    }

    this.createHosts(config.hosts)

    const routeOptions: InitializeRoutes = {
      config,
      remoteStates: this._remoteStates,
      nodeProxy: this.nodeProxy,
      networkProxy: this._networkProxy!,
      onError,
      getSpec,
      testingType,
    }

    this.getCurrentBrowser = getCurrentBrowser

    this.setupCrossOriginRequestHandling()

    app.use(createCommonRoutes(routeOptions))

    // Preserve Bluebird-typed return value.
    return Bluebird.resolve(this.createServer(app, config, onWarning))
  }

  createExpressApp (config) {
    const { morgan, clientRoute, namespace } = config
    const app = express()

    // set the cypress config from the cypress.config.{js,ts,mjs,cjs} file
    app.set('view engine', 'html')

    // since we use absolute paths, configure express-handlebars to not automatically find layouts
    // https://github.com/cypress-io/cypress/issues/2891
    app.engine('html', templateEngine.render)

    // handle the proxied url in case
    // we have not yet started our websocket server
    app.use((req, res, next) => {
      setProxiedUrl(req)

      // useful for tests
      if (this._middleware) {
        this._middleware(req, res)
      }

      // always continue on

      return next()
    })

    app.use(_forceProxyMiddleware(clientRoute, namespace))

    app.use(require('cookie-parser')())
    app.use(compression({ filter: notSSE }))
    if (morgan) {
      app.use(this.useMorgan())
    }

    // errorhandler
    app.use(require('errorhandler')())

    // remove the express powered-by header
    app.disable('x-powered-by')

    return app
  }

  useMorgan () {
    return require('morgan')('dev', {
      skip: () => GracefulExit.isShuttingDown,
    })
  }

  getHttpServer () {
    return this._server
  }

  portInUseErr (port: any) {
    const e = errors.get('PORT_IN_USE_SHORT', port) as any

    e.port = port
    e.portInUse = true

    return e
  }

  createNetworkProxy ({ config, remoteStates, shouldCorrelatePreRequests, getCurrentBrowser }) {
    const getFileServerToken = () => {
      return this._fileServer?.token
    }

    this._netStubbingState = netStubbingState()
    // @ts-ignore
    this._networkProxy = new NetworkProxy({
      config,
      shouldCorrelatePreRequests,
      remoteStates,
      getFileServerToken,
      getCookieJar: () => cookieJar,
      socket: this.socket,
      netStubbingState: this.netStubbingState,
      request: this.request,
      serverBus: this._eventBus,
      getCurrentBrowser,
    })
  }

  startWebsockets (automation: Automation, config, options: Record<string, unknown> = {}) {
    // e2e only?
    options.onResolveUrl = this._onResolveUrl.bind(this)

    options.onRequest = this._onRequest.bind(this)
    options.netStubbingState = this.netStubbingState
    options.getRenderedHTMLOrigins = this._networkProxy?.http.getRenderedHTMLOrigins
    options.getCurrentBrowser = () => this.getCurrentBrowser?.()

    options.onResetServerState = () => {
      this.networkProxy.reset({ resetBetweenSpecs: false })
      this.netStubbingState.reset()
      this._remoteStates.reset()
      this.networkProxy.clearCredentials()
    }

    const ios = this.socket.startListening(this.server, automation, config, options)

    this._normalizeReqUrl(this.server)

    return ios
  }

  createHosts (hosts: { [key: string]: string } | null = {}) {
    return _.each(hosts, (ip, host) => {
      return evilDns.add(host, ip)
    })
  }

  async addBrowserPreRequest (browserPreRequest: BrowserPreRequest) {
    await this.networkProxy.addPendingBrowserPreRequest(browserPreRequest)
  }

  removeBrowserPreRequest (requestId: string) {
    this.networkProxy.removePendingBrowserPreRequest(requestId)
  }

  getBrowserPreRequests () {
    return this._networkProxy?.getPendingBrowserPreRequests()
  }

  emitRequestEvent (eventName, data) {
    this.socket.toDriver('request:event', eventName, data)
  }

  addPendingUrlWithoutPreRequest (downloadUrl: string) {
    this.networkProxy.addPendingUrlWithoutPreRequest(downloadUrl)
  }

  updateServiceWorkerRegistrations (data: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) {
    this.networkProxy.updateServiceWorkerRegistrations(data)
  }

  updateServiceWorkerVersions (data: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) {
    this.networkProxy.updateServiceWorkerVersions(data)
  }

  updateServiceWorkerClientSideRegistrations (data: { scriptURL: string, initiatorOrigin: string }) {
    this.networkProxy.updateServiceWorkerClientSideRegistrations(data)
  }

  handleServiceWorkerClientEvent (event: ServiceWorkerClientEvent) {
    this.networkProxy.handleServiceWorkerClientEvent(event)
  }

  _createHttpServer (app): DestroyableHttpServer {
    const svr = http.createServer(httpUtils.lenientOptions, app)

    allowDestroy(svr)

    // @ts-ignore
    return svr
  }

  _port = () => {
    return (this.server.address() as AddressInfo).port
  }

  _listen (port: number | null | undefined): Promise<number> {
    return new Promise<number>((resolve, reject) => {
      const onError = (err) => {
        // if the server bombs before starting
        // and the err no is EADDRINUSE
        // then we know to display the custom err message
        if (err.code === 'EADDRINUSE') {
          reject(this.portInUseErr(port))
        }
      }

      this.server.once('error', onError)

      const listener = () => {
        const address = this.server.address() as AddressInfo

        this.isListening = true

        debug('Server listening on ', address)

        this.server.removeListener('error', onError)

        resolve(address.port)
      }

      this.server.listen(port || 0, '127.0.0.1', listener)
    })
  }

  _onRequest (userAgent, automationRequest, options) {
    // @ts-ignore
    return this.request.sendPromise(userAgent, automationRequest, options)
  }

  _callRequestListeners (server, listeners, req, res) {
    return listeners.map((listener) => {
      return listener.call(server, req, res)
    })
  }

  _normalizeReqUrl (server) {
    // because socket.io removes all of our request
    // events, it forces the socket.io traffic to be
    // handled first.
    // however we need to basically do the same thing
    // it does and after we call into socket.io go
    // through and remove all request listeners
    // and change the req.url by slicing out the host
    // because the browser is in proxy mode
    const listeners = server.listeners('request').slice(0)

    server.removeAllListeners('request')

    server.on('request', (req, res) => {
      setProxiedUrl(req)

      this._callRequestListeners(server, listeners, req, res)
    })
  }

  proxyWebsockets (proxy, socketIoRoute, req, socket, head) {
    // bail if this is our own namespaced socket.io / graphql-ws request

    if (req.url.startsWith(socketIoRoute)) {
      if (!this.socketAllowed.isRequestAllowed(req)) {
        socket.write('HTTP/1.1 400 Bad Request\r\n\r\nRequest not made via a Cypress-launched browser.')
        socket.end()
      }

      // we can return here either way, if the socket is still valid socket.io or graphql-ws will hook it up
      return
    }

    const host = req.headers.host

    if (host) {
      // get the protocol using req.connection.encrypted
      // get the port & hostname from host header
      const fullUrl = `${req.connection.encrypted ? 'https' : 'http'}://${host}`
      const { hostname, protocol } = url.parse(fullUrl)
      const { port } = parseUrlIntoHostProtocolDomainTldPort(fullUrl)

      const onProxyErr = (err, req, res) => {
        return debug('Got ERROR proxying websocket connection', { err, port, protocol, hostname, req })
      }

      return proxy.ws(req, socket, head, {
        secure: false,
        target: {
          host: hostname,
          port,
          protocol,
        },
        headers: {
          'x-cypress-forwarded-from-cypress': true,
        },
        agent,
      }, onProxyErr)
    }

    // we can't do anything with this socket
    // since we don't know how to proxy it!
    if (socket.writable) {
      return socket.end()
    }
  }

  reset () {
    this._networkProxy?.reset({ resetBetweenSpecs: true })
    this._networkProxy?.clearCredentials()
    const baseUrl = this._baseUrl ?? '<root>'

    return this._remoteStates.set(baseUrl)
  }

  _close () {
    // bail early we dont have a server or we're not
    // currently listening
    if (!this._server || !this.isListening) {
      return Promise.resolve(true)
    }

    this.reset()

    evilDns.clear()

    return this._server.destroyAsync()
    .then(() => {
      this.isListening = false
    })
  }

  close () {
    // graphql-ws clients must be closed before the HTTP server is destroyed.
    const graphqlDispose = this._graphqlWS?.dispose
      ? Bluebird.resolve(this._graphqlWS.dispose()).finally(() => {
        // graphql-ws dispose() closes the ws server; repeating close() rejects with
        // "The server is not running". Clear handle so subsequent close() is a no-op for gql.
        this._graphqlWS = undefined
      })
      : Bluebird.resolve()

    return graphqlDispose.then(() => {
      return Bluebird.all([
        this._close(),
        this._socket?.close(),
        this._fileServer?.close(),
        this._httpsProxy?.close(),
      ])
    })
    .then((res) => {
      this._middleware = null

      return res
    })
  }

  end () {
    return this._socket && this._socket.end()
  }

  async sendFocusBrowserMessage () {
    this._socket && await this._socket.sendFocusBrowserMessage()
  }

  onRequest (fn) {
    this._middleware = fn
  }

  onNextRequest (fn) {
    return this.onRequest((...args) => {
      fn.apply(this, args)

      this._middleware = null
    })
  }

  onUpgrade (req, socket, head, socketIoRoute) {
    debug('Got UPGRADE request from %s', req.url)

    return this.proxyWebsockets(this.nodeProxy, socketIoRoute, req, socket, head)
  }

  callListeners (req, res) {
    const listeners = this.server.listeners('request').slice(0)

    return this._callRequestListeners(this.server, listeners, req, res)
  }

  onSniUpgrade (req, socket, head) {
    const upgrades = this.server.listeners('upgrade').slice(0)

    return upgrades.map((upgrade) => {
      return upgrade.call(this.server, req, socket, head)
    })
  }

  onConnect (req, socket, head) {
    debug('Got CONNECT request from %s', req.url)

    socket.once('upstream-connected', this.socketAllowed.add)

    return this.httpsProxy.connect(req, socket, head)
  }

  _retryBaseUrlCheck (baseUrl, onWarning) {
    return ensureUrl.retryIsListening(baseUrl, {
      retryIntervals: [3000, 3000, 4000],
      onRetry ({ attempt, delay, remaining }) {
        const warning = errors.get('CANNOT_CONNECT_BASE_URL_RETRYING', {
          remaining,
          attempt,
          delay,
          baseUrl,
        })

        return onWarning(warning)
      },
    })
  }

  _onResolveUrl (urlStr, userAgent, automationRequest: (message: string, data: Record<string, unknown>) => Bluebird<any>, options: Record<string, any> = { headers: {} }) {
    debug('resolving visit %o', {
      url: urlStr,
      userAgent,
      options,
    })

    // always clear buffers - reduces the possibility of a random HTTP request
    // accidentally retrieving buffered content at the wrong time
    this._networkProxy?.reset()

    const startTime = Date.now()

    // if we have an existing url resolver
    // in flight then cancel it
    if (this._urlResolver) {
      this._urlResolver.cancel()
    }

    const request = this.request

    let handlingLocalFile = false
    const previousRemoteState = this._remoteStates.current()
    const previousRemoteStateIsPrimary = this._remoteStates.isPrimarySuperDomainOrigin(previousRemoteState.origin)
    const primaryRemoteState = this._remoteStates.getPrimary()

    // nuke any hashes from our url since
    // those those are client only and do
    // not apply to http requests
    urlStr = url.parse(urlStr)
    urlStr.hash = null
    urlStr = urlStr.format()

    const originalUrl = urlStr

    let reqStream = null
    let currentPromisePhase = null

    const runPhase = (fn) => {
      return currentPromisePhase = fn()
    }

    const matchesNetStubbingRoute = (requestOptions) => {
      const proxiedReq = {
        proxiedUrl: requestOptions.url,
        resourceType: 'document',
        ..._.pick(requestOptions, ['headers', 'method']),
        // TODO: add `body` here once bodies can be statically matched
      }

      // @ts-ignore
      const iterator = getRoutesForRequest(this.netStubbingState?.routes, proxiedReq)
      // If the iterator is exhausted (done) on the first try, then 0 matches were found
      const zeroMatches = iterator.next().done

      return !zeroMatches
    }

    let p

    return this._urlResolver = (p = new Bluebird<Record<string, any>>((resolve, reject, onCancel) => {
      let urlFile

      onCancel?.(() => {
        p.currentPromisePhase = currentPromisePhase
        p.reqStream = reqStream

        _.invoke(reqStream, 'abort')

        return _.invoke(currentPromisePhase, 'cancel')
      })

      const redirects: any[] = []
      let newUrl: string | null = null

      if (!fullyQualifiedRe.test(urlStr)) {
        handlingLocalFile = true

        options.headers['x-cypress-authorization'] = this._fileServer?.token

        const state = this._remoteStates.set(urlStr, options)

        // TODO: Update url.resolve signature to not use deprecated methods
        urlFile = state?.fileServer ? url.resolve(state.fileServer, urlStr) : url.resolve('', urlStr)
        urlStr = state?.origin ? url.resolve(state.origin, urlStr) : url.resolve('', urlStr)
      }

      const onReqError = (err) => {
        // only restore the previous state
        // if our promise is still pending
        if (p.isPending()) {
          restorePreviousRemoteState(previousRemoteState, previousRemoteStateIsPrimary)
        }

        return reject(err)
      }

      const onReqStreamReady = (str) => {
        reqStream = str

        return str
        .on('error', onReqError)
        .on('response', (incomingRes) => {
          debug(
            'resolve:url headers received, buffering response %o',
            _.pick(incomingRes, 'headers', 'statusCode'),
          )

          if (newUrl == null) {
            newUrl = urlStr
          }

          return runPhase(() => {
            // get the cookies that would be sent with this request so they can be rehydrated
            const hostname = newUrl ? this._documentDomainInjection.getHostname(newUrl) : undefined

            return automationRequest('get:cookies', {
              domain: hostname,
            })
            .then((cookies: (AutomationCookie | null)[]) => {
              const statusIs2xxOrAllowedFailure = () => {
                // is our status code in the 2xx range, or have we disabled failing
                // on status code?
                return statusCode.isOk(incomingRes.statusCode) || options.failOnStatusCode === false
              }

              const isOk = statusIs2xxOrAllowedFailure()
              const contentType = getContentType(incomingRes)

              const details: Record<string, unknown> = {
                isOkStatusCode: isOk,
                contentType,
                url: newUrl,
                status: incomingRes.statusCode,
                cookies,
                statusText: statusCode.getText(incomingRes.statusCode),
                redirects,
                originalUrl,
              }

              // does this response have this cypress header?
              const fp = incomingRes.headers['x-cypress-file-path']

              if (fp) {
                // if so we know this is a local file request
                details.filePath = decodeURI(fp)
              }

              debug('setting details resolving url %o', details)

              const concatStr = concatStream((responseBuffer) => {
                // buffer the entire response before resolving.
                // this allows us to detect & reject ETIMEDOUT errors
                // where the headers have been sent but the
                // connection hangs before receiving a body.

                // if there is not a content-type, try to determine
                // if the response content is HTML-like
                // https://github.com/cypress-io/cypress/issues/1727
                details.isHtml = isResponseHtml(contentType, responseBuffer)

                debug('resolve:url response ended, setting buffer %o', { newUrl, alreadyVisited: options.hasAlreadyVisitedUrl, details })

                details.totalTime = Date.now() - startTime

                // buffer the response and set the remote state if this is a successful html response
                // TODO: think about moving this logic back into the frontend so that the driver can be in control
                // of when to buffer and set the remote state
                if (isOk && details.isHtml) {
                  const originsMatchByPolicy = this._documentDomainInjection.urlsMatch(primaryRemoteState.origin, newUrl || '')

                  const urlDoesNotMatchPolicyBasedOnDomain = options.hasAlreadyVisitedUrl
                    && !originsMatchByPolicy
                    || options.isFromSpecBridge

                  debug('urlDoesNotMatchPolicy?: %o', {
                    urlDoesNotMatchPolicyBasedOnDomain,
                    hasAlreadyVisited: options.hasAlreadyVisited,
                    originsMatchByPolicy,
                    isFromSpecBridge: options.isFromSpecBridge,
                  })

                  if (!handlingLocalFile) {
                    this._remoteStates.set(newUrl as string, options, !urlDoesNotMatchPolicyBasedOnDomain)
                  }

                  const responseBufferStream = new stream.PassThrough({
                    highWaterMark: Number.MAX_SAFE_INTEGER,
                  })

                  responseBufferStream.end(responseBuffer)

                  this._networkProxy?.setHttpBuffer({
                    url: newUrl,
                    stream: responseBufferStream,
                    details,
                    originalUrl,
                    response: incomingRes,
                    urlDoesNotMatchPolicyBasedOnDomain,
                  })
                } else {
                  // TODO: move this logic to the driver too for
                  // the same reasons listed above
                  restorePreviousRemoteState(previousRemoteState, previousRemoteStateIsPrimary)
                }

                details.isPrimarySuperDomainOrigin = this._remoteStates.isPrimarySuperDomainOrigin(newUrl!)

                return resolve(details)
              })

              return str.pipe(concatStr)
            }).catch(onReqError)
          })
        })
      }

      const restorePreviousRemoteState = (previousRemoteState: RemoteState, previousRemoteStateIsPrimary: boolean) => {
        this._remoteStates.set(previousRemoteState, {}, previousRemoteStateIsPrimary)
      }

      // if they're POSTing an object, querystringify their POST body
      if ((options.method === 'POST') && _.isObject(options.body)) {
        options.form = options.body
        delete options.body
      }

      // HTTP header names are case-insensitive; convert all keys to lowercase
      options.headers = _.mapKeys(options.headers, (value, key) => key.toLowerCase())

      _.assign(options, {
        // turn off gzip since we need to eventually
        // rewrite these contents
        gzip: false,
        url: urlFile != null ? urlFile : urlStr,
        headers: _.assign({
          accept: 'text/html,*/*',
        }, options.headers, {
          'accept-encoding': getSupportedAcceptEncoding(options.headers['accept-encoding']),
        }),
        onBeforeReqInit: runPhase,
        followRedirect (incomingRes) {
          const status = incomingRes.statusCode
          const next = incomingRes.headers.location

          const curr = newUrl != null ? newUrl : urlStr

          newUrl = url.resolve(curr, next)

          redirects.push([status, newUrl].join(': '))

          return true
        },
      })

      if (matchesNetStubbingRoute(options)) {
        // TODO: this is being used to force cy.visits to be interceptable by network stubbing
        // however, network errors will be obfuscated by the proxying so this is not an ideal solution
        _.merge(options, {
          proxy: `http://127.0.0.1:${this._port()}`,
          agent: null,
          headers: {
            'x-cypress-resolving-url': '1',
          },
        })
      }

      debug('sending request with options %o', options)

      return runPhase(() => {
        // @ts-ignore - this is currently affecting the v8-snapshot type checking job as we are importing the file directly from the server package
        // After some package refactoring, we should be able to remove this.
        return request.sendStream(userAgent, automationRequest, options)
        .then((createReqStream) => {
          const stream = createReqStream()

          return onReqStreamReady(stream)
        }).catch(onReqError)
      })
    }))
  }

  destroyAut () {
    if (this.testingType === 'component' && 'destroyAut' in this.socket) {
      return this.socket.destroyAut()
    }

    return
  }
}
