'use strict'

const net = require('node:net')
const assert = require('node:assert')
const util = require('./util')
const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')

let tls // include tls conditionally since it is not always available

// TODO: session re-use does not wait for the first
// connection to resolve the session and might therefore
// resolve the same servername multiple times even when
// re-use is enabled.

const SessionCache = class WeakSessionCache {
  constructor (maxCachedSessions) {
    this._maxCachedSessions = maxCachedSessions
    this._sessionCache = new Map()
    this._sessionRegistry = new FinalizationRegistry((key) => {
      if (this._sessionCache.size < this._maxCachedSessions) {
        return
      }

      const ref = this._sessionCache.get(key)
      if (ref !== undefined && ref.deref() === undefined) {
        this._sessionCache.delete(key)
      }
    })
  }

  get (sessionKey) {
    const ref = this._sessionCache.get(sessionKey)
    return ref ? ref.deref() : null
  }

  set (sessionKey, session) {
    if (this._maxCachedSessions === 0) {
      return
    }

    if (this._sessionCache.has(sessionKey)) {
      this._sessionCache.delete(sessionKey)
    } else if (this._sessionCache.size >= this._maxCachedSessions) {
      for (const [key, ref] of this._sessionCache) {
        if (ref.deref() === undefined) {
          this._sessionCache.delete(key)
          return
        }
      }

      const oldest = this._sessionCache.keys().next()
      if (!oldest.done) {
        this._sessionCache.delete(oldest.value)
      }
    }

    this._sessionCache.set(sessionKey, new WeakRef(session))
    this._sessionRegistry.register(session, sessionKey)
  }
}

function buildConnector ({ allowH2, preferH2, useH2c, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
  if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
    throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
  }

  const options = { path: socketPath, ...opts }
  const sessionCache = new SessionCache(maxCachedSessions == null ? 100 : maxCachedSessions)
  timeout = timeout == null ? 10e3 : timeout
  allowH2 = allowH2 != null ? allowH2 : true
  return function connect ({ hostname, host, protocol, port, servername, localAddress, httpSocket }, callback) {
    let socket
    if (protocol === 'https:') {
      if (!tls) {
        tls = require('node:tls')
      }
      servername = servername || options.servername || util.getServerName(host) || null

      const sessionKey = servername || hostname
      assert(sessionKey)

      const session = customSession || sessionCache.get(sessionKey) || null

      port = port || 443

      socket = tls.connect({
        highWaterMark: 16384, // TLS in node can't have bigger HWM anyway...
        ...options,
        servername,
        session,
        localAddress,
        ALPNProtocols: allowH2 ? (preferH2 ? ['h2', 'http/1.1'] : ['http/1.1', 'h2']) : ['http/1.1'],
        socket: httpSocket, // upgrade socket connection
        port,
        host: hostname
      })

      socket
        .on('session', function (session) {
          // TODO (fix): Can a session become invalid once established? Don't think so?
          sessionCache.set(sessionKey, session)
        })
    } else {
      assert(!httpSocket, 'httpSocket can only be sent on TLS update')

      port = port || 80

      socket = net.connect({
        highWaterMark: 64 * 1024, // Same as nodejs fs streams.
        ...options,
        localAddress,
        port,
        host: hostname
      })
      if (useH2c === true) {
        socket.alpnProtocol = 'h2'
      }
    }

    // Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket
    if (options.keepAlive == null || options.keepAlive) {
      const keepAliveInitialDelay = options.keepAliveInitialDelay === undefined ? 60e3 : options.keepAliveInitialDelay
      socket.setKeepAlive(true, keepAliveInitialDelay)
    }

    const clearConnectTimeout = util.setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })

    socket
      .setNoDelay(true)
      .once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
        queueMicrotask(clearConnectTimeout)

        if (callback) {
          const cb = callback
          callback = null
          cb(null, this)
        }
      })
      .on('error', function (err) {
        queueMicrotask(clearConnectTimeout)

        if (callback) {
          const cb = callback
          callback = null
          cb(maybeNormalizeConnectError(err, this, { timeout, hostname, port }))
        }
      })

    return socket
  }
}

// `net.connect` with `autoSelectFamily` raises an `AggregateError` when every
// attempted address fails. If any of those failures is a timeout, surface the
// error as a `ConnectTimeoutError` so callers see the same error regardless of
// which timer (Node's internal one or undici's `connectTimeout`) wins the race.
// The original `AggregateError` is preserved on `.cause`.
function maybeNormalizeConnectError (err, socket, opts) {
  if (
    err instanceof AggregateError &&
    (err.code === 'ETIMEDOUT' || err.errors.some((e) => e != null && e.code === 'ETIMEDOUT'))
  ) {
    let message = 'Connect Timeout Error'
    if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
      message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
    } else {
      message += ` (attempted address: ${opts.hostname}:${opts.port},`
    }
    message += ` timeout: ${opts.timeout}ms)`

    const wrapped = new ConnectTimeoutError(message)
    wrapped.cause = err
    return wrapped
  }
  return err
}

module.exports = buildConnector
