// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// How far a cross-site request may carry a cookie.
///
/// `Strict` sends it only on a same-site request, `Lax` also on a top-level
/// navigation, and `Unrestricted` sends it everywhere. `Unrestricted` is the wire
/// value `None`, spelled differently here because `None` is the empty option; a
/// browser honours it only on a `Secure` cookie.
pub(all) enum SameSite {
  Strict
  Lax
  Unrestricted
} derive(Eq, Debug)

///|
pub extend SameSite with Eq::{equal, not_equal}

///|
pub extend SameSite with Debug::{to_repr}

///|
/// The attribute's wire spelling.
pub fn SameSite::code(self : SameSite) -> String {
  match self {
    Strict => "Strict"
    Lax => "Lax"
    Unrestricted => "None"
  }
}

///|
/// The attribute a spelling names, or `None` for one no revision defines.
///
/// The comparison ignores case, as §5.2 says to do with every attribute value.
pub fn SameSite::of(text : StringView) -> SameSite? {
  match lower(text) {
    "strict" => Some(Strict)
    "lax" => Some(Lax)
    "none" => Some(Unrestricted)
    _ => None
  }
}

///|
/// One cookie, as `Set-Cookie` states it.
///
/// An attribute left `None` is left out of the header, and means what leaving it
/// out means: no `Expires` and no `Max-Age` is a session cookie, no `Domain` is
/// the origin host alone, no `Path` is the request's own directory.
pub(all) struct Cookie {
  name : String
  value : String
  /// When the cookie dies, written as the HTTP-date `Expires` carries.
  expires : @moondate.Moment?
  /// How long it lives, in seconds. A browser that understands it prefers it to
  /// `Expires`, which is why a deletion writes both.
  max_age : Int?
  domain : String?
  path : String?
  secure : Bool
  http_only : Bool
  same_site : SameSite?
} derive(Eq, Debug)

///|
pub extend Cookie with Eq::{equal, not_equal}

///|
pub extend Cookie with Debug::{to_repr}

///|
/// A cookie by name, with the defaults a server usually wants.
///
/// `path` defaults to `/`, so the cookie covers the whole site, and `same_site` to
/// `Lax`, which is what a browser assumes of a cookie that does not say — and what
/// stops a cross-site form post from carrying a session. Passing `None` for either
/// leaves the attribute out.
pub fn Cookie::new(
  name : String,
  value : String,
  expires? : @moondate.Moment,
  max_age? : Int,
  domain? : String,
  path? : String? = Some("/"),
  secure? : Bool = false,
  http_only? : Bool = false,
  same_site? : SameSite? = Some(Lax),
) -> Cookie {
  { name, value, expires, max_age, domain, path, secure, http_only, same_site, }
}

///|
/// The `Set-Cookie` field value this cookie is sent as.
///
/// Attributes come in RFC 6265 §4.1.1's order, which is the order a browser and a
/// log reader expect. The octets a name, value, domain or path may not carry are
/// dropped rather than escaped: what a reader gets back is then the text that was
/// set, and a `\r\n` smuggled into a value cannot open a header of its own.
///
/// An `expires` that is a time with no date aborts: a cookie cannot die at a
/// clock reading, and a caller that built one has made a mistake rather than met
/// a condition.
pub fn Cookie::encode(self : Cookie) -> String {
  let out = StringBuilder()
  out.write_string(safe(self.name[:]))
  out.write_char('=')
  out.write_string(safe(self.value[:]))
  match self.expires {
    Some(moment) => {
      out.write_string("; Expires=")
      out.write_string(
        moment.http_text() catch {
          _ => abort("a cookie cannot expire at a time with no date")
        },
      )
    }
    None => ()
  }
  match self.max_age {
    Some(seconds) => {
      out.write_string("; Max-Age=")
      out.write_string(seconds.to_string())
    }
    None => ()
  }
  match self.domain {
    Some(domain) => {
      out.write_string("; Domain=")
      out.write_string(safe(domain[:]))
    }
    None => ()
  }
  match self.path {
    Some(path) => {
      out.write_string("; Path=")
      out.write_string(safe(path[:]))
    }
    None => ()
  }
  if self.secure {
    out.write_string("; Secure")
  }
  if self.http_only {
    out.write_string("; HttpOnly")
  }
  match self.same_site {
    Some(same_site) => {
      out.write_string("; SameSite=")
      out.write_string(same_site.code())
    }
    None => ()
  }
  out.to_string()
}

///|
/// The cookie a `Set-Cookie` field value states, or `None` when it states none —
/// an empty field, or one whose first pair has no `=`.
///
/// Everything §5.2 says to ignore is ignored rather than refused: an attribute no
/// revision defines, an `Expires` that is not a date, a `Max-Age` that is not a
/// number. A server sends what it sends, and a client that refused the header
/// would lose the cookie over an attribute it does not even use.
pub fn Cookie::decode(raw : StringView) -> Cookie? {
  let pieces = split(raw, ';')
  if pieces.length() == 0 {
    return None
  }
  let (name, value) = match pair(pieces[0][:]) {
    Some(pair) => pair
    None => return None
  }
  let mut expires : @moondate.Moment? = None
  let mut max_age : Int? = None
  let mut domain : String? = None
  let mut path : String? = None
  let mut secure = false
  let mut http_only = false
  let mut same_site : SameSite? = None
  for i = 1; i < pieces.length(); i = i + 1 {
    let piece = pieces[i]
    let (key, text) = match pair(piece[:]) {
      Some((key, text)) => (lower(key[:]), text)
      // An attribute with no `=` is a flag.
      None => (lower(trim(piece[:])), "")
    }
    match key {
      "expires" => expires = Some(@moondate.http(text[:])) catch { _ => None }
      "max-age" =>
        max_age = Some(@string.parse_int(text[:])) catch { _ => None }
      "domain" =>
        // A leading dot is how RFC 2109 wrote a domain and §5.2.3 says to drop it.
        domain = Some(
          if text.has_prefix(".") {
            text[1:].to_owned()
          } else {
            text
          },
        )
      "path" => path = Some(text)
      "secure" => secure = true
      "httponly" => http_only = true
      "samesite" => same_site = SameSite::of(text[:])
      _ => ()
    }
  }
  Some({
    name,
    value,
    expires,
    max_age,
    domain,
    path,
    secure,
    http_only,
    same_site,
  })
}

///|
/// The `Cookie` field value that sends these pairs, which is what a client writes
/// back from what it was set.
///
/// The header carries names and values and no attributes at all: those were the
/// server's instructions to the client, not something the client repeats.
pub fn encode(items : ArrayView[(String, String)]) -> String {
  let out = StringBuilder()
  for i = 0; i < items.length(); i = i + 1 {
    if i > 0 {
      out.write_string("; ")
    }
    out.write_string(safe(items[i].0[:]))
    out.write_char('=')
    out.write_string(safe(items[i].1[:]))
  }
  out.to_string()
}

///|
/// The pairs a `Cookie` field value carries, in the order they arrived.
///
/// A piece with no `=` is dropped rather than read as a name with no value, which
/// is what §5.4 leaves a server free to do and what every server does. Duplicate
/// names are kept: which one wins is the reader's decision, and [`get`] makes the
/// one browsers make.
pub fn decode(raw : StringView) -> Array[(String, String)] {
  let out : Array[(String, String)] = []
  for piece in split(raw, ';') {
    match pair(piece[:]) {
      Some(pair) => out.push(pair)
      None => ()
    }
  }
  out
}

///|
/// The value of the cookie named `name` in a `Cookie` field value, or `None`.
///
/// The first of a repeated name wins, which is the value a browser sends first
/// and the one every server library reads.
pub fn get(raw : StringView, name : StringView) -> String? {
  let want = name.to_owned()
  for piece in split(raw, ';') {
    match pair(piece[:]) {
      Some((key, value)) => if key == want { return Some(value) }
      None => ()
    }
  }
  None
}

// ------------------------------------------------------------------- the pieces

///|
/// One `name=value`, or `None` when the piece has no `=`.
///
/// The spaces around the name and around the value belong to the separators, not
/// to either: a space is not a cookie-octet, so one that arrives around a value
/// was written by whoever laid the header out.
fn pair(piece : StringView) -> (String, String)? {
  let text = trim(piece)
  for i = 0; i < text.length(); i = i + 1 {
    if text[i] == '=' {
      return Some((trim(text[0:i]), trim(text[i + 1:])))
    }
  }
  None
}

///|
/// `s` split on `sep`, every piece kept as written.
fn split(s : StringView, sep : Char) -> Array[String] {
  let out : Array[String] = []
  let piece = StringBuilder()
  for c in s {
    if c == sep {
      out.push(piece.to_string())
      piece.reset()
    } else {
      piece.write_char(c)
    }
  }
  out.push(piece.to_string())
  out
}

///|
/// `s` without its leading and trailing spaces and tabs.
fn trim(s : StringView) -> String {
  let mut from = 0
  let mut upto = s.length()
  while from < upto && (s[from] == ' ' || s[from] == '\t') {
    from = from + 1
  }
  while upto > from && (s[upto - 1] == ' ' || s[upto - 1] == '\t') {
    upto = upto - 1
  }
  s[from:upto].to_owned()
}

///|
/// `s` in lowercase, for the attribute names and values §5.2 compares case-blind.
fn lower(s : StringView) -> String {
  let out = StringBuilder()
  for c in s {
    let code = c.to_int()
    out.write_char(
      if code >= 0x41 && code <= 0x5A {
        (code + 32).unsafe_to_char()
      } else {
        c
      },
    )
  }
  out.to_string()
}

///|
/// `s` without the octets a cookie-octet may not be: RFC 6265 §4.1.1 allows the
/// printable ASCII other than space, `"`, `,`, `;` and `\`.
///
/// Dropped, not escaped, because an escape the writer applies and the reader does
/// not know about is worse than a character that never survives.
fn safe(s : StringView) -> String {
  let out = StringBuilder()
  for c in s {
    let code = c.to_int()
    if code > 0x20 &&
      code < 0x7F &&
      code != 0x22 &&
      code != 0x2C &&
      code != 0x3B &&
      code != 0x5C {
      out.write_char(c)
    }
  }
  out.to_string()
}