'use strict'

const http = require('http')
const https = require('https')
const net = require('net')
const tls = require('tls')
const { once } = require('events')
const { createTimeout, abortRace, urlify, appendPort, cacheAgent } = require('./util')
const { normalizeOptions, cacheOptions } = require('./options')
const { getProxy, getProxyType, isSecureProxy, proxyCache } = require('./proxy.js')
const Errors = require('./errors.js')

const createAgent = (base, name) => {
  const SECURE = base === https
  const SOCKET_TYPE = SECURE ? tls : net

  const agent = class extends base.Agent {
    #options
    #timeouts
    #proxy
    #socket

    constructor (_options) {
      const { timeouts, proxy, noProxy, ...options } = normalizeOptions(_options)

      super(options)

      this.#options = options
      this.#timeouts = timeouts
      this.#proxy = proxy ? { proxies: getProxyType(proxy), proxy: urlify(proxy), noProxy } : null
    }

    get proxy () {
      return this.#proxy ? { url: this.#proxy.proxy } : {}
    }

    #getProxy (options) {
      const proxy = this.#proxy
        ? getProxy(appendPort(`${options.protocol}//${options.host}`, options.port), this.#proxy)
        : null

      if (!proxy) {
        return
      }

      const secure = isSecureProxy(proxy)

      return cacheAgent({
        key: cacheOptions({
          ...options,
          ...this.#options,
          secure,
          timeouts: this.#timeouts,
          proxy,
        }),
        cache: proxyCache,
        secure,
        proxies: this.#proxy.proxies,
      }, proxy, this.#options)
    }

    #setKeepAlive (socket) {
      socket.setKeepAlive(this.keepAlive, this.keepAliveMsecs)
      socket.setNoDelay(this.keepAlive)
    }

    #setIdleTimeout (socket, options) {
      if (this.#timeouts.idle) {
        socket.setTimeout(this.#timeouts.idle, () => {
          socket.destroy(new Errors.IdleTimeoutError(options))
        })
      }
    }

    async #proxyConnect (proxy, request, options) {
      // socks-proxy-agent accepts a dns lookup function
      options.lookup ??= this.#options.lookup

      // all the proxy agents use this secureEndpoint option to determine
      // if the proxy should connect over tls or not. we can set it based
      // on if the HttpAgent or HttpsAgent is used.
      options.secureEndpoint = SECURE

      const socket = await abortRace([
        (ac) => createTimeout(this.#timeouts.connection, ac).catch(() => {
          throw new Errors.ConnectionTimeoutError(options)
        }),
        (ac) => proxy.connect(request, options).then((s) => {
          this.#setKeepAlive(s)

          const connectEvent = SECURE ? 'secureConnect' : 'connect'
          const connectingEvent = SECURE ? 'secureConnecting' : 'connecting'

          if (!s[connectingEvent]) {
            return s
          }

          return abortRace([
            () => once(s, 'error', ac).then((err) => {
              throw err
            }),
            () => once(s, connectEvent, ac).then(() => s),
          ], ac)
        }),
      ])

      this.#setIdleTimeout(socket, options)

      return socket
    }

    async connect (request, options) {
      const proxy = this.#getProxy(options)
      if (proxy) {
        return this.#proxyConnect(proxy, request, options)
      }

      const socket = SOCKET_TYPE.connect(options)

      this.#setKeepAlive(socket)

      await abortRace([
        (s) => createTimeout(this.#timeouts.connection, s).catch(() => {
          throw new Errors.ConnectionTimeoutError(options)
        }),
        (s) => once(socket, 'error', s).then((err) => {
          throw err
        }),
        (s) => once(socket, 'connect', s),
      ])

      this.#setIdleTimeout(socket, options)

      return socket
    }

    addRequest (request, options) {
      const proxy = this.#getProxy(options)
      // it would be better to call proxy.addRequest here but this causes the
      // http-proxy-agent to call its super.addRequest which causes the request
      // to be added to the agent twice. since we only support 3 agents
      // currently (see the required agents in proxy.js) we have manually
      // checked that the only public methods we need to call are called in the
      // next block. this could change in the future and presumably we would get
      // failing tests until we have properly called the necessary methods on
      // each of our proxy agents
      if (proxy?.setRequestProps) {
        proxy.setRequestProps(request, options)
      }

      request.setHeader('connection', this.keepAlive ? 'keep-alive' : 'close')

      const responseTimeout = createTimeout(this.#timeouts.response)
      if (responseTimeout) {
        request.once('finish', () => {
          responseTimeout.start(() => {
            request.destroy(new Errors.ResponseTimeoutError(request, this.proxy?.url))
          })
        })
        request.once('response', () => {
          responseTimeout.clear()
        })
      }

      const transferTimeout = createTimeout(this.#timeouts.transfer)
      if (transferTimeout) {
        request.once('response', (res) => {
          transferTimeout.start(() => {
            res.destroy(new Errors.TransferTimeoutError(request, this.proxy?.url))
          })
          res.once('close', () => {
            transferTimeout.clear()
          })
        })
      }

      return super.addRequest(request, options)
    }

    createSocket (req, options, cb) {
      return Promise.resolve()
        .then(() => this.connect(req, options))
        .then((socket) => {
          this.#socket = socket
          return super.createSocket(req, options, cb)
        }, cb)
    }

    createConnection () {
      return this.#socket
    }
  }

  Object.defineProperty(agent, 'name', { value: name })
  return agent
}

module.exports = {
  HttpAgent: createAgent(http, 'HttpAgent'),
  HttpsAgent: createAgent(https, 'HttpsAgent'),
}
