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

///|
/// A cookie as it is kept: the RFC 6265bis state, not the wire form.
priv struct Stored {
  name : String
  value : String
  domain : String
  host_only : Bool
  path : String
  /// When it dies, in milliseconds since the epoch; `None` is a session cookie.
  expires : Int64?
  created : Int64
  secure : Bool
}

///|
/// How many cookies one domain may hold, and how many one jar keeps: the RFC's own advice
/// (§6.1), which is also what browsers settled on.
let per_domain : Int32 = 50

///|
let per_jar : Int32 = 3000

///|
/// A cookie store for a `Client` (RFC 6265bis).
///
/// One jar may be shared by several clients and by tasks: every change goes through a lock.
/// Persistence is not part of it — a jar lives as long as the process does.
pub struct Jar {
  priv lock : @async.Mutex
  priv store : Ref[Array[Stored]]
}

///|
/// An empty jar.
pub fn Jar::new() -> Jar {
  { lock: @async.Mutex(), store: Ref([]), }
}

///|
pub fn Jar::clear(self : Jar) -> Unit {
  self.store.val = []
}

///|
/// How many cookies the jar holds.
pub fn Jar::size(self : Jar) -> Int32 {
  self.store.val.length()
}

///|
fn ip_literal(host : String) -> Bool {
  if host.contains(":") {
    return true
  }
  let parts = host.split(".").to_array()
  if parts.length() != 4 {
    return false
  }
  for part in parts {
    guard part.length() > 0 && part.length() <= 3 else { return false }
    for c in part {
      guard c is ('0'..<='9') else { return false }
    }
  }
  true
}

///|
/// Whether a cookie set for `domain` may be sent to `host` (RFC 6265bis §5.1.3).
fn domain_match(host : String, domain : String) -> Bool {
  if host == domain {
    return true
  }
  guard !ip_literal(host) else { return false }
  host.has_suffix(".\{domain}")
}

///|
fn last_slash(path : StringView) -> Int32 {
  let mut at = -1
  for i in 0.. String {
  guard path.has_prefix("/") else { return "/" }
  let at = last_slash(path)
  if at <= 0 {
    return "/"
  }
  path[0:at].to_owned()
}

///|
/// Whether a cookie with `path` is sent on a request for `request` (§5.1.4).
fn path_match(request : StringView, path : StringView) -> Bool {
  if request == path {
    return true
  }
  guard request.has_prefix(path) else { return false }
  guard path.has_suffix("/") else {
    return request.length() > path.length() && request[path.length()] == '/'
  }
  true
}

///|
/// The domain a `Set-Cookie` names, lower-cased and without the leading dot, when it is one
/// the request may set.
fn cookie_domain(url : @moonurl.Url, named : String?) -> (String, Bool)? {
  let host = url.host.unwrap_or("").to_lower()
  guard host != "" else { return None }
  match named {
    None => Some((host, true))
    Some(text) => {
      let lower = text.to_lower()
      let domain = lower.strip_prefix(".").unwrap_or(lower).to_owned()
      guard domain != "" && !ip_literal(host) else { return None }
      guard domain_match(host, domain) else { return None }
      guard !is_public_suffix(domain) else { return None }
      Some((domain, false))
    }
  }
}

///|
/// Where the cookie sits in the jar, or `None`.
fn entry_at(
  kept : Array[Stored],
  name : String,
  domain : String,
  path : String,
) -> Int32? {
  for i, cookie in kept {
    if cookie.name == name && cookie.domain == domain && cookie.path == path {
      return Some(i)
    }
  }
  None
}

///|
/// Take the cookies out of a response (RFC 6265bis §5.3).
pub async fn Jar::store(
  self : Jar,
  url : @moonurl.Url,
  values : Array[String],
) -> Unit {
  guard values.length() > 0 else { return }
  let now = @async.now()
  let request_path = match url.path {
    "" => "/"
    path => path
  }
  self.lock.acquire()
  defer self.lock.release()
  let kept = self.store.val.copy()
  for value in values {
    let parsed = match @cookie.Cookie::decode(value) {
      Some(cookie) => cookie
      None => continue
    }
    guard parsed.name != "" else { continue }
    let (domain, host_only) = match cookie_domain(url, parsed.domain) {
      Some(pair) => pair
      None => continue
    }
    // A `__Host-` cookie is host-only, secure and rooted at `/`; `__Secure-` must be secure.
    guard !parsed.name.has_prefix("__Host-") ||
      (parsed.secure && host_only && parsed.path == Some("/")) else {
      continue
    }
    guard !parsed.name.has_prefix("__Secure-") || parsed.secure else {
      continue
    }
    let path = parsed.path.unwrap_or(default_path(request_path))
    let at = entry_at(kept, parsed.name, domain, path)
    let dead = match parsed.max_age {
      Some(age) =>
        if age <= 0 {
          Some(None)
        } else {
          Some(Some(now + age.to_int64() * 1000))
        }
      None =>
        match parsed.expires {
          Some(moment) =>
            match moment.epoch() {
              Some(epoch) => Some(Some(epoch * 1000))
              None => None
            }
          None => None
        }
    }
    // A `Max-Age` of zero or less deletes; an `Expires` already past deletes on the next sweep.
    match dead {
      Some(None) => {
        match at {
          Some(i) => ignore(kept.remove(i))
          None => ()
        }
        continue
      }
      _ => ()
    }
    let expires = match dead {
      Some(Some(at)) => Some(at)
      _ => None
    }
    let fresh = {
      name: parsed.name,
      value: parsed.value,
      domain,
      host_only,
      path,
      expires,
      created: match at {
        Some(i) => kept[i].created
        None => now
      },
      secure: parsed.secure,
    }
    match at {
      Some(i) => kept[i] = fresh
      None => kept.push(fresh)
    }
  }
  // Drop what has died, then the newest of a domain that is over its share.
  let live = kept.filter(c => {
    match c.expires {
      Some(at) => at > now
      None => true
    }
  })
  let trimmed : Array[Stored] = []
  for cookie in live {
    let mut same = 0
    for other in trimmed {
      if other.domain == cookie.domain {
        same += 1
      }
    }
    if same >= per_domain {
      continue
    }
    trimmed.push(cookie)
  }
  while trimmed.length() > per_jar {
    let mut oldest = 0
    for i in 1.. String {
  let host = url.host.unwrap_or("").to_lower()
  let path = match url.path {
    "" => "/"
    path => path
  }
  let secure = url.scheme is Some("https")
  let now = @async.now()
  self.lock.acquire()
  let held = self.store.val
  self.lock.release()
  let out = []
  for cookie in held {
    guard cookie.expires is None || cookie.expires.unwrap() > now else {
      continue
    }
    let fits = if cookie.host_only {
      host == cookie.domain
    } else {
      domain_match(host, cookie.domain)
    }
    if fits && path_match(path, cookie.path) && (!cookie.secure || secure) {
      out.push(cookie)
    }
  }
  out.sort_by((a, b) => {
    if a.path.length() != b.path.length() {
      b.path.length() - a.path.length()
    } else if a.created < b.created {
      -1
    } else if a.created > b.created {
      1
    } else {
      0
    }
  })
  let header = StringBuilder()
  let mut first = true
  for cookie in out {
    if !first {
      header.write_string("; ")
    }
    first = false
    header.write_string("\{cookie.name}=\{cookie.value}")
  }
  header.to_string()
}