'use strict'

const net = require('net')
const tls = require('tls')
const { once } = require('events')
const timers = require('timers/promises')
const { normalizeOptions, cacheOptions } = require('./options')
const { getProxy, getProxyAgent, proxyCache } = require('./proxy.js')
const Errors = require('./errors.js')
const { Agent: AgentBase } = require('agent-base')

module.exports = class Agent extends AgentBase {
  #options
  #timeouts
  #proxy
  #noProxy
  #ProxyAgent

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

    super(normalizedOptions)

    this.#options = normalizedOptions
    this.#timeouts = timeouts

    if (proxy) {
      this.#proxy = new URL(proxy)
      this.#noProxy = noProxy
      this.#ProxyAgent = getProxyAgent(proxy)
    }
  }

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

  #getProxy (options) {
    if (!this.#proxy) {
      return
    }

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

    if (!proxy) {
      return
    }

    const cacheKey = cacheOptions({
      ...options,
      ...this.#options,
      timeouts: this.#timeouts,
      proxy,
    })

    if (proxyCache.has(cacheKey)) {
      return proxyCache.get(cacheKey)
    }

    let ProxyAgent = this.#ProxyAgent
    if (Array.isArray(ProxyAgent)) {
      ProxyAgent = this.isSecureEndpoint(options) ? ProxyAgent[1] : ProxyAgent[0]
    }

    const proxyAgent = new ProxyAgent(proxy, {
      ...this.#options,
      socketOptions: { family: this.#options.family },
    })
    proxyCache.set(cacheKey, proxyAgent)

    return proxyAgent
  }

  // takes an array of promises and races them against the connection timeout
  // which will throw the necessary error if it is hit. This will return the
  // result of the promise race.
  async #timeoutConnection ({ promises, options, timeout }, ac = new AbortController()) {
    if (timeout) {
      const connectionTimeout = timers.setTimeout(timeout, null, { signal: ac.signal })
        .then(() => {
          throw new Errors.ConnectionTimeoutError(`${options.host}:${options.port}`)
        }).catch((err) => {
          if (err.name === 'AbortError') {
            return
          }
          throw err
        })
      promises.push(connectionTimeout)
    }

    let result
    try {
      result = await Promise.race(promises)
      ac.abort()
    } catch (err) {
      ac.abort()
      throw err
    }
    return result
  }

  async connect (request, options) {
    // if the connection does not have its own lookup function
    // set, then use the one from our options
    options.lookup ??= this.#options.lookup

    let socket
    let timeout = this.#timeouts.connection
    const isSecureEndpoint = this.isSecureEndpoint(options)

    const proxy = this.#getProxy(options)
    if (proxy) {
      // some of the proxies will wait for the socket to fully connect before
      // returning so we have to await this while also racing it against the
      // connection timeout.
      const start = Date.now()
      socket = await this.#timeoutConnection({
        options,
        timeout,
        promises: [proxy.connect(request, options)],
      })
      // see how much time proxy.connect took and subtract it from
      // the timeout
      if (timeout) {
        timeout = timeout - (Date.now() - start)
      }
    } else {
      socket = (isSecureEndpoint ? tls : net).connect(options)
    }

    socket.setKeepAlive(this.keepAlive, this.keepAliveMsecs)
    socket.setNoDelay(this.keepAlive)

    const abortController = new AbortController()
    const { signal } = abortController

    const connectPromise = socket[isSecureEndpoint ? 'secureConnecting' : 'connecting']
      ? once(socket, isSecureEndpoint ? 'secureConnect' : 'connect', { signal })
      : Promise.resolve()

    await this.#timeoutConnection({
      options,
      timeout,
      promises: [
        connectPromise,
        once(socket, 'error', { signal }).then((err) => {
          throw err[0]
        }),
      ],
    }, abortController)

    if (this.#timeouts.idle) {
      socket.setTimeout(this.#timeouts.idle, () => {
        socket.destroy(new Errors.IdleTimeoutError(`${options.host}:${options.port}`))
      })
    }

    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')

    if (this.#timeouts.response) {
      let responseTimeout
      request.once('finish', () => {
        setTimeout(() => {
          request.destroy(new Errors.ResponseTimeoutError(request, this.#proxy))
        }, this.#timeouts.response)
      })
      request.once('response', () => {
        clearTimeout(responseTimeout)
      })
    }

    if (this.#timeouts.transfer) {
      let transferTimeout
      request.once('response', (res) => {
        setTimeout(() => {
          res.destroy(new Errors.TransferTimeoutError(request, this.#proxy))
        }, this.#timeouts.transfer)
        res.once('close', () => {
          clearTimeout(transferTimeout)
        })
      })
    }

    return super.addRequest(request, options)
  }

  // When connect() rejects, agent-base removes only its placeholder socket, so Node never drains this.requests[name] and requests queued past maxSockets hang forever.
  // On a failure we dispatch the next queued request ourselves.
  // See npm/cli#9386 and TooTallNate/proxy-agents#427.
  createSocket (req, options, cb) {
    super.createSocket(req, options, (err, socket) => {
      if (err) {
        this.#drainPendingRequests(req, options)
      }
      cb(err, socket)
    })
  }

  // Dispatch the next request queued behind maxSockets, reusing the slot the failed connection freed.
  #drainPendingRequests (failedReq, options) {
    const name = this.getName(options)
    const queue = this.requests[name]
    if (!queue || queue.length === 0) {
      return
    }

    // Node's removeSocket() picks a queued request without shifting it off, so drop the failed one to avoid dispatching it twice.
    const failedIndex = queue.indexOf(failedReq)
    if (failedIndex !== -1) {
      queue.splice(failedIndex, 1)
    }
    if (queue.length === 0) {
      delete this.requests[name]
      return
    }

    // Safety belt: only dispatch if a socket slot is genuinely free.
    const socketCount = this.sockets[name] ? this.sockets[name].length : 0
    if (socketCount >= this.maxSockets || this.totalSocketCount >= this.maxTotalSockets) {
      return
    }

    const nextReq = queue.shift()
    if (queue.length === 0) {
      delete this.requests[name]
    }

    // All queued requests share this origin, so the failed request's options suit the next one.
    // createSocket() recurses here if this connection also fails, draining the whole queue.
    this.createSocket(nextReq, options, (err, socket) => {
      if (err) {
        nextReq.onSocket(null, err)
      } else {
        nextReq.onSocket(socket)
      }
    })
  }
}
