'use strict'

const {
  safeHTTPMethods,
  pathHasQueryOrFragment,
  hasSafeIterator,
  isValidHTTPToken
} = require('../core/util')

const { serializePathWithQuery } = require('../core/util')

const MAX_DELTA_SECONDS = 2147483647
const RESTRICTIVE_DIRECTIVE_NAMES = ['no-store', 'private', 'no-cache']
const kInvalidCacheControlDirectives = Symbol('invalid cache-control directives')

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 trimOWSStart (value) {
  return value.replace(/^[\t ]+/, '')
}

function trimOWSEnd (value) {
  return value.replace(/[\t ]+$/, '')
}

function findUnescapedQuote (value, start) {
  let escaped = false
  for (let i = start; i < value.length; i++) {
    if (escaped) {
      escaped = false
    } else if (value[i] === '\\') {
      escaped = true
    } else if (value[i] === '"') {
      return i
    }
  }

  return -1
}

function splitCacheControlHeaderValue (value) {
  const directives = []
  let start = 0
  let quoteStart = -1
  let inQuote = false
  let escaped = false

  for (let i = 0; i < value.length; i++) {
    if (inQuote) {
      if (escaped) {
        escaped = false
      } else if (value[i] === '\\') {
        escaped = true
      } else if (value[i] === '"') {
        inQuote = false
        quoteStart = -1
      }
    } else if (value[i] === '"') {
      inQuote = true
      quoteStart = i
    } else if (value[i] === ',') {
      directives.push({ value: value.substring(start, i), fromMalformedQuote: false })
      start = i + 1
    }
  }

  if (!inQuote) {
    directives.push({ value: value.substring(start), fromMalformedQuote: false })
    return directives
  }

  const tail = value.substring(start)
  const quoteOffset = quoteStart - start
  let tailStart = 0
  for (let i = 0; i < tail.length; i++) {
    if (tail[i] === ',') {
      directives.push({
        value: tail.substring(tailStart, i),
        fromMalformedQuote: tailStart > quoteOffset
      })
      tailStart = i + 1
    }
  }

  directives.push({
    value: tail.substring(tailStart),
    fromMalformedQuote: tailStart > quoteOffset
  })
  return directives
}

function markInvalidCacheControlDirective (directives, key) {
  let invalidDirectives = directives[kInvalidCacheControlDirectives]

  if (invalidDirectives === undefined) {
    invalidDirectives = new Set()
    Object.defineProperty(directives, kInvalidCacheControlDirectives, {
      value: invalidDirectives
    })
  }

  invalidDirectives.add(key)
}

function hasInvalidCacheControlDirective (directives, key) {
  return directives[kInvalidCacheControlDirectives]?.has(key) === true
}

function getMalformedRestrictiveDirectiveName (key) {
  for (const directiveName of RESTRICTIVE_DIRECTIVE_NAMES) {
    if (
      key.startsWith(directiveName) &&
      key.length > directiveName.length &&
      !isValidHTTPToken(key[directiveName.length])
    ) {
      return directiveName
    }
  }

  let tokenOnlyKey = ''
  let hasInvalidTokenChar = false
  for (let i = 0; i < key.length; i++) {
    if (isValidHTTPToken(key[i])) {
      tokenOnlyKey += key[i]
    } else {
      hasInvalidTokenChar = true
    }
  }

  if (hasInvalidTokenChar && arrayIncludes(RESTRICTIVE_DIRECTIVE_NAMES, tokenOnlyKey)) {
    return tokenOnlyKey
  }
}

/**
 * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts
 */
function makeCacheKey (opts) {
  if (!opts.origin) {
    throw new Error('opts.origin is undefined')
  }

  let fullPath = opts.path || '/'

  if (opts.query && !pathHasQueryOrFragment(fullPath)) {
    fullPath = serializePathWithQuery(fullPath, opts.query)
  }

  return {
    origin: opts.origin.toString(),
    method: opts.method,
    path: fullPath,
    headers: opts.headers
  }
}

function appendHeader (headers, key, val) {
  const headerName = key.toLowerCase()
  const current = headers[headerName]
  const values = Array.isArray(val) ? val : [val]

  if (current === undefined) {
    headers[headerName] = Array.isArray(val) ? val.slice() : val
  } else if (Array.isArray(current)) {
    current.push(...values)
  } else {
    headers[headerName] = [current, ...values]
  }
}

/**
 * @param {Record<string, string[] | string>}
 * @returns {Record<string, string[] | string>}
 */
function normalizeHeaders (opts) {
  let headers
  if (opts.headers == null) {
    headers = {}
  } else if (typeof opts.headers === 'object') {
    headers = {}

    if (hasSafeIterator(opts.headers)) {
      for (const x of opts.headers) {
        if (!Array.isArray(x)) {
          throw new Error('opts.headers is not a valid header map')
        }
        const [key, val] = x
        if (typeof key !== 'string' || typeof val !== 'string') {
          throw new Error('opts.headers is not a valid header map')
        }
        appendHeader(headers, key, val)
      }
    } else {
      for (const key of Object.keys(opts.headers)) {
        appendHeader(headers, key, opts.headers[key])
      }
    }
  } else {
    throw new Error('opts.headers is not an object')
  }

  return headers
}

/**
 * @param {any} key
 */
function assertCacheKey (key) {
  if (typeof key !== 'object') {
    throw new TypeError(`expected key to be object, got ${typeof key}`)
  }

  for (const property of ['origin', 'method', 'path']) {
    if (typeof key[property] !== 'string') {
      throw new TypeError(`expected key.${property} to be string, got ${typeof key[property]}`)
    }
  }

  if (key.headers !== undefined && typeof key.headers !== 'object') {
    throw new TypeError(`expected headers to be object, got ${typeof key}`)
  }
}

/**
 * @param {any} value
 */
function assertCacheValue (value) {
  if (typeof value !== 'object') {
    throw new TypeError(`expected value to be object, got ${typeof value}`)
  }

  for (const property of ['statusCode', 'cachedAt', 'staleAt', 'deleteAt']) {
    if (typeof value[property] !== 'number') {
      throw new TypeError(`expected value.${property} to be number, got ${typeof value[property]}`)
    }
  }

  if (typeof value.statusMessage !== 'string') {
    throw new TypeError(`expected value.statusMessage to be string, got ${typeof value.statusMessage}`)
  }

  if (value.headers != null && typeof value.headers !== 'object') {
    throw new TypeError(`expected value.rawHeaders to be object, got ${typeof value.headers}`)
  }

  if (value.vary !== undefined && typeof value.vary !== 'object') {
    throw new TypeError(`expected value.vary to be object, got ${typeof value.vary}`)
  }

  if (value.etag !== undefined && typeof value.etag !== 'string') {
    throw new TypeError(`expected value.etag to be string, got ${typeof value.etag}`)
  }
}

/**
 * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control
 * @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml

 * @param {string | string[]} header
 * @returns {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives}
 */
function parseCacheControlHeader (header) {
  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives}
   */
  const output = {}
  const invalidNumericDirectives = new Set()
  const invalidNoArgumentDirectives = new Set()

  const directives = splitCacheControlHeaderValue(Array.isArray(header) ? header.join(',') : header)

  for (let i = 0; i < directives.length; i++) {
    const directiveRecord = directives[i]
    const directive = directiveRecord.value.toLowerCase()
    const fromMalformedQuote = directiveRecord.fromMalformedQuote
    const keyValueDelimiter = directive.indexOf('=')

    let key
    let value
    let keyHasTrailingWhitespace = false
    let valueHasLeadingWhitespace = false
    if (keyValueDelimiter !== -1) {
      const rawKey = directive.substring(0, keyValueDelimiter)
      const rawValue = directive.substring(keyValueDelimiter + 1)

      keyHasTrailingWhitespace = trimOWSEnd(rawKey) !== rawKey
      valueHasLeadingWhitespace = trimOWSStart(rawValue) !== rawValue
      key = trimOWS(rawKey)
      value = trimOWSStart(rawValue)
    } else {
      key = trimOWS(directive)
    }

    const malformedRestrictiveDirectiveName = getMalformedRestrictiveDirectiveName(key)
    if (malformedRestrictiveDirectiveName !== undefined) {
      output[malformedRestrictiveDirectiveName] = true
      continue
    }

    switch (key) {
      case 'min-fresh':
      case 'max-stale':
      case 'max-age':
      case 's-maxage':
      case 'stale-while-revalidate':
      case 'stale-if-error': {
        if (fromMalformedQuote || invalidNumericDirectives.has(key)) {
          continue
        }

        if (value === undefined || keyHasTrailingWhitespace || valueHasLeadingWhitespace) {
          delete output[key]
          invalidNumericDirectives.add(key)
          markInvalidCacheControlDirective(output, key)
          continue
        }

        if (
          value.length >= 2 &&
          value[0] === '"' &&
          value[value.length - 1] === '"'
        ) {
          value = value.substring(1, value.length - 1)
        }

        if (!/^[0-9]+$/.test(value)) {
          delete output[key]
          invalidNumericDirectives.add(key)
          markInvalidCacheControlDirective(output, key)
          continue
        }

        const parsedValue = Math.min(parseInt(value, 10), MAX_DELTA_SECONDS)

        if (key === 'min-fresh') {
          if (!(key in output) || output[key] < parsedValue) {
            output[key] = parsedValue
          }
        } else if (!(key in output) || output[key] > parsedValue) {
          output[key] = parsedValue
        }

        break
      }
      case 'private':
      case 'no-cache': {
        if (fromMalformedQuote) {
          output[key] = true
          break
        }

        if (value !== undefined && value.length === 0) {
          output[key] = true
          break
        }

        if (value) {
          // The private and no-cache directives can be unqualified (aka just
          //  `private` or `no-cache`) or qualified (w/ a value). When they're
          //  qualified, it's a list of headers like `no-cache=header1`,
          //  `no-cache="header1"`, or `no-cache="header1, header2"`
          // If we're given multiple headers, the comma messes us up since
          //  we split the full header by commas. So, let's loop through the
          //  remaining parts in front of us until we find one that contains a
          //  closing quote. We can then skip the consumed quoted-list fragments and
          //  continue parsing like normal.
          // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2
          if (value[0] === '"') {
            // Something like `no-cache="some-header"` OR `no-cache="some-header, another-header"`.
            value = trimOWSEnd(value)

            let fieldList = ''
            let lastQuotedPart = i
            let foundEndingQuote = false
            const closingQuote = findUnescapedQuote(value, 1)

            if (closingQuote !== -1) {
              fieldList = value.substring(1, closingQuote)
              foundEndingQuote = true
            } else {
              // Something like `no-cache="some-header, another-header"`
              //  This can still be something invalid, e.g. `no-cache="some-header, ...`
              const fieldListParts = [value.substring(1)]

              for (let j = i + 1; j < directives.length; j++) {
                const nextPart = trimOWS(directives[j].value)
                const closingQuote = findUnescapedQuote(nextPart, 0)

                lastQuotedPart = j

                if (closingQuote !== -1) {
                  fieldListParts.push(nextPart.substring(0, closingQuote))
                  foundEndingQuote = true
                  break
                }

                fieldListParts.push(nextPart)
              }

              fieldList = fieldListParts.join(',')
            }

            if (!foundEndingQuote) {
              output[key] = true
              break
            }

            i = lastQuotedPart

            const headers = fieldList.split(',')
            let validFieldNames = true
            for (let j = 0; j < headers.length; j++) {
              headers[j] = trimOWS(headers[j])
              if (!isValidHTTPToken(headers[j])) {
                validFieldNames = false
              }
            }

            if (!validFieldNames) {
              output[key] = true
            } else if (output[key] !== true) {
              if (key in output) {
                output[key] = output[key].concat(headers)
              } else {
                output[key] = headers
              }
            }
          } else {
            // Something like `no-cache=some-header`
            const fieldName = trimOWS(value)

            if (!isValidHTTPToken(fieldName)) {
              output[key] = true
            } else if (output[key] !== true) {
              if (key in output) {
                output[key] = output[key].concat(fieldName)
              } else {
                output[key] = [fieldName]
              }
            }
          }

          break
        }
      }
      // eslint-disable-next-line no-fallthrough
      case 'public':
      case 'must-revalidate':
      case 'proxy-revalidate':
      case 'immutable':
      case 'no-transform':
      case 'must-understand':
      case 'only-if-cached':
        if (fromMalformedQuote || invalidNoArgumentDirectives.has(key)) {
          continue
        }

        if (value !== undefined) {
          // These are qualified (something like `public=...`) when they aren't
          //  allowed to be, skip all instances of the malformed directive.
          delete output[key]
          invalidNoArgumentDirectives.add(key)
          continue
        }

        output[key] = true
        break
      case 'no-store':
        output[key] = true
        break
      default:
        // Ignore unknown directives as per https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.3-1
        continue
    }
  }

  return output
}

/**
 * @param {string | string[]} varyHeader Vary header from the server
 * @returns {string[]}
 */
function splitVaryHeader (varyHeader) {
  const values = Array.isArray(varyHeader) ? varyHeader : [varyHeader]
  const output = []

  for (let i = 0; i < values.length; i++) {
    const parts = values[i].split(',')
    for (let j = 0; j < parts.length; j++) {
      output.push(parts[j])
    }
  }

  return output
}

/**
 * @param {string | string[]} varyHeader Vary header from the server
 * @returns {boolean}
 */
function hasVaryStar (varyHeader) {
  const values = splitVaryHeader(varyHeader)
  for (let i = 0; i < values.length; i++) {
    if (trimOWS(values[i]).indexOf('*') !== -1) {
      return true
    }
  }

  return false
}

/**
 * @param {string | string[]} varyHeader Vary header from the server
 * @param {Record<string, string | string[]>} headers Request headers
 * @returns {Record<string, string | string[] | null> | undefined}
 */
function parseVaryHeader (varyHeader, headers) {
  if (hasVaryStar(varyHeader)) {
    return headers
  }

  const output = /** @type {Record<string, string | string[] | null>} */ ({})

  const varyingHeaders = splitVaryHeader(varyHeader)

  for (const header of varyingHeaders) {
    const trimmedHeader = trimOWS(header).toLowerCase()

    if (trimmedHeader.length === 0) {
      continue
    }

    if (!isValidHTTPToken(trimmedHeader)) {
      return undefined
    }

    const headerValue = headers[trimmedHeader]
    output[trimmedHeader] = Array.isArray(headerValue) ? headerValue.slice() : headerValue ?? null
  }

  return output
}

/**
 * @param {string | string[]} varyHeader Vary header from the server
 * @returns {boolean}
 */
function isInvalidOrWildcardVaryHeader (varyHeader) {
  return hasVaryStar(varyHeader) || parseVaryHeader(varyHeader, {}) === undefined
}

/**
 * Note: this deviates from the spec a little. Empty etags ("", W/"") are valid,
 *  however, including them in cached resposnes serves little to no purpose.
 *
 * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag
 *
 * @param {string} etag
 * @returns {boolean}
 */
function isEtagUsable (etag) {
  if (etag.length <= 2) {
    // Shortest an etag can be is two chars (just ""). This is where we deviate
    //  from the spec requiring a min of 3 chars however
    return false
  }

  if (etag[0] === '"' && etag[etag.length - 1] === '"') {
    // ETag: ""asd123"" or ETag: "W/"asd123"", kinda undefined behavior in the
    //  spec. Some servers will accept these while others don't.
    // ETag: "asd123"
    return !(etag[1] === '"' || etag.startsWith('"W/'))
  }

  if (etag.startsWith('W/"') && etag[etag.length - 1] === '"') {
    // ETag: W/"", also where we deviate from the spec & require a min of 3
    //  chars
    // ETag: for W/"", W/"asd123"
    return etag.length !== 4
  }

  // Anything else
  return false
}

/**
 * @param {unknown} store
 * @returns {asserts store is import('../../types/cache-interceptor.d.ts').default.CacheStore}
 */
function assertCacheStore (store, name = 'CacheStore') {
  if (typeof store !== 'object' || store === null) {
    throw new TypeError(`expected type of ${name} to be a CacheStore, got ${store === null ? 'null' : typeof store}`)
  }

  for (const fn of ['get', 'createWriteStream', 'delete']) {
    if (typeof store[fn] !== 'function') {
      throw new TypeError(`${name} needs to have a \`${fn}()\` function`)
    }
  }
}
/**
 * @param {unknown} methods
 * @returns {asserts methods is import('../../types/cache-interceptor.d.ts').default.CacheMethods[]}
 */
function assertCacheMethods (methods, name = 'CacheMethods') {
  if (!Array.isArray(methods)) {
    throw new TypeError(`expected type of ${name} needs to be an array, got ${methods === null ? 'null' : typeof methods}`)
  }

  if (methods.length === 0) {
    throw new TypeError(`${name} needs to have at least one method`)
  }

  for (const method of methods) {
    if (!arrayIncludes(safeHTTPMethods, method)) {
      throw new TypeError(`element of ${name}-array needs to be one of following values: ${safeHTTPMethods.join(', ')}, got ${method}`)
    }
  }
}

/**
 * Creates a string key for request deduplication purposes.
 * This key is used to identify in-flight requests that can be shared.
 * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
 * @param {Set<string>} [excludeHeaders] Set of lowercase header names to exclude from the key
 * @returns {string}
 */
function makeDeduplicationKey (cacheKey, excludeHeaders) {
  // Use JSON.stringify to produce a collision-resistant key.
  // Previous format used `:` and `=` delimiters without escaping, which
  // allowed different header sets to produce identical keys (e.g.
  // {a:"x:b=y"} vs {a:"x", b:"y"}). See: https://github.com/nodejs/undici/issues/5012
  const headers = {}

  if (cacheKey.headers) {
    const sortedHeaders = Object.keys(cacheKey.headers).sort()
    for (const header of sortedHeaders) {
      // Skip excluded headers
      if (excludeHeaders?.has(header.toLowerCase())) {
        continue
      }
      headers[header] = cacheKey.headers[header]
    }
  }

  return JSON.stringify([cacheKey.origin, cacheKey.method, cacheKey.path, headers])
}

module.exports = {
  makeCacheKey,
  normalizeHeaders,
  assertCacheKey,
  assertCacheValue,
  parseCacheControlHeader,
  hasInvalidCacheControlDirective,
  parseVaryHeader,
  hasVaryStar,
  isInvalidOrWildcardVaryHeader,
  isEtagUsable,
  assertCacheMethods,
  assertCacheStore,
  makeDeduplicationKey
}
