///|
/// HTTP Cookie Jar — RFC 6265 compliant cookie storage
///
/// SECURITY: Cookie support is DISABLED by default.
/// Enable via CookieJar::new(enabled=true) only for testing or when explicitly needed.

///|
/// Parsed Set-Cookie attributes
pub(all) struct ParsedCookie {
  name : String
  value : String
  domain : String
  path : String
  secure : Bool
  http_only : Bool
  same_site : String // "strict" | "lax" | "none" | ""
  max_age : Int? // seconds
  expires : String? // raw date string
} derive(Debug)

///|
/// Cookie Jar — stores cookies per-domain with security enforcement
pub struct CookieJar {
  cookies : Array[ParsedCookie]
  /// SECURITY: When false (default), all operations are no-ops
  enabled : Bool
}

///|
pub fn CookieJar::new(enabled? : Bool = false) -> CookieJar {
  { cookies: [], enabled }
}

///|
pub impl Show for ParsedCookie with fn output(self, logger) {
  logger.write_string("{name: ")
  self.name.output(logger)
  logger.write_string(", value: ")
  self.value.output(logger)
  logger.write_string(", domain: ")
  self.domain.output(logger)
  logger.write_string(", path: ")
  self.path.output(logger)
  logger.write_string(", secure: ")
  self.secure.output(logger)
  logger.write_string(", http_only: ")
  self.http_only.output(logger)
  logger.write_string(", same_site: ")
  self.same_site.output(logger)
  logger.write_string(", max_age: ")
  logger.write_object(to_repr(self.max_age))
  logger.write_string(", expires: ")
  logger.write_object(to_repr(self.expires))
  logger.write_string("}")
}

///|
/// Parse a Set-Cookie header value into a ParsedCookie.
/// Returns None if the cookie is malformed.
pub fn parse_set_cookie(
  header : String,
  request_domain : String,
  request_path : String,
) -> ParsedCookie? {
  if header.is_empty() {
    return None
  }
  // Split name=value from attributes
  let parts = header.split(";")
  let mut first = true
  let mut name = ""
  let mut value = ""
  let mut domain = ""
  let mut path = ""
  let mut secure = false
  let mut http_only = false
  let mut same_site = ""
  let mut max_age : Int? = None
  let mut expires : String? = None
  let mut domain_set = false
  let mut path_set = false
  for part in parts {
    let trimmed = part.trim(chars=" ").to_owned()
    if first {
      first = false
      // name=value pair
      match trimmed.find("=") {
        Some(eq_idx) => {
          name = trimmed[:eq_idx].trim().to_owned()
          value = trimmed[eq_idx + 1:].trim().to_owned()
        }
        None =>
          // Cookie with no value
          name = trimmed
      }
      // Empty cookie name is invalid (RFC 6265 §5.2)
      if name.is_empty() {
        return None
      }
      continue
    }
    // Parse attributes
    let lower = trimmed.to_lower()
    if lower == "secure" {
      secure = true
    } else if lower == "httponly" {
      http_only = true
    } else if lower.has_prefix("domain=") {
      let d = trimmed[7:].trim().to_owned().to_lower()
      // Strip leading dot (RFC 6265 §5.2.3)
      domain = if d.has_prefix(".") { d[1:].to_owned() } else { d }
      domain_set = true
    } else if lower.has_prefix("path=") {
      path = trimmed[5:].trim().to_owned()
      path_set = true
    } else if lower.has_prefix("max-age=") {
      let val = trimmed[8:].trim().to_owned()
      max_age = parse_cookie_int(val)
    } else if lower.has_prefix("expires=") {
      expires = Some(trimmed[8:].trim().to_owned())
    } else if lower.has_prefix("samesite=") {
      same_site = trimmed[9:].trim().to_owned().to_lower()
    }
  }
  // Default domain to request domain if not set
  if !domain_set {
    domain = request_domain.to_lower()
  }
  // Default path per RFC 6265 §5.1.4
  if !path_set {
    path = cookie_default_path(request_path)
  }
  // Domain validation: cookie domain must be a suffix of request domain
  // or equal (RFC 6265 §5.3 step 6)
  if domain_set {
    let req_lower = request_domain.to_lower()
    if req_lower != domain && !req_lower.has_suffix("." + domain) {
      return None // Reject: domain does not match request
    }
  }
  Some({
    name,
    value,
    domain,
    path,
    secure,
    http_only,
    same_site,
    max_age,
    expires,
  })
}

///|
/// Get the default cookie path from a request path (RFC 6265 §5.1.4)
fn cookie_default_path(request_path : String) -> String {
  if request_path.is_empty() || !request_path.has_prefix("/") {
    return "/"
  }
  // Find last '/' (excluding trailing)
  let mut last_slash = 0
  for i = 1; i < request_path.length(); i = i + 1 {
    if request_path[i] == '/' {
      last_slash = i
    }
  }
  if last_slash == 0 {
    "/"
  } else {
    request_path[:last_slash].to_owned()
  }
}

///|
/// Parse an integer for cookie max-age (allows negative values)
fn parse_cookie_int(s : String) -> Int? {
  if s.is_empty() {
    return None
  }
  let mut result = 0
  let mut negative = false
  let mut start = 0
  if s[0] == '-' {
    negative = true
    start = 1
  }
  if start >= s.length() {
    return None
  }
  for i = start; i < s.length(); i = i + 1 {
    let c = s[i]
    if c >= '0' && c <= '9' {
      result = result * 10 + (c.to_int() - '0'.to_int())
    } else {
      return None
    }
  }
  if negative {
    Some(-result)
  } else {
    Some(result)
  }
}

///|
/// Store a cookie from a Set-Cookie header.
/// Enforces: domain validation, secure flag, httpOnly flag.
pub fn CookieJar::store_from_header(
  self : CookieJar,
  set_cookie_header : String,
  request_url : String,
  is_secure_origin : Bool,
) -> Unit {
  if !self.enabled {
    return
  }
  let req_domain = cookie_extract_domain(request_url)
  let req_path = cookie_extract_path(request_url)
  let cookie = match parse_set_cookie(set_cookie_header, req_domain, req_path) {
    Some(c) => c
    None => return
  }
  // Security: Secure cookies only from secure origins
  if cookie.secure && !is_secure_origin {
    return
  }
  // Security: __Secure- prefix requires Secure flag (RFC 6265bis §4.1.3)
  if cookie.name.has_prefix("__Secure-") && !cookie.secure {
    return
  }
  // Security: __Host- prefix requires Secure, no explicit Domain, Path=/ (RFC 6265bis §4.1.3)
  if cookie.name.has_prefix("__Host-") {
    if !cookie.secure || cookie.path != "/" || cookie.domain != req_domain {
      // __Host- cookies must not have Domain attribute set (domain == request domain only)
      return
    }
  }
  // Security: SameSite=None requires Secure flag (RFC 6265bis)
  if cookie.same_site == "none" && !cookie.secure {
    return
  }
  // max-age=0 or negative: delete cookie
  match cookie.max_age {
    Some(age) if age <= 0 => {
      self.remove_cookie(cookie.name, cookie.domain, cookie.path)
      return
    }
    _ => ()
  }
  // Replace existing cookie with same name+domain+path
  self.remove_cookie(cookie.name, cookie.domain, cookie.path)
  self.cookies.push(cookie)
}

///|
/// Get cookies for a request URL as "Cookie: name=value; name2=value2" pairs.
/// Enforces: domain matching, path matching, secure flag, httpOnly, SameSite.
///
/// `site_origin`: the origin of the page making the request (for SameSite checks).
///   Empty string means "same-site" (no cross-site enforcement).
/// `is_top_level_navigation`: true for top-level navigations (SameSite=Lax allows these).
pub fn CookieJar::get_cookie_header(
  self : CookieJar,
  request_url : String,
  is_secure : Bool,
  is_http_api : Bool,
  site_origin? : String = "",
  is_top_level_navigation? : Bool = false,
) -> String? {
  if !self.enabled {
    return None
  }
  let req_domain = cookie_extract_domain(request_url)
  let req_path = cookie_extract_path(request_url)
  let cross_site = if site_origin.is_empty() {
    false
  } else {
    let site_domain = cookie_extract_domain(site_origin)
    !cookie_same_site(site_domain, req_domain)
  }
  let buf = StringBuilder::new()
  let mut first = true
  for cookie in self.cookies {
    // Domain match (RFC 6265 §5.4)
    if !cookie_domain_matches(req_domain, cookie.domain) {
      continue
    }
    // Path match (RFC 6265 §5.4)
    if !cookie_path_matches(req_path, cookie.path) {
      continue
    }
    // Secure cookie only over secure channel
    if cookie.secure && !is_secure {
      continue
    }
    // httpOnly cookies only sent in HTTP APIs (not accessible to JS)
    if cookie.http_only && !is_http_api {
      continue
    }
    // SameSite enforcement (RFC 6265bis)
    if cross_site {
      let effective_same_site = if cookie.same_site.is_empty() {
        "lax" // Default is Lax per modern browsers
      } else {
        cookie.same_site
      }
      match effective_same_site {
        "strict" => continue // Never sent cross-site
        "lax" =>
          // Lax: only sent on top-level navigation (GET)
          if !is_top_level_navigation {
            continue
          }
        "none" =>
          // SameSite=None requires Secure flag
          if !cookie.secure {
            continue
          }
        _ =>
          // Unknown value → treat as Lax per WHATWG RFC 6265bis
          if !is_top_level_navigation {
            continue
          }
      }
    }
    if !first {
      buf.write_string("; ")
    }
    first = false
    buf.write_string(cookie.name)
    buf.write_char('=')
    buf.write_string(cookie.value)
  }
  let result = buf.to_string()
  if result.is_empty() {
    None
  } else {
    Some(result)
  }
}

///|
/// Check if two domains are "same-site" (share the same registrable
/// domain). Backed by the curated Public Suffix List in `http/psl`, so
/// `example.co.uk` and `other.co.uk` correctly classify as different
/// sites instead of collapsing under the last-two-labels heuristic.
fn cookie_same_site(domain_a : String, domain_b : String) -> Bool {
  let a = domain_a.to_lower()
  let b = domain_b.to_lower()
  if a == b {
    return true
  }
  let reg_a = @psl.registrable_domain(a)
  let reg_b = @psl.registrable_domain(b)
  reg_a == reg_b && reg_a.length() > 0
}

///|
/// Remove a cookie by name+domain+path
fn CookieJar::remove_cookie(
  self : CookieJar,
  name : String,
  domain : String,
  path : String,
) -> Unit {
  let mut i = 0
  while i < self.cookies.length() {
    let c = self.cookies[i]
    if c.name == name && c.domain == domain && c.path == path {
      self.cookies.remove(i) |> ignore
    } else {
      i = i + 1
    }
  }
}

///|
/// Clear all cookies
pub fn CookieJar::clear(self : CookieJar) -> Unit {
  self.cookies.clear()
}

///|
/// Get number of stored cookies
pub fn CookieJar::size(self : CookieJar) -> Int {
  self.cookies.length()
}

///|
/// Domain match per RFC 6265 §5.1.3
fn cookie_domain_matches(
  request_domain : String,
  cookie_domain : String,
) -> Bool {
  let req = request_domain.to_lower()
  let cd = cookie_domain.to_lower()
  if req == cd {
    return true
  }
  // request domain must end with ".cookiedomain"
  req.has_suffix("." + cd)
}

///|
/// Path match per RFC 6265 §5.1.4
fn cookie_path_matches(request_path : String, cookie_path : String) -> Bool {
  if request_path == cookie_path {
    return true
  }
  if request_path.has_prefix(cookie_path) {
    // Cookie path must end with / or request path must have / after cookie path
    if cookie_path.has_suffix("/") {
      return true
    }
    if request_path.length() > cookie_path.length() &&
      request_path[cookie_path.length()] == '/' {
      return true
    }
  }
  false
}

///|
/// Extract domain from URL
fn cookie_extract_domain(url : String) -> String {
  let host = match url.find("://") {
    Some(i) => {
      let after = url[i + 3:].to_owned()
      let end = match after.find("/") {
        Some(j) => j
        None => after.length()
      }
      let host_port = after[:end].to_owned()
      // Remove port
      match host_port.find(":") {
        Some(j) => host_port[:j].to_owned().to_lower()
        None => host_port.to_lower()
      }
    }
    None => url.to_lower()
  }
  // Strip a single trailing dot — FQDN forms like "example.com." are
  // equivalent to "example.com" for site comparison.
  if host.has_suffix(".") && host.length() > 1 {
    host.unsafe_substring(start=0, end=host.length() - 1)
  } else {
    host
  }
}

///|
/// Extract path from URL
fn cookie_extract_path(url : String) -> String {
  match url.find("://") {
    Some(i) => {
      let after = url[i + 3:].to_owned()
      match after.find("/") {
        Some(j) => after[j:].to_owned()
        None => "/"
      }
    }
    None => "/"
  }
}