'use strict'

/**
 * @param {string} value
 * @returns {boolean}
 */
function isCTLExcludingHtab (value) {
  for (let i = 0; i < value.length; ++i) {
    const code = value.charCodeAt(i)

    if (
      (code >= 0x00 && code <= 0x08) ||
      (code >= 0x0A && code <= 0x1F) ||
      code === 0x7F
    ) {
      return true
    }
  }
  return false
}

/**
 CHAR           = <any US-ASCII character (octets 0 - 127)>
 token          = 1*<any CHAR except CTLs or separators>
 separators     = "(" | ")" | "<" | ">" | "@"
                | "," | ";" | ":" | "\" | <">
                | "/" | "[" | "]" | "?" | "="
                | "{" | "}" | SP | HT
 * @param {string} name
 */
function validateCookieName (name) {
  for (let i = 0; i < name.length; ++i) {
    const code = name.charCodeAt(i)

    if (
      code < 0x21 || // exclude CTLs (0-31), SP and HT
      code > 0x7E || // exclude non-ascii and DEL
      code === 0x22 || // "
      code === 0x28 || // (
      code === 0x29 || // )
      code === 0x3C || // <
      code === 0x3E || // >
      code === 0x40 || // @
      code === 0x2C || // ,
      code === 0x3B || // ;
      code === 0x3A || // :
      code === 0x5C || // \
      code === 0x2F || // /
      code === 0x5B || // [
      code === 0x5D || // ]
      code === 0x3F || // ?
      code === 0x3D || // =
      code === 0x7B || // {
      code === 0x7D // }
    ) {
      throw new Error('Invalid cookie name')
    }
  }
}

/**
 cookie-value      = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
 cookie-octet      = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
                       ; US-ASCII characters excluding CTLs,
                       ; whitespace DQUOTE, comma, semicolon,
                       ; and backslash
 * @param {string} value
 */
function validateCookieValue (value) {
  let len = value.length
  let i = 0

  // if the value is wrapped in DQUOTE
  if (value[0] === '"') {
    if (len === 1 || value[len - 1] !== '"') {
      throw new Error('Invalid cookie value')
    }
    --len
    ++i
  }

  while (i < len) {
    const code = value.charCodeAt(i++)

    if (
      code < 0x21 || // exclude CTLs (0-31)
      code > 0x7E || // non-ascii and DEL (127)
      code === 0x22 || // "
      code === 0x2C || // ,
      code === 0x3B || // ;
      code === 0x5C // \
    ) {
      throw new Error('Invalid cookie value')
    }
  }
}

/**
 * path-value        = <any CHAR except CTLs or ";">
 * @param {string} path
 */
function validateCookiePath (path) {
  for (let i = 0; i < path.length; ++i) {
    const code = path.charCodeAt(i)

    if (
      code < 0x20 || // exclude CTLs (0-31)
      code > 0x7E || // exclude non-ascii and DEL
      code === 0x3B // ;
    ) {
      throw new Error('Invalid cookie path')
    }
  }
}

/**
 * <let-dig> ::= <letter> | <digit>
 *
 * <letter> ::= any one of the 52 alphabetic characters A through Z in
 * upper case and a through z in lower case
 *
 * <digit> ::= any one of the ten digits 0 through 9r
 *
 * @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5
 * @param {number} code
 */
function isLetterOrDigit (code) {
  return (
    (code >= 0x30 && code <= 0x39) || // 0-9
    (code >= 0x41 && code <= 0x5A) || // A-Z
    (code >= 0x61 && code <= 0x7A) // a-z
  )
}

/**
 * Validates a cookie domain against the "preferred name syntax".
 *
 * <domain>      ::= <subdomain> | " "
 * <subdomain>   ::= <label> | <subdomain> "." <label>
 * <label>       ::= <let-dig> [ [ <ldh-str> ] <let-dig> ]
 * <ldh-str>     ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
 * <let-dig-hyp> ::= <let-dig> | "-"
 *
 * @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5
 * @see https://www.rfc-editor.org/rfc/rfc1123#section-2.1
 * @see https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4
 * @param {string} domain
 */
function validateCookieDomain (domain) {
  // <domain> ::= <subdomain> | " "
  if (domain === ' ') {
    return
  }

  if (domain.length > 255) {
    throw new Error('Invalid cookie domain')
  }

  let labelLength = 0

  for (let i = 0; i < domain.length; ++i) {
    const code = domain.charCodeAt(i)

    if (code === 0x2E) {
      if (labelLength === 0) {
        throw new Error('Invalid cookie domain')
      }

      if (domain.charCodeAt(i - 1) === 0x2D) { // "-"
        throw new Error('Invalid cookie domain')
      }

      labelLength = 0
      continue
    }

    if (labelLength === 0 && !isLetterOrDigit(code)) {
      throw new Error('Invalid cookie domain')
    }

    if (!isLetterOrDigit(code) && code !== 0x2D) { // "-"
      throw new Error('Invalid cookie domain')
    }

    if (++labelLength > 63) {
      throw new Error('Invalid cookie domain')
    }
  }

  if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 0x2D) { // "-"
    throw new Error('Invalid cookie domain')
  }
}

const IMFDays = [
  'Sun', 'Mon', 'Tue', 'Wed',
  'Thu', 'Fri', 'Sat'
]

const IMFMonths = [
  'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
]

const IMFPaddedNumbers = Array(61).fill(0).map((_, i) => i.toString().padStart(2, '0'))

/**
 * @see https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1
 * @param {number|Date} date
  IMF-fixdate  = day-name "," SP date1 SP time-of-day SP GMT
  ; fixed length/zone/capitalization subset of the format
  ; see Section 3.3 of [RFC5322]

  day-name     = %x4D.6F.6E ; "Mon", case-sensitive
              / %x54.75.65 ; "Tue", case-sensitive
              / %x57.65.64 ; "Wed", case-sensitive
              / %x54.68.75 ; "Thu", case-sensitive
              / %x46.72.69 ; "Fri", case-sensitive
              / %x53.61.74 ; "Sat", case-sensitive
              / %x53.75.6E ; "Sun", case-sensitive
  date1        = day SP month SP year
                  ; e.g., 02 Jun 1982

  day          = 2DIGIT
  month        = %x4A.61.6E ; "Jan", case-sensitive
              / %x46.65.62 ; "Feb", case-sensitive
              / %x4D.61.72 ; "Mar", case-sensitive
              / %x41.70.72 ; "Apr", case-sensitive
              / %x4D.61.79 ; "May", case-sensitive
              / %x4A.75.6E ; "Jun", case-sensitive
              / %x4A.75.6C ; "Jul", case-sensitive
              / %x41.75.67 ; "Aug", case-sensitive
              / %x53.65.70 ; "Sep", case-sensitive
              / %x4F.63.74 ; "Oct", case-sensitive
              / %x4E.6F.76 ; "Nov", case-sensitive
              / %x44.65.63 ; "Dec", case-sensitive
  year         = 4DIGIT

  GMT          = %x47.4D.54 ; "GMT", case-sensitive

  time-of-day  = hour ":" minute ":" second
              ; 00:00:00 - 23:59:60 (leap second)

  hour         = 2DIGIT
  minute       = 2DIGIT
  second       = 2DIGIT
 */
function toIMFDate (date) {
  if (typeof date === 'number') {
    date = new Date(date)
  }

  return `${IMFDays[date.getUTCDay()]}, ${IMFPaddedNumbers[date.getUTCDate()]} ${IMFMonths[date.getUTCMonth()]} ${date.getUTCFullYear()} ${IMFPaddedNumbers[date.getUTCHours()]}:${IMFPaddedNumbers[date.getUTCMinutes()]}:${IMFPaddedNumbers[date.getUTCSeconds()]} GMT`
}

/**
 max-age-av        = "Max-Age=" non-zero-digit *DIGIT
                       ; In practice, both expires-av and max-age-av
                       ; are limited to dates representable by the
                       ; user agent.
 * @param {number} maxAge
 */
function validateCookieMaxAge (maxAge) {
  if (maxAge < 0) {
    throw new Error('Invalid cookie max-age')
  }
}

/**
 * @see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
 * @param {import('./index').Cookie} cookie
 */
function stringify (cookie) {
  if (cookie.name.length === 0) {
    return null
  }

  validateCookieName(cookie.name)
  validateCookieValue(cookie.value)

  const out = [`${cookie.name}=${cookie.value}`]

  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.1
  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.2
  if (cookie.name.startsWith('__Secure-')) {
    cookie.secure = true
  }

  if (cookie.name.startsWith('__Host-')) {
    cookie.secure = true
    cookie.domain = null
    cookie.path = '/'
  }

  if (cookie.secure) {
    out.push('Secure')
  }

  if (cookie.httpOnly) {
    out.push('HttpOnly')
  }

  if (typeof cookie.maxAge === 'number') {
    validateCookieMaxAge(cookie.maxAge)
    out.push(`Max-Age=${cookie.maxAge}`)
  }

  if (cookie.domain) {
    validateCookieDomain(cookie.domain)
    out.push(`Domain=${cookie.domain}`)
  }

  if (cookie.path) {
    validateCookiePath(cookie.path)
    out.push(`Path=${cookie.path}`)
  }

  if (cookie.expires && cookie.expires.toString() !== 'Invalid Date') {
    out.push(`Expires=${toIMFDate(cookie.expires)}`)
  }

  if (cookie.sameSite) {
    out.push(`SameSite=${cookie.sameSite}`)
  }

  for (const part of cookie.unparsed) {
    if (!part.includes('=')) {
      throw new Error('Invalid unparsed')
    }

    const [key, ...value] = part.split('=')

    const trimmedKey = key.trim()
    const joinedValue = value.join('=')

    validateCookieName(trimmedKey)
    validateCookieValue(joinedValue)

    out.push(`${trimmedKey}=${joinedValue}`)
  }

  return out.join('; ')
}

module.exports = {
  isCTLExcludingHtab,
  validateCookieName,
  validateCookiePath,
  validateCookieValue,
  toIMFDate,
  stringify
}
