'use strict'

const { kProxy, kClose, kDestroy, kDispatch } = require('../core/symbols')
const Agent = require('./agent')
const Pool = require('./pool')
const DispatcherBase = require('./dispatcher-base')
const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError, ProxyConnectionError } = require('../core/errors')
const buildConnector = require('../core/connect')
const Client = require('./client')
const { channels } = require('../core/diagnostics')
const Socks5ProxyAgent = require('./socks5-proxy-agent')

const kAgent = Symbol('proxy agent')
const kClient = Symbol('proxy client')
const kProxyHeaders = Symbol('proxy headers')
const kRequestTls = Symbol('request tls settings')
const kProxyTls = Symbol('proxy tls settings')
const kConnectEndpoint = Symbol('connect endpoint function')
const kConnectEndpointHTTP1 = Symbol('connect endpoint function (http/1.1 only)')
const kTunnelProxy = Symbol('tunnel proxy')
const proxyAuthorization = 'proxy-authorization'

function defaultProtocolPort (protocol) {
  return protocol === 'https:' ? 443 : 80
}

function defaultFactory (origin, opts) {
  return new Pool(origin, opts)
}

const noop = () => {}

function defaultAgentFactory (origin, opts) {
  if (opts.connections === 1) {
    return new Client(origin, opts)
  }
  return new Pool(origin, opts)
}

function shouldProxyTunnel (requestProtocol, proxyTunnel) {
  return proxyTunnel === true || requestProtocol !== 'http:'
}

class Http1ProxyWrapper extends DispatcherBase {
  #client
  #proxyServername

  constructor (proxyUrl, { headers = {}, connect, factory, proxyServername }) {
    if (!proxyUrl) {
      throw new InvalidArgumentError('Proxy URL is mandatory')
    }

    super()

    this[kProxyHeaders] = headers
    this.#proxyServername = proxyServername
    if (factory) {
      this.#client = factory(proxyUrl, { connect })
    } else {
      this.#client = new Client(proxyUrl, { connect })
    }
  }

  [kDispatch] (opts, handler) {
    const onResponseStart = handler.onResponseStart
    handler.onResponseStart = function (controller, statusCode, data, statusMessage) {
      if (statusCode === 407) {
        if (typeof handler.onResponseError === 'function') {
          handler.onResponseError(controller, new InvalidArgumentError('Proxy Authentication Required (407)'))
        }
        return
      }
      if (onResponseStart) onResponseStart.call(this, controller, statusCode, data, statusMessage)
    }

    // Rewrite request as an HTTP1 Proxy request, without tunneling.
    const {
      origin,
      path = '/',
      headers = {}
    } = opts

    opts.path = origin + path

    if (!('host' in headers) && !('Host' in headers)) {
      const { host } = new URL(origin)
      headers.host = host
    }
    opts.headers = { ...this[kProxyHeaders], ...headers }

    // Pin the SNI/cert hostname to the proxy. Without this the underlying
    // Client would derive it from the (rewritten) Host header, which points
    // at the target — wrong for the TLS handshake to the proxy itself.
    if (this.#proxyServername != null) {
      opts.servername = this.#proxyServername
    }

    return this.#client[kDispatch](opts, handler)
  }

  [kClose] () {
    return this.#client.close()
  }

  [kDestroy] (err) {
    return this.#client.destroy(err)
  }
}

class ProxyAgent extends DispatcherBase {
  constructor (opts) {
    if (!opts || (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)) {
      throw new InvalidArgumentError('Proxy uri is mandatory')
    }

    const { clientFactory = defaultFactory } = opts
    if (typeof clientFactory !== 'function') {
      throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
    }

    const { proxyTunnel, connectTimeout } = opts

    super()

    const url = this.#getUrl(opts)
    const { href, origin, port, protocol, username, password, hostname: proxyHostname } = url

    this[kProxy] = { uri: href, protocol }
    this[kRequestTls] = opts.requestTls
    this[kProxyTls] = opts.proxyTls
    this[kProxyHeaders] = opts.headers || {}
    this[kTunnelProxy] = proxyTunnel

    if (opts.auth && opts.token) {
      throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token')
    } else if (opts.auth) {
      /* @deprecated in favour of opts.token */
      this[kProxyHeaders]['proxy-authorization'] = `Basic ${opts.auth}`
    } else if (opts.token) {
      this[kProxyHeaders]['proxy-authorization'] = opts.token
    } else if (username && password) {
      this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
    } else if (username) {
      this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:`).toString('base64')}`
    }

    const connect = buildConnector({ timeout: connectTimeout, ...opts.proxyTls })
    const connectHTTP1 = buildConnector({ timeout: connectTimeout, ...opts.proxyTls, allowH2: false })
    this[kConnectEndpoint] = buildConnector({ timeout: connectTimeout, ...opts.requestTls })
    this[kConnectEndpointHTTP1] = buildConnector({ timeout: connectTimeout, ...opts.requestTls, allowH2: false })

    const agentFactory = opts.factory || defaultAgentFactory
    const factory = (origin, options) => {
      const { protocol } = new URL(origin)

      // Handle SOCKS5 proxy
      if (this[kProxy].protocol === 'socks5:' || this[kProxy].protocol === 'socks:') {
        return new Socks5ProxyAgent(this[kProxy].uri, {
          headers: this[kProxyHeaders],
          connect,
          factory: agentFactory,
          username: opts.username || username,
          password: opts.password || password,
          proxyTls: opts.proxyTls,
          requestTls: opts.requestTls
        })
      }

      if (!shouldProxyTunnel(protocol, this[kTunnelProxy])) {
        const forwardConnect = this[kProxy].protocol === 'https:'
          ? (opts, cb) => connectHTTP1(opts, (err, socket) => {
              if (err && err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
                cb(new SecureProxyConnectionError(err))
              } else {
                cb(err, socket)
              }
            })
          : connectHTTP1
        return new Http1ProxyWrapper(this[kProxy].uri, {
          headers: this[kProxyHeaders],
          connect: forwardConnect,
          factory: agentFactory,
          proxyServername: this[kProxy].protocol === 'https:'
            ? (this[kProxyTls]?.servername || proxyHostname)
            : undefined
        })
      }
      return agentFactory(origin, options)
    }

    // For SOCKS5 proxies, we don't need a client to the proxy itself
    // The SOCKS5 connection is handled within Socks5ProxyAgent
    if (protocol === 'socks5:' || protocol === 'socks:') {
      this[kClient] = null
    } else {
      this[kClient] = clientFactory(url, { connect })
    }

    this[kAgent] = new Agent({
      ...opts,
      factory,
      connect: async (opts, callback) => {
        // SOCKS5 proxies handle their own connections via Socks5ProxyAgent,
        // so this connect function should never be called for them.
        if (!this[kClient]) {
          callback(new InvalidArgumentError('Cannot establish tunnel connection without a proxy client'))
          return
        }

        let requestedPath = opts.host
        if (!opts.port) {
          requestedPath += `:${defaultProtocolPort(opts.protocol)}`
        }
        try {
          const connectParams = {
            origin,
            port,
            path: requestedPath,
            signal: opts.signal,
            headers: {
              ...this[kProxyHeaders],
              host: opts.host,
              ...(opts.connections == null || opts.connections > 0 ? { 'proxy-connection': 'keep-alive' } : {})
            },
            servername: this[kProxyTls]?.servername || proxyHostname
          }
          const { socket, statusCode } = await this[kClient].connect(connectParams)
          if (statusCode !== 200) {
            socket.on('error', noop).destroy()
            callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`))
            return
          }

          if (channels.proxyConnected.hasSubscribers) {
            channels.proxyConnected.publish({
              socket,
              connectParams
            })
          }

          if (opts.protocol !== 'https:') {
            callback(null, socket)
            return
          }
          let servername
          if (this[kRequestTls]) {
            servername = this[kRequestTls].servername
          } else {
            servername = opts.servername
          }
          const connectEndpoint = opts.allowH2 === false
            ? this[kConnectEndpointHTTP1]
            : this[kConnectEndpoint]

          connectEndpoint({ ...opts, servername, httpSocket: socket }, callback)
        } catch (err) {
          if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
            // Throw a custom error to avoid loop in client.js#connect
            callback(new SecureProxyConnectionError(err))
          } else if (err.code === 'UND_ERR_SOCKET') {
            // A socket failure while establishing the tunnel means the CONNECT
            // never completed, so there is nothing to recover - the proxy just
            // tore down the connection. client.js#onError treats UND_ERR_SOCKET
            // as a recoverable error on an established connection and leaves the
            // request queued, which makes connect() retry forever. Surface it as
            // a non-recoverable proxy error so the request fails instead. (#3897)
            callback(new ProxyConnectionError(err))
          } else {
            callback(err)
          }
        }
      }
    })
  }

  dispatch (opts, handler) {
    const headers = buildHeaders(opts.headers)
    throwIfProxyAuthIsSent(headers)

    if (headers && !('host' in headers) && !('Host' in headers)) {
      const { host } = new URL(opts.origin)
      headers.host = host
    }

    return this[kAgent].dispatch(
      {
        ...opts,
        headers
      },
      handler
    )
  }

  /**
   * @param {import('../../types/proxy-agent').ProxyAgent.Options | string | URL} opts
   * @returns {URL}
   */
  #getUrl (opts) {
    if (typeof opts === 'string') {
      return new URL(opts)
    } else if (opts instanceof URL) {
      return opts
    } else {
      return new URL(opts.uri)
    }
  }

  [kClose] () {
    const promises = [this[kAgent].close()]
    if (this[kClient]) {
      promises.push(this[kClient].close())
    }
    return Promise.all(promises)
  }

  [kDestroy] () {
    const promises = [this[kAgent].destroy()]
    if (this[kClient]) {
      promises.push(this[kClient].destroy())
    }
    return Promise.all(promises)
  }
}

/**
 * @param {string[] | Record<string, string>} headers
 * @returns {Record<string, string>}
 */
function buildHeaders (headers) {
  // When using undici.fetch, the headers list is stored
  // as an array.
  if (Array.isArray(headers)) {
    /** @type {Record<string, string>} */
    const headersPair = {}

    for (let i = 0; i < headers.length; i += 2) {
      if (isProxyAuthorizationHeader(headers[i])) {
        throwProxyAuthError()
      }

      headersPair[headers[i]] = headers[i + 1]
    }

    return headersPair
  }

  return headers
}

/**
 * @param {Record<string, string>} headers
 *
 * Previous versions of ProxyAgent suggests the Proxy-Authorization in request headers
 * Nevertheless, it was changed and to avoid a security vulnerability by end users
 * this check was created.
 * It should be removed in the next major version for performance reasons
 */
function throwIfProxyAuthIsSent (headers) {
  for (const key in headers) {
    if (isProxyAuthorizationHeader(key)) {
      throwProxyAuthError()
    }
  }
}

/**
 * @param {string} key
 * @returns {boolean}
 */
function isProxyAuthorizationHeader (key) {
  return key.length === proxyAuthorization.length && key.toLowerCase() === proxyAuthorization
}

function throwProxyAuthError () {
  throw new InvalidArgumentError('Proxy-Authorization should be sent in ProxyAgent constructor')
}

module.exports = ProxyAgent
