import debugModule from 'debug'
import http from 'http'
import https from 'https'
import _ from 'lodash'
import net from 'net'
import { getProxyForUrl } from 'proxy-from-env'
import url from 'url'
import { createRetryingSocket, getAddress } from './connect'
import { lenientOptions } from './http-utils'
import { clientCertificateStoreSingleton } from './client-certificates'
import { CaOptions, getCaOptions } from './ca'

const debug = debugModule('cypress:network:agent')
const CRLF = '\r\n'
const statusCodeRe = /^HTTP\/1.[01] (\d*)/

let baseCaOptions: CaOptions | undefined
const getCaOptionsAsync = async (): Promise<CaOptions> => {
  try {
    const options = await getCaOptions()

    baseCaOptions = options

    return options
  } catch (error) {
    debug('Error getting CA options', error)

    // Errors reading the config are treated as warnings by npm and node and handled by those processes separately
    // from what we're doing here.
    return {} as CaOptions
  }
}
let baseCaOptionsPromise: Promise<CaOptions> = getCaOptionsAsync()

// This is for testing purposes only
export const _resetBaseCaOptionsPromise = () => {
  baseCaOptions = undefined
  baseCaOptionsPromise = getCaOptionsAsync()
}

const mergeCAOptions = (options: https.RequestOptions, caOptions: CaOptions): https.RequestOptions => {
  if (!caOptions.ca) {
    return options
  }

  if (!options.ca) {
    return {
      ...options,
      ca: caOptions.ca,
    }
  }

  // First, normalize the options.ca option. It can be a string, a Buffer, an array of strings, or an array of Buffers
  const caArray = _.castArray(options.ca).map((caOption) => caOption.toString())

  return {
    ...options,
    ca: [...caArray, ...caOptions.ca],
  }
}

type WithProxyOpts<RequestOptions> = RequestOptions & {
  proxy: string
  shouldRetry?: boolean
}

type RequestOptionsWithProxy = WithProxyOpts<http.RequestOptions>

type HttpsRequestOptions = https.RequestOptions & {
  minVersion?: 'TLSv1'
}

type HttpsRequestOptionsWithProxy = WithProxyOpts<HttpsRequestOptions>

type FamilyCache = {
  [host: string]: 4 | 6
}

export function buildConnectReqHead (hostname: string, port: string, proxy: url.Url) {
  const connectReq = [`CONNECT ${hostname}:${port} HTTP/1.1`]

  connectReq.push(`Host: ${hostname}:${port}`)

  if (proxy.auth) {
    connectReq.push(`Proxy-Authorization: Basic ${Buffer.from(proxy.auth).toString('base64')}`)
  }

  return connectReq.join(CRLF) + _.repeat(CRLF, 2)
}

interface CreateProxySockOpts {
  proxy: url.Url
  shouldRetry?: boolean
}

type CreateProxySockCb = (
  (err: undefined, result: net.Socket, triggerRetry: (err: Error) => void) => void
) & (
  (err: Error) => void
)

export const createProxySock = (opts: CreateProxySockOpts, cb: CreateProxySockCb) => {
  if (opts.proxy.protocol !== 'https:' && opts.proxy.protocol !== 'http:') {
    return cb(new Error(`Unsupported proxy protocol: ${opts.proxy.protocol}`))
  }

  const isHttps = opts.proxy.protocol === 'https:'
  const port = opts.proxy.port || (isHttps ? 443 : 80)

  let connectOpts: any = {
    port: Number(port),
    host: opts.proxy.hostname,
    useTls: isHttps,
  }

  if (!opts.shouldRetry) {
    connectOpts.getDelayMsForRetry = () => undefined
  }

  createRetryingSocket(connectOpts, (err, sock, triggerRetry) => {
    if (err) {
      return cb(err)
    }

    cb(undefined, <net.Socket>sock, <CreateProxySockCb>triggerRetry)
  })
}

export const isRequestHttps = (options: http.RequestOptions) => {
  // WSS connections will not have an href, but you can tell protocol from the defaultAgent
  return _.get(options, '_defaultAgent.protocol') === 'https:' || options.protocol === 'https:' || (options.href || '').slice(0, 6) === 'https:'
}

export const isResponseStatusCode200 = (head: string) => {
  // read status code from proxy's response
  const matches = head.match(statusCodeRe)

  return _.get(matches, 1) === '200'
}

export const regenerateRequestHead = (req: http.ClientRequest) => {
  delete req._header
  req._implicitHeader()
  if (req.output && req.output.length > 0) {
    // the _header has already been queued to be written to the socket
    const first = req.output[0]
    const endOfHeaders = first.indexOf(_.repeat(CRLF, 2)) + 4

    req.output[0] = req._header + first.substring(endOfHeaders)
  }
}

// this function has to be sync via callback because it is called by the Agent.addRequest method, which expect a sync function
export const getFirstWorkingFamily = (
  { port, host }: Pick<http.RequestOptions, 'port' | 'host'>,
  familyCache: FamilyCache,
  cb: (family?: net.family) => void,
) => {
  // this is a workaround for localhost (and potentially others) having invalid
  // A records but valid AAAA records. here, we just cache the family of the first
  // returned A/AAAA record for a host that we can establish a connection to.
  // https://github.com/cypress-io/cypress/issues/112

  const isIP = net.isIP(host) as net.family | 0

  if (isIP) {
    // isIP conveniently returns the family of the address
    return cb(isIP)
  }

  if (process.env.HTTP_PROXY) {
    // can't make direct connections through the proxy, this won't work
    return cb()
  }

  const cacheKey = `${host}:${port}`

  if (familyCache[cacheKey]) {
    return cb(familyCache[cacheKey])
  }

  return getAddress(port, host)
  .then((firstWorkingAddress: net.Address) => {
    familyCache[cacheKey] = firstWorkingAddress.family

    return cb(firstWorkingAddress.family)
  })
  .catch(() => {
    return cb()
  })
}

export class CombinedAgent {
  httpAgent: HttpAgent
  httpsAgent: HttpsAgent
  familyCache: FamilyCache = {}

  constructor (httpOpts: http.AgentOptions = {}, httpsOpts: https.AgentOptions = {}) {
    this.httpAgent = new HttpAgent(httpOpts)
    this.httpsAgent = new HttpsAgent(httpsOpts)
  }

  // called by Node.js whenever a new request is made internally
  // NOTE: this function has to be sync via callback because it is called by the Agent.addRequest method, which expect a sync function
  addRequest (req: http.ClientRequest, options: http.RequestOptions, port?: number, localAddress?: string) {
    _.merge(req, lenientOptions)

    // Legacy API: addRequest(req, host, port, localAddress)
    // https://github.com/nodejs/node/blob/cb68c04ce1bc4534b2d92bc7319c6ff6dda0180d/lib/_http_agent.js#L148-L155
    if (typeof options === 'string') {
      // @ts-ignore
      options = {
        host: options,
        port: port!,
        localAddress,
      }
    }

    // If the path property is a fully qualified URL, which is what as Axios appears to set,
    // parse the URL and set the href, path, and port based on this path
    if (typeof options.path === 'string' && /^http(s)?:\/\//.test(options.path)) {
      const pathUrl = new URL(options.path)
      const portToSet = pathUrl.port ?? options.port

      options.href = options.path
      options.path = pathUrl.pathname

      if (portToSet) {
        options.port = Number(portToSet)
      }
    }

    const isHttps = isRequestHttps(options)

    // Ensure that HTTPS requests are using 443
    if (isHttps && options.port === 80) {
      options.port = 443
    }

    if (!options.href) {
      // options.path can contain query parameters, which url.format will not-so-kindly urlencode for us...
      // so just append it to the resultant URL string
      options.href = url.format({
        protocol: isHttps ? 'https:' : 'http:',
        slashes: true,
        hostname: options.host,
        port: options.port,
      }) + options.path
    }

    const uri = options.uri = options.uri ?? url.parse(options.href)

    debug('addRequest called %o', { isHttps, ..._.pick(options, 'href') })

    return getFirstWorkingFamily(options, this.familyCache, (family?: net.family) => {
      options.family = family

      debug('got family %o', _.pick(options, 'family', 'href'))

      if (isHttps) {
        _.assign(options, clientCertificateStoreSingleton.getClientCertificateAgentOptionsForUrl(uri))

        return this.httpsAgent.addRequest(req, options as https.RequestOptions)
      }

      return this.httpAgent.addRequest(req, options)
    })
  }
}

const getProxyOrTargetOverrideForUrl = (href: string) => {
  // HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS is used for Cypress in Cypress E2E testing and will
  // force the parent Cypress server to treat the child Cypress server like a proxy without
  // having HTTP_PROXY set and will force traffic ONLY bound to that origin to behave
  // like a proxy
  const targetHost = process.env.HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS

  if (targetHost && href.includes(targetHost)) {
    return targetHost
  }

  return getProxyForUrl(href)
}

class HttpAgent extends http.Agent {
  httpsAgent: https.Agent

  constructor (opts: http.AgentOptions = {}) {
    opts.keepAlive = true
    super(opts)
    // we will need this if they wish to make http requests over an https proxy
    this.httpsAgent = new https.Agent({ keepAlive: true })
  }

  addRequest (req: http.ClientRequest, options: http.RequestOptions) {
    if (process.env.HTTP_PROXY || process.env.HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS) {
      const proxy = getProxyOrTargetOverrideForUrl(options.href)

      if (proxy) {
        options.proxy = proxy

        return this._addProxiedRequest(req, <RequestOptionsWithProxy>options)
      }
    }

    super.addRequest(req, options)
  }

  _addProxiedRequest (req: http.ClientRequest, options: RequestOptionsWithProxy) {
    debug(`Creating proxied request for ${options.href} through ${options.proxy}`)

    const proxy = url.parse(options.proxy)

    // set req.path to the full path so the proxy can resolve it
    // @ts-ignore: Cannot assign to 'path' because it is a constant or a read-only property.
    req.path = options.href

    delete req._header // so we can set headers again

    req.setHeader('host', `${options.host}:${options.port}`)
    if (proxy.auth) {
      req.setHeader('proxy-authorization', `basic ${Buffer.from(proxy.auth).toString('base64')}`)
    }

    // node has queued an HTTP message to be sent already, so we need to regenerate the
    // queued message with the new path and headers
    // https://github.com/TooTallNate/node-http-proxy-agent/blob/master/index.js#L93
    regenerateRequestHead(req)

    options.port = Number(proxy.port || 80)
    options.host = proxy.hostname || 'localhost'
    delete options.path // so the underlying net.connect doesn't default to IPC

    if (proxy.protocol === 'https:') {
      // gonna have to use the https module to reach the proxy, even though this is an http req
      req.agent = this.httpsAgent as any

      return this.httpsAgent.addRequest(req, options)
    }

    super.addRequest(req, options)
  }
}

class HttpsAgent extends https.Agent {
  constructor (opts: https.AgentOptions = {}) {
    opts.keepAlive = true
    super(opts)
  }

  async addRequest (req: http.ClientRequest, options: https.RequestOptions) {
    // Ensure we have a proper port defined otherwise node has assumed we are port 80
    // (https://github.com/nodejs/node/blob/master/lib/_http_client.js#L164) since we are a combined agent
    // rather than an http or https agent. This will cause issues with fetch requests (@cypress/request already handles it:
    // https://github.com/cypress-io/request/blob/master/request.js#L301-L303)
    if (!options?.uri?.port && options?.uri?.protocol === 'https:') {
      options.uri = {
        ...options.uri,
        port: String(443),
      }

      options.port = 443
    }

    if (baseCaOptions) {
      super.addRequest(req, mergeCAOptions(options, baseCaOptions))
    } else {
      await baseCaOptionsPromise.then((caOptions) => {
        super.addRequest(req, mergeCAOptions(options, caOptions))
      })
    }
  }

  createConnection (options: HttpsRequestOptions, cb?: any): any {
    if (process.env.HTTPS_PROXY) {
      const proxy = getProxyForUrl(options.href)

      if (proxy) {
        options.proxy = <string>proxy

        // If no callback is provided, we can't handle the async proxy connection
        // Return the direct connection instead
        if (!cb) {
          return super.createConnection(options)
        }

        return this.createUpstreamProxyConnection(<HttpsRequestOptionsWithProxy>options, cb as any)
      }
    }

    // @ts-ignore
    cb?.(null, super.createConnection(options) as any)
  }

  createUpstreamProxyConnection (options: HttpsRequestOptionsWithProxy, cb: http.SocketCallback) {
    // heavily inspired by
    // https://github.com/mknj/node-keepalive-proxy-agent/blob/master/index.js
    debug(`Creating proxied socket for ${options.href} through ${options.proxy}`)

    const proxy = url.parse(options.proxy)
    const port = options.uri?.port || '443'
    const hostname = options.uri?.hostname || 'localhost'

    createProxySock({ proxy, shouldRetry: options.shouldRetry }, (originalErr?: Error, proxySocket?: net.Socket, triggerRetry?: (err: Error) => void) => {
      if (originalErr) {
        const err: any = new Error(`A connection to the upstream proxy could not be established: ${originalErr.message}`)

        err.originalErr = originalErr
        err.upstreamProxyConnect = true

        return cb(err, undefined)
      }

      const onClose = () => {
        triggerRetry?.(new Error('ERR_EMPTY_RESPONSE: The upstream proxy closed the socket after connecting but before sending a response.'))
      }

      const onError = (err: Error) => {
        triggerRetry?.(err)
        proxySocket?.destroy()
      }

      let buffer = ''

      const onData = (data: Buffer) => {
        debug(`Proxy socket for ${options.href} established`)

        buffer += data.toString()

        if (!_.includes(buffer, _.repeat(CRLF, 2))) {
          // haven't received end of headers yet, keep buffering
          proxySocket?.once('data', onData)

          return
        }

        // we've now gotten enough of a response not to retry
        // connecting to the proxy
        proxySocket?.removeListener('error', onError)
        proxySocket?.removeListener('close', onClose)

        if (!isResponseStatusCode200(buffer)) {
          return cb(new Error(`Error establishing proxy connection. Response from server was: ${buffer}`), undefined)
        }

        if (options._agentKey) {
          // https.Agent will upgrade and reuse this socket now
          options.socket = proxySocket

          // as of Node 12, a ServerName cannot be an IP address
          // https://github.com/cypress-io/cypress/issues/5729
          if (!net.isIP(hostname)) {
            options.servername = hostname
          }

          return cb(undefined, super.createConnection(options) as any)
        }

        cb(undefined, proxySocket)
      }

      proxySocket?.once('close', onClose)
      proxySocket?.once('error', onError)
      proxySocket?.once('data', onData)

      const connectReq = buildConnectReqHead(hostname, port, proxy)

      proxySocket?.setNoDelay(true)
      proxySocket?.write(connectReq)
    })
  }
}

// NODE_TLS_REJECT_UNAUTHORIZED is set to '0' in Cypress to cover
// all traffic to the user's app and `agent` honors this by default.
// Calls to the Cloud should use the `strictAgent` or `api/index`'s
// request promise implementation instead as they override
// this functionality to actually reject in unauthorized situations.
const agent = new CombinedAgent()

// This agent always rejects unauthorized certificates.
const strictAgent = new CombinedAgent({}, {
  rejectUnauthorized: true,
})

export default agent

export { strictAgent }
