@ 261107.5
current
'use strict'

const util = require('../core/util')
const { kRedirectCacheControl } = require('../core/symbols')
const {
  parseCacheControlHeader,
  hasInvalidCacheControlDirective,
  parseVaryHeader,
  hasVaryStar,
  isInvalidOrWildcardVaryHeader,
  isEtagUsable
} = require('../util/cache')

/**
 * A suppressed redirect hop recorded by the redirect handler. Carried on
 * {@link kRedirectCacheControl} and inspected by the cache handler before the
 * final response is stored under the original request key.
 *
 * @typedef {object} RedirectHop
 * @property {number} statusCode The hop's response status code (e.g. 302).
 * @property {import('../../types/header.d.ts').IncomingHttpHeaders} headers The hop's response headers.
 * @property {number} receivedAt The time the hop's response headers were received.
 */
const { parseHttpDate } = require('../util/date.js')

function noop () {}

class CacheController {
  #blocked = false
  #downstreamPaused = false

  constructor (target) {
    this.target = target
  }

  block () {
    this.#blocked = true
  }

  release () {
    this.#blocked = false
    if (!this.#downstreamPaused) {
      this.target.resume()
    }
  }

  pause () {
    this.#downstreamPaused = true
    this.target.pause()
  }

  resume () {
    this.#downstreamPaused = false
    if (!this.#blocked) {
      this.target.resume()
    }
  }

  abort (reason) {
    this.target.abort(reason)
  }

  get paused () { return this.#downstreamPaused }
  get aborted () { return this.target.aborted }
  get reason () { return this.target.reason }
  get rawHeaders () { return this.target.rawHeaders }
  set rawHeaders (value) { this.target.rawHeaders = value }
  get rawTrailers () { return this.target.rawTrailers }
  set rawTrailers (value) { this.target.rawTrailers = value }
}

/**
 * @param {string | URL} a
 * @param {string | URL} b
 * @returns {boolean}
 */
function sameOrigin (a, b) {
  try {
    return new URL(String(a)).origin === new URL(String(b)).origin
  } catch {
    return String(a) === String(b)
  }
}

// Status codes that we can use some heuristics on to cache
const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
  200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
]

// Status codes which semantic is not handled by the cache
// https://datatracker.ietf.org/doc/html/rfc9111#section-3
// This list should not grow beyond 206 unless the RFC is updated
// by a newer one including more. Please introduce another list if
// implementing caching of responses with the 'must-understand' directive.
const NOT_UNDERSTOOD_STATUS_CODES = [
  206
]

const MAX_RESPONSE_AGE = 2147483647000

// Retention for revalidation-only entries (zero freshness lifetime but a
// validator present); each successful revalidation re-stores the entry.
const REVALIDATION_ONLY_RETENTION = 86400000 // 24 hours

function trimOWS (value) {
  return value.replace(/^[\t ]+|[\t ]+$/g, '')
}

function arrayIncludes (array, value) {
  for (let i = 0; i < array.length; i++) {
    if (array[i] === value) {
      return true
    }
  }

  return false
}

function appendVaryValues (target, varyValue) {
  if (Array.isArray(varyValue)) {
    for (let i = 0; i < varyValue.length; i++) {
      target.push(varyValue[i])
    }
  } else if (varyValue != null) {
    target.push(varyValue)
  }
}

function appendConnectionHeaderTokens (headersToRemove, connectionHeader) {
  const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader]

  for (let i = 0; i < values.length; i++) {
    const tokens = values[i].split(',')
    for (let j = 0; j < tokens.length; j++) {
      headersToRemove.push(trimOWS(tokens[j]).toLowerCase())
    }
  }
}

function getSameOriginPath (cacheKey, location) {
  if (typeof location !== 'string') {
    return undefined
  }

  let originUrl
  let requestUrl
  let locationUrl
  try {
    originUrl = new URL(cacheKey.origin)
    requestUrl = new URL(cacheKey.path, originUrl)
    locationUrl = new URL(location, requestUrl)
  } catch {
    return undefined
  }

  if (locationUrl.origin !== originUrl.origin) {
    return undefined
  }

  return locationUrl.pathname + locationUrl.search
}

function deleteCachedUri (store, cacheKey, path) {
  deleteCachedValue(store, {
    ...cacheKey,
    path
  })

  for (let i = 0; i < util.safeHTTPMethods.length; i++) {
    const method = util.safeHTTPMethods[i]
    if (method !== cacheKey.method) {
      deleteCachedValue(store, {
        ...cacheKey,
        method,
        path
      })
    }
  }
}

function deleteLocationTargets (store, cacheKey, headerValue) {
  if (headerValue === undefined) {
    return
  }

  const values = Array.isArray(headerValue) ? headerValue : [headerValue]
  for (let i = 0; i < values.length; i++) {
    const path = getSameOriginPath(cacheKey, values[i])
    if (path !== undefined) {
      deleteCachedUri(store, cacheKey, path)
    }
  }
}

function invalidateUnsafeRequest (store, cacheKey, resHeaders) {
  deleteCachedUri(store, cacheKey, cacheKey.path)
  deleteLocationTargets(store, cacheKey, resHeaders.location)
  deleteLocationTargets(store, cacheKey, resHeaders['content-location'])
}

/**
 * Whether the original request carried a credential that is not reflected in
 * the cache key. The memory/sqlite stores key an entry on origin, method, path
 * and `vary` only, so a credentialed request that is redirected to a resource
 * that varies per principal must not be stored under the original (alias) key:
 * a later caller would be served the first principal's body. The set mirrors the
 * request credentials the redirect handler strips across origins (`authorization`,
 * `cookie`, `proxy-authorization`).
 *
 * @param {import('../../types/header.d.ts').IncomingHttpHeaders | undefined} headers
 * @returns {boolean}
 */
function requestCarriesCredentials (headers) {
  return headers != null && (
    Object.hasOwn(headers, 'authorization') ||
    Object.hasOwn(headers, 'cookie') ||
    Object.hasOwn(headers, 'proxy-authorization')
  )
}
/**
 * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
 *
 * @implements {DispatchHandler}
 */
class CacheHandler {
  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
   */
  #cacheKey

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']}
   */
  #cacheType

  /**
   * @type {number | undefined}
   */
  #cacheByDefault

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}
   */
  #store

  /**
   * @type {import('../../types/dispatcher.d.ts').default.DispatchHandler}
   */
  #handler

  /**
   * @type {CacheController | undefined}
   */
  #downstreamController

  /**
   * @type {import('node:stream').Writable | undefined}
   */
  #writeStream

  /**
   * The URLs visited while following redirects, shared by reference from the
   * redirect handler through the request context. Carries the suppressed hops'
   * status/headers under {@link kRedirectCacheControl}.
   * @type {URL[] | undefined}
   */
  #history

  /**
   * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts
   * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
   * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
   */
  constructor ({ store, type, cacheByDefault }, cacheKey, handler) {
    this.#store = store
    this.#cacheType = type
    this.#cacheByDefault = cacheByDefault
    this.#cacheKey = cacheKey
    this.#handler = handler
  }

  onRequestStart (controller, context) {
    this.#writeStream?.destroy()
    this.#writeStream = undefined
    this.#downstreamController = new CacheController(controller)
    // The redirect handler threads the list of visited URLs through the
    // handler-chain context. Keep a reference to it (it is mutated as redirects
    // are followed) so onResponseStart can tell which request actually produced
    // the response. Unlike the request options object, this reference is not
    // broken by an interceptor that copies the options between the cache and
    // the redirect.
    this.#history = context?.history
    this.#handler.onRequestStart?.(this.#downstreamController, context)
  }

  onBodySent (chunk) {
    this.#handler.onBodySent?.(chunk)
  }

  onRequestSent () {
    this.#handler.onRequestSent?.()
  }

  onRequestUpgrade (_controller, statusCode, headers, socket) {
    this.#handler.onRequestUpgrade?.(this.#downstreamController, statusCode, headers, socket)
  }

  /**
   * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
   * @param {number} statusCode
   * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
   * @param {string} statusMessage
   */
  onResponseStart (
    controller,
    statusCode,
    resHeaders,
    statusMessage
  ) {
    const downstreamOnHeaders = () =>
      this.#handler.onResponseStart?.(
        this.#downstreamController,
        statusCode,
        resHeaders,
        statusMessage
      )
    const handler = this

    if (
      !arrayIncludes(util.safeHTTPMethods, this.#cacheKey.method) &&
      statusCode >= 200 &&
      statusCode <= 399
    ) {
      // Successful response to an unsafe method, delete it from cache
      //  https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response
      invalidateUnsafeRequest(this.#store, this.#cacheKey, resHeaders)
      return downstreamOnHeaders()
    }

    // A credential-bearing request that was redirected must not have the final
    // hop's response stored under the original request's key. The cache key
    // does not include the credential, and the redirect target may vary per
    // principal (e.g. `/me` -> `/profile/{user}`), so replaying the body under
    // the alias key would leak one principal's data to another — or to an
    // unauthenticated caller — from a shared cache. The final hop's permissive
    // cache-control (e.g. `public`) does not authorize this: the credential was
    // presented to the original hop, not the resource that actually produced
    // the body. Fail closed. A history with more than one entry means at least
    // one redirect was actually followed (a single non-redirected hop records
    // exactly one entry), so a plain revalidation is not mistaken for a redirect.
    if (
      this.#history && this.#history.length > 1 &&
      requestCarriesCredentials(this.#cacheKey.headers)
    ) {
      return downstreamOnHeaders()
    }

    // A cross-origin redirect can change the origin that actually produced the
    // final response. Keying that response on the original (trusted) origin
    // would let a redirect target control an entry for a URL the application
    // trusts. Every hop in the redirect chain must stay on the cache key's
    // origin. An intermediate cross-origin hop can bounce back to the trusted
    // origin (or to any cacheable asset there) even when the final response
    // comes from the trusted origin, so checking only the final URL is not
    // enough.
    if (this.#history && this.#history.length > 0) {
      for (const hopUrl of this.#history) {
        if (!sameOrigin(hopUrl.origin, this.#cacheKey.origin)) {
          return downstreamOnHeaders()
        }
      }
    }

    // A redirect hop that is not forwarded can still carry a storage policy
    // (for example `no-store`, `private` for a shared cache, `no-cache`,
    // `must-revalidate`, zero freshness, or `Vary: *`) that the final response
    // body does not inherit. Honor it, otherwise a redirect that must not be
    // stored (or that selects a private/authenticated target) could be
    // laundered into a cacheable final response under the original key.
    const now = Date.now()
    const redirectHopInfo = this.#history?.[kRedirectCacheControl]
    const redirectPolicy = redirectHopInfo?.length > 0
      ? getRedirectPolicy(
        this.#cacheType,
        this.#cacheKey.method,
        redirectHopInfo,
        this.#cacheKey.headers,
        this.#cacheByDefault,
        now
      )
      : undefined
    if (redirectHopInfo?.length > 0 && redirectPolicy === undefined) {
      return downstreamOnHeaders()
    }

    const cacheControlHeader = resHeaders['cache-control']
    const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}

    if (revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives)) {
      deleteCachedValue(this.#store, this.#cacheKey)
      return downstreamOnHeaders()
    }

    const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode)
    if (
      !cacheControlHeader &&
      !resHeaders['expires'] &&
      !heuristicallyCacheable &&
      !this.#cacheByDefault
    ) {
      if (statusCode === 304 && resHeaders.vary && isInvalidOrWildcardVaryHeader(resHeaders.vary)) {
        deleteCachedValue(this.#store, this.#cacheKey)
      }

      // Don't have anything to tell us this response is cacheable and we're not
      //  caching by default
      return downstreamOnHeaders()
    }

    if (!canCacheResponse(this.#cacheType, this.#cacheKey.method, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
      if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) {
        deleteCachedValue(this.#store, this.#cacheKey)
      }

      return downstreamOnHeaders()
    }

    const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined
    if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) {
      // Response considered stale
      deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
      return downstreamOnHeaders()
    }

    const resDate = Object.hasOwn(resHeaders, 'date') ? getDate(resHeaders.date) : undefined
    if (resDate === null) {
      deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
      return downstreamOnHeaders()
    }

    const apparentAge = resDate ? Math.max(0, now - resDate.getTime()) : 0
    const currentAge = Math.max(apparentAge, resAge ?? 0)

    const hasValidator =
      (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) ||
      typeof resHeaders['last-modified'] === 'string'

    const staleAt =
      determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives, hasValidator) ??
      this.#cacheByDefault
    // Zero freshness lifetime but a validator: stale from the start, yet still
    // storable since each reuse is preceded by a revalidation request.
    // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4
    const revalidationOnly = staleAt === 0 && hasValidator
    if (staleAt === undefined || (currentAge >= staleAt && !revalidationOnly)) {
      if (cacheControlHeader || staleAt !== undefined) {
        deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
      }

      return downstreamOnHeaders()
    }

    const baseTime = now - currentAge
    let absoluteStaleAt = staleAt + baseTime
    if (redirectPolicy !== undefined) {
      absoluteStaleAt = Math.min(absoluteStaleAt, redirectPolicy.staleAt)
    }
    if (now >= absoluteStaleAt && !revalidationOnly) {
      // Response is already stale
      deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
      return downstreamOnHeaders()
    }

    // Union the `Vary` of every suppressed redirect hop with the final
    // response's `Vary`. A 3xx hop that negotiates its target on `Authorization`
    // / `Cookie` (or any other header) must make the stored entry vary on those
    // headers too, or an authenticated user's private response would be served
    // to unrelated callers.
    let varyDirectives
    const combinedVary = []
    if (redirectHopInfo) {
      for (const hop of redirectHopInfo) {
        appendVaryValues(combinedVary, hop?.headers?.vary)
      }
    }
    appendVaryValues(combinedVary, resHeaders.vary)
    if (combinedVary.length > 0) {
      for (const varyHeader of combinedVary) {
        if (hasVaryStar(varyHeader)) {
          // `Vary: *` means the response can never be reused (RFC 9111 §4.1)
          return downstreamOnHeaders()
        }
      }
      if (this.#cacheKey.headers) {
        varyDirectives = parseVaryHeader(combinedVary, this.#cacheKey.headers)
        if (!varyDirectives) {
          // Parse error
          return downstreamOnHeaders()
        }
      }
    }

    // When the entry was folded from a redirect chain, the final response's
    // stale-serving directives (RFC 5861) must not outlive the hops' freshness.
    // A hop that grants no `stale-while-revalidate` / `stale-if-error` caps
    // stale serving at its freshness, so the final response cannot launder a
    // stale-serving window past the hop-bounded `staleAt`. Drop those directives
    // (and keep the `proxy-revalidate` the hop policy may require) so the entry
    // revalidates at `staleAt` instead of being served stale for the final
    // response's window.
    let effectiveCacheControlDirectives = cacheControlDirectives
    if (redirectPolicy !== undefined) {
      effectiveCacheControlDirectives = { ...cacheControlDirectives }
      delete effectiveCacheControlDirectives['stale-while-revalidate']
      delete effectiveCacheControlDirectives['stale-if-error']
      if (redirectPolicy.requiresRevalidation === true) {
        effectiveCacheControlDirectives['proxy-revalidate'] = true
      }
    }

    const cachedAt = baseTime
    const deleteAt = determineDeleteAt(baseTime, now, effectiveCacheControlDirectives, absoluteStaleAt)
    const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives)

    /**
     * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
     */
    const value = {
      statusCode,
      statusMessage,
      headers: strippedHeaders,
      vary: varyDirectives,
      cacheControlDirectives: effectiveCacheControlDirectives,
      cachedAt,
      staleAt: absoluteStaleAt,
      deleteAt
    }

    // Not modified, re-use the cached value
    // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified
    if (statusCode === 304) {
      let done = false
      const finish304 = () => {
        if (done) {
          return
        }
        done = true
        this.#downstreamController.release()
      }

      // The cached body below is replayed to the downstream handler from later
      // stream ticks (or an iterator loop), outside the dispatcher's own error
      // handling, so a throwing downstream handler must be converged to a
      // per-request error instead of escaping as an uncaught exception.
      const failReplay = (err) => {
        if (done) {
          return
        }
        done = true
        // Abort the revalidation request: this releases the paused origin
        // response (freeing the connection) and routes the error through
        // CacheHandler.onResponseError, which destroys the cache write stream
        // and forwards the error downstream exactly once.
        controller.abort(err)
      }

      const handle304 = (cachedValue) => {
        if (!cachedValue) {
          // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached.
          downstreamOnHeaders()
          finish304()
          return
        }

        // Re-use the cached value: statuscode, statusmessage, headers and body
        value.statusCode = cachedValue.statusCode
        value.statusMessage = cachedValue.statusMessage
        value.etag = cachedValue.etag
        value.vary = varyDirectives ?? cachedValue.vary
        value.headers = { ...cachedValue.headers, ...strippedHeaders }

        downstreamOnHeaders()

        this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)

        if (!this.#writeStream || !cachedValue?.body) {
          finish304()
          return
        }

        if (typeof cachedValue.body.values === 'function') {
          const bodyIterator = cachedValue.body.values()

          const streamCachedBody = () => {
            if (done) {
              return
            }
            try {
              for (const chunk of bodyIterator) {
                const full = this.#writeStream?.write(chunk) === false
                this.#handler.onResponseData?.(this.#downstreamController, chunk)
                // when stream is full stop writing until we get a 'drain' event
                if (full) {
                  return
                }
              }
              finish304()
            } catch (err) {
              failReplay(err)
            }
          }

          this.#writeStream
            .on('error', function () {
              handler.#writeStream = undefined
              handler.#store.delete(handler.#cacheKey)
              // Keep replaying downstream without the store
              streamCachedBody()
            })
            .on('drain', () => {
              streamCachedBody()
            })
            .on('close', function () {
              if (handler.#writeStream === this) {
                handler.#writeStream = undefined
                // Keep replaying downstream without the store
                streamCachedBody()
              }
            })

          streamCachedBody()
        } else if (typeof cachedValue.body.on === 'function') {
          // Readable stream body (e.g. from async/remote cache stores)
          cachedValue.body
            .on('data', (chunk) => {
              try {
                this.#writeStream?.write(chunk)
                this.#handler.onResponseData?.(this.#downstreamController, chunk)
              } catch (err) {
                cachedValue.body.destroy()
                failReplay(err)
              }
            })
            .on('end', () => {
              this.#writeStream?.end()
              finish304()
            })
            .on('error', () => {
              this.#writeStream = undefined
              this.#store.delete(this.#cacheKey)
              finish304()
            })

          this.#writeStream
            .on('error', function () {
              handler.#writeStream = undefined
              handler.#store.delete(handler.#cacheKey)
            })
            .on('close', function () {
              if (handler.#writeStream === this) {
                handler.#writeStream = undefined
              }
            })
        } else {
          finish304()
        }
      }

      /**
       * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
       */
      const result = this.#store.get(this.#cacheKey)
      // A 304 ends before the cached body is replayed. Keep the origin
      // response paused until replay is complete.
      this.#downstreamController.block()
      controller.pause()
      if (result && typeof result.then === 'function') {
        result.then(handle304, () => handle304(undefined)).catch((err) => {
          controller.abort(err)
        })
      } else {
        handle304(result)
      }
    } else {
      if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
        value.etag = resHeaders.etag
      }

      this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)

      if (!this.#writeStream) {
        return downstreamOnHeaders()
      }

      this.#writeStream
        .on('drain', () => controller.resume())
        .on('error', function () {
          // TODO (fix): Make error somehow observable?
          handler.#writeStream = undefined

          // Delete the value in case the cache store is holding onto state from
          //  the call to createWriteStream
          handler.#store.delete(handler.#cacheKey)
        })
        .on('close', function () {
          if (handler.#writeStream === this) {
            handler.#writeStream = undefined
          }

          // TODO (fix): Should we resume even if was paused downstream?
          controller.resume()
        })

      downstreamOnHeaders()
    }
  }

  onResponseData (controller, chunk) {
    if (this.#writeStream?.write(chunk) === false) {
      controller.pause()
    }

    this.#handler.onResponseData?.(this.#downstreamController, chunk)
  }

  onResponseEnd (controller, trailers) {
    this.#writeStream?.end()
    this.#handler.onResponseEnd?.(this.#downstreamController, trailers)
  }

  onResponseError (controller, err) {
    this.#writeStream?.destroy(err)
    this.#writeStream = undefined
    this.#handler.onResponseError?.(this.#downstreamController, err)
  }
}

/**
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
 */
function deleteCachedValue (store, cacheKey) {
  try {
    store.delete(cacheKey)?.catch?.(noop)
  } catch {
    // Fail silently
  }
}

function deleteCachedValueIfNotModified (statusCode, store, cacheKey) {
  if (statusCode === 304) {
    deleteCachedValue(store, cacheKey)
  }
}

/**
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
 * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
 * @returns {boolean}
 */
function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) {
  return cacheControlDirectives['no-store'] === true ||
    (cacheType === 'shared' && (
      cacheControlDirectives.private === true ||
      Object.hasOwn(resHeaders, 'set-cookie')
    )) ||
    (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false)
}

/**
 * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
 *
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
 * @param {string} method
 * @param {number} statusCode
 * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
 * @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders]
 */
function canCacheResponse (cacheType, method, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
  if (!arrayIncludes(util.safeHTTPMethods, method)) {
    return false
  }
  // Status code must be final and understood.
  if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) {
    return false
  }
  // Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching
  // directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3
  if (!arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) && !resHeaders['expires'] &&
    !cacheControlDirectives.public &&
    cacheControlDirectives['max-age'] === undefined &&
    // RFC 9111: a private response directive, if the cache is not shared
    !(cacheControlDirectives.private && cacheType === 'private') &&
    !(cacheControlDirectives['s-maxage'] !== undefined && cacheType === 'shared')
  ) {
    return false
  }

  if (cacheControlDirectives['no-store']) {
    return false
  }

  if (cacheType === 'shared' && (
    cacheControlDirectives.private === true ||
    Object.hasOwn(resHeaders, 'set-cookie')
  )) {
    return false
  }

  // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
  if (resHeaders.vary && hasVaryStar(resHeaders.vary)) {
    return false
  }

  // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
  if (reqHeaders != null && Object.hasOwn(reqHeaders, 'authorization')) {
    if (
      !cacheControlDirectives.public &&
      !cacheControlDirectives['s-maxage'] &&
      !cacheControlDirectives['must-revalidate']
    ) {
      return false
    }

    if (typeof reqHeaders.authorization !== 'string') {
      return false
    }

    if (
      Array.isArray(cacheControlDirectives['no-cache']) &&
      arrayIncludes(cacheControlDirectives['no-cache'], 'authorization')
    ) {
      return false
    }

    if (
      Array.isArray(cacheControlDirectives['private']) &&
      arrayIncludes(cacheControlDirectives['private'], 'authorization')
    ) {
      return false
    }
  }

  return true
}

/**
 * Determine the absolute freshness deadline imposed by all suppressed redirect
 * hops. Returning `undefined` means at least one hop cannot safely be folded
 * into the final response under the original request key.
 *
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
 * @param {string} method
 * @param {RedirectHop[]} redirectHops
 * @param {import('../../types/header.d.ts').IncomingHttpHeaders} reqHeaders
 * @param {number | undefined} cacheByDefault
 * @param {number} now
 * @returns {{ staleAt: number, requiresRevalidation: boolean } | undefined}
 */
function getRedirectPolicy (cacheType, method, redirectHops, reqHeaders, cacheByDefault, now) {
  let staleAt = Infinity
  let requiresRevalidation = false

  for (const hop of redirectHops) {
    const hopPolicy = getRedirectHopPolicy(cacheType, method, hop, reqHeaders, cacheByDefault)
    if (hopPolicy === undefined || hopPolicy.staleAt <= now) {
      return undefined
    }

    staleAt = Math.min(staleAt, hopPolicy.staleAt)
    requiresRevalidation ||= hopPolicy.requiresRevalidation
  }

  return { staleAt, requiresRevalidation }
}

/**
 * Calculate one redirect hop's absolute freshness deadline using the same age,
 * date, expiration, invalid-directive, and heuristic rules as a final response.
 *
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
 * @param {string} method
 * @param {RedirectHop} hop
 * @param {import('../../types/header.d.ts').IncomingHttpHeaders} reqHeaders
 * @param {number | undefined} cacheByDefault
 * @returns {{ staleAt: number, requiresRevalidation: boolean } | undefined}
 */
function getRedirectHopPolicy (cacheType, method, hop, reqHeaders, cacheByDefault) {
  if (!Number.isInteger(hop?.statusCode) || !Number.isFinite(hop?.receivedAt)) {
    return undefined
  }

  const hopHeaders = hop.headers ?? {}
  const cacheControlHeader = hopHeaders['cache-control']
  const hopCacheControl = cacheControlHeader
    ? parseCacheControlHeader(cacheControlHeader)
    : {}

  // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
  if (hopHeaders.vary && hasVaryStar(hopHeaders.vary)) {
    return undefined
  }

  if (hopCacheControl['no-store'] === true) {
    return undefined
  }

  // A qualified `private` directive still marks the named fields as private.
  // Folding a redirect that says `private="location"` would preserve the
  // private target choice in the final body even though a shared cache is not
  // allowed to store that Location field.
  if (cacheType === 'shared' && Object.hasOwn(hopCacheControl, 'private')) {
    return undefined
  }

  // These directives require the hop to be revalidated before reuse. Since the
  // cache stores only the final response, it cannot independently validate the
  // suppressed redirect response.
  if (
    Object.hasOwn(hopCacheControl, 'no-cache') ||
    hopCacheControl['must-revalidate'] === true ||
    (cacheType === 'shared' && hopCacheControl['proxy-revalidate'] === true)
  ) {
    return undefined
  }

  const heuristicallyCacheable =
    hopHeaders['last-modified'] &&
    arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, hop.statusCode)
  if (
    !cacheControlHeader &&
    !hopHeaders.expires &&
    !heuristicallyCacheable &&
    !cacheByDefault
  ) {
    return undefined
  }

  if (!canCacheResponse(cacheType, method, hop.statusCode, hopHeaders, hopCacheControl, reqHeaders)) {
    return undefined
  }

  const age = Object.hasOwn(hopHeaders, 'age') ? getAge(hopHeaders.age) : undefined
  if (age !== undefined && age >= MAX_RESPONSE_AGE) {
    return undefined
  }

  const responseDate = Object.hasOwn(hopHeaders, 'date') ? getDate(hopHeaders.date) : undefined
  if (responseDate === null) {
    return undefined
  }

  const apparentAge = responseDate ? Math.max(0, hop.receivedAt - responseDate.getTime()) : 0
  const currentAge = Math.max(apparentAge, age ?? 0)
  const hasValidator =
    (typeof hopHeaders.etag === 'string' && isEtagUsable(hopHeaders.etag)) ||
    typeof hopHeaders['last-modified'] === 'string'
  const freshnessLifetime =
    determineStaleAt(
      cacheType,
      hop.receivedAt,
      age,
      hopHeaders,
      responseDate,
      hopCacheControl,
      hasValidator
    ) ?? cacheByDefault

  if (freshnessLifetime === undefined) {
    return undefined
  }

  return {
    staleAt: hop.receivedAt - currentAge + freshnessLifetime,
    // RFC 9111 §5.2.2.10: s-maxage implies proxy-revalidate for shared caches.
    requiresRevalidation: cacheType === 'shared' && Object.hasOwn(hopCacheControl, 's-maxage')
  }
}

/**
 * @param {string | string[]} dateHeader
 * @returns {Date | null | undefined}
 */
function getDate (dateHeader) {
  let dateValue = dateHeader
  if (Array.isArray(dateValue)) {
    if (dateValue.length !== 1) {
      return null
    }

    dateValue = dateValue[0]
  }

  if (typeof dateValue !== 'string') {
    return null
  }

  return parseHttpDate(dateValue)
}

/**
 * @param {string | string[]} ageHeader
 * @returns {number | undefined}
 */
function getAge (ageHeader) {
  let ageValue = ageHeader
  if (Array.isArray(ageValue)) {
    if (ageValue.length !== 1) {
      return MAX_RESPONSE_AGE
    }

    ageValue = ageValue[0]
  }

  if (typeof ageValue !== 'string' || !/^[\t ]*[0-9]+[\t ]*$/.test(ageValue)) {
    return MAX_RESPONSE_AGE
  }

  const age = BigInt(ageValue.replace(/^[\t ]+|[\t ]+$/g, ''))
  if (age >= BigInt(MAX_RESPONSE_AGE / 1000)) {
    return MAX_RESPONSE_AGE
  }

  return Number(age) * 1000
}

/**
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
 * @param {number} now
 * @param {number | undefined} age
 * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
 * @param {Date | undefined} responseDate
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
 * @param {boolean} hasValidator whether the response has a validator (etag or
 *  last-modified) that revalidation requests can be made with
 *
 * @returns {number | undefined} time that the value is stale at in seconds or undefined if it shouldn't be cached
 */
function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives, hasValidator) {
  if (cacheType === 'shared') {
    // Prioritize s-maxage since we're a shared cache
    //  s-maxage > max-age > Expire
    //  https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
    if (hasInvalidCacheControlDirective(cacheControlDirectives, 's-maxage')) {
      return 0
    }

    const sMaxAge = cacheControlDirectives['s-maxage']
    if (sMaxAge !== undefined) {
      if (sMaxAge > 0) {
        return sMaxAge * 1000
      }

      // Immediately stale, but storable if we can revalidate it before reuse.
      return 0
    }
  }

  if (hasInvalidCacheControlDirective(cacheControlDirectives, 'max-age')) {
    return 0
  }

  const maxAge = cacheControlDirectives['max-age']
  if (maxAge !== undefined) {
    if (maxAge > 0) {
      return maxAge * 1000
    }

    // Immediately stale, but storable if we can revalidate it before reuse.
    return 0
  }

  if (Object.hasOwn(resHeaders, 'expires')) {
    // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
    if (typeof resHeaders.expires !== 'string') {
      return 0
    }

    const expiresDate = parseHttpDate(resHeaders.expires)
    if (!expiresDate) {
      return 0
    }

    if (now >= expiresDate.getTime()) {
      return 0
    }

    if (responseDate) {
      if (responseDate >= expiresDate) {
        return 0
      }

      const freshnessLifetime = expiresDate.getTime() - responseDate.getTime()
      if (age !== undefined && age >= freshnessLifetime) {
        return 0
      }

      return freshnessLifetime
    }

    return expiresDate.getTime() - now
  }

  if (typeof resHeaders['last-modified'] === 'string') {
    // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh
    const lastModified = parseHttpDate(resHeaders['last-modified'])
    if (lastModified) {
      if (lastModified.getTime() >= now) {
        return undefined
      }

      const responseAge = now - lastModified.getTime()

      return responseAge * 0.1
    }
  }

  if (cacheControlDirectives.immutable) {
    // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
    return 31536000000
  }

  if (cacheControlDirectives['no-cache'] === true && hasValidator) {
    // No freshness source, but a validator lets us revalidate before reuse.
    //  https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4
    return 0
  }

  return undefined
}

/**
 * @param {number} baseTime
 * @param {number} cachedAt
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
 * @param {number} staleAt
 */
function determineDeleteAt (baseTime, cachedAt, cacheControlDirectives, staleAt) {
  let staleWhileRevalidate = -Infinity
  let staleIfError = -Infinity
  let immutable = -Infinity

  if (cacheControlDirectives['stale-while-revalidate']) {
    staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000)
  }

  if (cacheControlDirectives['stale-if-error']) {
    staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000)
  }

  if (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
    immutable = cachedAt + 31536000000
  }

  // When no stale directives or immutable flag, add a revalidation buffer
  // equal to the freshness lifetime so the entry survives past staleAt long
  // enough to be revalidated instead of silently disappearing.
  //
  // Response Date headers only have second precision, so baseTime can trail the
  // actual cache insertion time by up to ~1s. Pad the buffer by that bounded
  // skew so short-lived entries do not disappear exactly when they should be
  // revalidated.
  if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) {
    const freshnessLifetime = staleAt - baseTime
    if (freshnessLifetime <= 0) {
      // Revalidation-only entry: no freshness lifetime to size the buffer on,
      //  so retain it for a bounded window instead.
      return cachedAt + REVALIDATION_ONLY_RETENTION
    }
    const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000)
    return staleAt + freshnessLifetime + datePrecisionPadding
  }

  return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable)
}

/**
 * Strips headers required to be removed in cached responses
 * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
 * @returns {Record<string, string | string []>}
 */
function stripNecessaryHeaders (resHeaders, cacheControlDirectives) {
  const headersToRemove = [
    'connection',
    'proxy-authenticate',
    'proxy-authentication-info',
    'proxy-authorization',
    'proxy-connection',
    'te',
    'transfer-encoding',
    'upgrade',
    // We'll add age back when serving it
    'age'
  ]

  if (resHeaders['connection']) {
    appendConnectionHeaderTokens(headersToRemove, resHeaders['connection'])
  }

  if (Array.isArray(cacheControlDirectives['no-cache'])) {
    headersToRemove.push(...cacheControlDirectives['no-cache'])
  }

  if (Array.isArray(cacheControlDirectives['private'])) {
    headersToRemove.push(...cacheControlDirectives['private'])
  }

  let strippedHeaders
  for (const headerName of headersToRemove) {
    if (Object.hasOwn(resHeaders, headerName)) {
      strippedHeaders ??= { ...resHeaders }
      delete strippedHeaders[headerName]
    }
  }

  return strippedHeaders ?? resHeaders
}

module.exports = CacheHandler