'use strict'

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

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

/**
 * @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
  }
}

/**
 * @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')
        }
        headers[key.toLowerCase()] = val
      }
    } else {
      for (const key of Object.keys(opts.headers)) {
        headers[key.toLowerCase()] = 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 = {}

  let directives
  if (Array.isArray(header)) {
    directives = []

    for (const directive of header) {
      directives.push(...directive.split(','))
    }
  } else {
    directives = header.split(',')
  }

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

    let key
    let value
    if (keyValueDelimiter !== -1) {
      key = directive.substring(0, keyValueDelimiter).trimStart()
      value = directive.substring(keyValueDelimiter + 1)
    } else {
      key = directive.trim()
    }

    switch (key) {
      case 'min-fresh':
      case 'max-stale':
      case 'max-age':
      case 's-maxage':
      case 'stale-while-revalidate':
      case 'stale-if-error': {
        if (value === undefined || value[0] === ' ') {
          continue
        }

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

        const parsedValue = parseInt(value, 10)
        // eslint-disable-next-line no-self-compare
        if (parsedValue !== parsedValue) {
          continue
        }

        if (key === 'max-age' && key in output && output[key] >= parsedValue) {
          continue
        }

        output[key] = parsedValue

        break
      }
      case 'private':
      case 'no-cache': {
        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 ends in a
          //  quote. We can then just splice all of the parts in between the
          //  starting quote and the ending quote out of the directives array
          //  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"`.

            // Add the first header on and cut off the leading quote
            const headers = [value.substring(1)]

            let foundEndingQuote = value[value.length - 1] === '"'
            if (!foundEndingQuote) {
              // Something like `no-cache="some-header, another-header"`
              //  This can still be something invalid, e.g. `no-cache="some-header, ...`
              for (let j = i + 1; j < directives.length; j++) {
                const nextPart = directives[j]
                const nextPartLength = nextPart.length

                headers.push(nextPart.trim())

                if (nextPartLength !== 0 && nextPart[nextPartLength - 1] === '"') {
                  foundEndingQuote = true
                  break
                }
              }
            }

            if (foundEndingQuote) {
              let lastHeader = headers[headers.length - 1]
              if (lastHeader[lastHeader.length - 1] === '"') {
                lastHeader = lastHeader.substring(0, lastHeader.length - 1)
                headers[headers.length - 1] = lastHeader
              }

              for (let j = 0; j < headers.length; j++) {
                headers[j] = headers[j].trim()
              }

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

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

          break
        }
      }
      // eslint-disable-next-line no-fallthrough
      case 'public':
      case 'no-store':
      case 'must-revalidate':
      case 'proxy-revalidate':
      case 'immutable':
      case 'no-transform':
      case 'must-understand':
      case 'only-if-cached':
        if (value) {
          // These are qualified (something like `public=...`) when they aren't
          //  allowed to be, skip
          continue
        }

        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
 * @param {Record<string, string | string[]>} headers Request headers
 * @returns {Record<string, string | string[]>}
 */
function parseVaryHeader (varyHeader, headers) {
  if (typeof varyHeader === 'string' && varyHeader.includes('*')) {
    return headers
  }

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

  const varyingHeaders = typeof varyHeader === 'string'
    ? varyHeader.split(',')
    : varyHeader

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

    output[trimmedHeader] = headers[trimmedHeader] ?? null
  }

  return output
}

/**
 * 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 (!safeHTTPMethods.includes(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,
  parseVaryHeader,
  isEtagUsable,
  assertCacheMethods,
  assertCacheStore,
  makeDeduplicationKey
}
