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

///|
/// One metadata entry on a baggage member: `key` or `key=value`, after the `;` of
/// a list-member (W3C Baggage §3.2.2.3). The specification gives properties no
/// meaning of their own; they are carried so a hop that does not understand one
/// can pass it on unchanged.
#derive(Eq, Debug)
pub(all) struct Property {
  key : String
  /// `None` for a property written as a bare key.
  value : String?
}

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

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

///|
/// One baggage entry: a key, a value, and any properties attached to it.
///
/// The value is stored decoded, as text. The wire form is percent-encoded ASCII,
/// and decoding is where a hop stops caring how the value was spelled.
#derive(Eq, Debug)
pub(all) struct Member {
  key : String
  value : String
  properties : Array[Property]
}

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

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

///|
/// Keys and values carried alongside a trace, ordered, with duplicates allowed.
///
/// Order is part of the data: W3C Baggage asks a hop to preserve the order of
/// duplicate entries when it mutates the list, so this is an array rather than a
/// map. `get` answers with the first member carrying a key, which is the one a
/// reader asking "what did the caller say" wants.
#derive(Eq, Debug)
pub(all) struct Baggage {
  members : Array[Member]
}

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

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

///|
/// Nothing carried, which a request with no `baggage` header arrives with.
pub let empty_baggage : Baggage = { members: [], }

///|
/// The list-members W3C Baggage §3.2.1.3 requires a platform to propagate: a
/// header of 64 members or fewer must survive the hop whole.
pub let baggage_member_limit : Int32 = 64

///|
/// The size W3C Baggage §3.2.1.3 requires a platform to propagate: 8192 octets of
/// combined header.
pub let baggage_byte_limit : Int32 = 8192

///|
/// A bag carrying `members`, in the order given.
pub fn Baggage::new(members~ : Array[Member] = []) -> Baggage {
  { members, }
}

///|
/// How many members the bag carries.
pub fn Baggage::len(self : Baggage) -> Int32 {
  self.members.length()
}

///|
/// Whether the bag has no members.
pub fn Baggage::is_empty(self : Baggage) -> Bool {
  self.members.is_empty()
}

///|
/// The value of the first member keyed `key`, or `None`.
pub fn Baggage::get(self : Baggage, key : String) -> String? {
  for entry in self.members {
    if entry.key == key {
      return Some(entry.value)
    }
  }
  None
}

///|
/// The same bag with `member` added at the end, past the member limit keeping the
/// bag as it was rather than growing an unusable header.
pub fn Baggage::add(self : Baggage, entry : Member) -> Baggage {
  if self.members.length() >= baggage_member_limit {
    return self
  }
  let members = self.members.copy()
  members.push(entry)
  { members, }
}

///|
/// Parse a `baggage` header value (W3C Baggage §3.2).
///
/// Whitespace around a member, around an `=`, and around the commas is dropped;
/// a member that does not match the grammar refuses the whole header, because a
/// half-read bag is a bag the next hop would rewrite. Values are percent-decoded
/// and sequences that are not UTF-8 become `U+FFFD`, as §3.2.2.2 asks.
///
/// The limits are the ones a platform must propagate — 64 members and 8192 octets
/// — checked against the value as it arrived, and enforced by refusing rather than
/// truncating: a caller that receives a bag too big to be legal has received
/// something a middlebox either wrote or mangled, and guessing which members it
/// meant is not this library's business.
pub fn Baggage::parse(input : StringView) -> Baggage raise @trace.Refused {
  let members : Array[Member] = []
  let bytes = @utf8.encode(input)
  if bytes.length() > baggage_byte_limit {
    raise @trace.Refused::TooLong(limit=baggage_byte_limit, got=bytes.length())
  }
  let mut start = 0
  let mut i = 0
  let n = input.length()
  while i <= n {
    if i == n || input[i].to_int() == 0x2c {
      let (lo, hi) = trim(input, start, i)
      if lo < hi {
        // The byte limit is on the header, the member limit on the list: both are
        // checked as the list is built so that a header of a thousand empty
        // members is refused too.
        if members.length() >= baggage_member_limit {
          raise @trace.Refused::TooMany(
            limit=baggage_member_limit,
            got=members.length() + 1,
          )
        }
        members.push(parse_member(input, lo, hi))
      }
      start = i + 1
    }
    i = i + 1
  }
  { members, }
}

///|
/// The header value: members in order, `key=value` with properties after `;`, and
/// every character outside the `baggage-octet` set percent-encoded in uppercase
/// hexadecimal (W3C Baggage §3.2.2.2). Empty when the bag is empty, which on the
/// wire is the same as sending no header. `encode_value` says why a space is `%20`
/// rather than the `+` form encoding would use.
pub fn Baggage::to_header(self : Baggage) -> String {
  let out = StringBuilder()
  for i, entry in self.members {
    if i > 0 {
      out.write_char(',')
    }
    out.write_string(entry.key)
    out.write_char('=')
    encode_value(entry.value, out)
    for property in entry.properties {
      out.write_char(';')
      out.write_string(property.key)
      match property.value {
        Some(value) => {
          out.write_char('=')
          encode_value(value, out)
        }
        None => ()
      }
    }
  }
  out.to_string()
}

// ------------------------------------------------------------------ internals

///|
/// One list-member over `s[start.. Member raise @trace.Refused {
  // A `;` cannot appear inside a value — it is not a `baggage-octet` — so the
  // first one ends the key and value part.
  let mut semicolon = None
  let mut i = start
  while i < end {
    if s[i].to_int() == 0x3b {
      semicolon = Some(i)
      break
    }
    i = i + 1
  }
  let head_end = match semicolon {
    Some(position) => position
    None => end
  }
  let mut equals = None
  let mut j = start
  while j < head_end {
    if s[j].to_int() == 0x3d {
      equals = Some(j)
      break
    }
    j = j + 1
  }
  let position = match equals {
    Some(position) => position
    None => raise @trace.Refused::Field(at=start, want="key=value")
  }
  let (key_lo, key_hi) = trim(s, start, position)
  check_key(s, key_lo, key_hi)
  let (value_lo, value_hi) = trim(s, position + 1, head_end)
  check_value(s, value_lo, value_hi)
  let properties : Array[Property] = []
  if semicolon is Some(position) {
    // Properties are `;`-separated to the end of the member, and a `;` cannot
    // appear inside a value, so scanning for the next one is unambiguous.
    let mut from = position + 1
    while from < end {
      let mut next = from
      while next < end && s[next].to_int() != 0x3b {
        next = next + 1
      }
      let (lo, hi) = trim(s, from, next)
      if lo < hi {
        properties.push(parse_property(s, lo, hi))
      }
      from = next + 1
    }
  }
  {
    key: s.exact_view(start=key_lo, end=key_hi).to_owned(),
    value: percent_decode(s.exact_view(start=value_lo, end=value_hi)),
    properties,
  }
}

///|
/// One property over `s[start.. Property raise @trace.Refused {
  let mut equals = None
  let mut i = start
  while i < end {
    if s[i].to_int() == 0x3d {
      equals = Some(i)
      break
    }
    i = i + 1
  }
  match equals {
    None => {
      let (lo, hi) = trim(s, start, end)
      check_key(s, lo, hi)
      { key: s.exact_view(start=lo, end=hi).to_owned(), value: None, }
    }
    Some(position) => {
      let (key_lo, key_hi) = trim(s, start, position)
      check_key(s, key_lo, key_hi)
      let (value_lo, value_hi) = trim(s, position + 1, end)
      check_value(s, value_lo, value_hi)
      {
        key: s.exact_view(start=key_lo, end=key_hi).to_owned(),
        value: Some(percent_decode(s.exact_view(start=value_lo, end=value_hi))),
      }
    }
  }
}

///|
/// A key is an RFC 7230 §3.2.6 `token`: one or more `tchar`.
fn check_key(
  s : StringView,
  start : Int32,
  end : Int32,
) -> Unit raise @trace.Refused {
  if start == end {
    raise @trace.Refused::Field(at=start, want="a baggage key")
  }
  let mut i = start
  while i < end {
    let c = s[i].to_int()
    let is_tchar = (c >= 0x30 && c <= 0x39) ||
      (c >= 0x41 && c <= 0x5a) ||
      (c >= 0x61 && c <= 0x7a) ||
      c == 0x21 ||
      c == 0x23 ||
      c == 0x24 ||
      c == 0x25 ||
      c == 0x26 ||
      c == 0x27 ||
      c == 0x2a ||
      c == 0x2b ||
      c == 0x2d ||
      c == 0x2e ||
      c == 0x5e ||
      c == 0x5f ||
      c == 0x60 ||
      c == 0x7c ||
      c == 0x7e
    if !is_tchar {
      raise @trace.Refused::Field(at=i, want="a baggage key character")
    }
    i = i + 1
  }
}

///|
/// A value is zero or more `baggage-octet`, which may be empty: `k=` is a member
/// whose value is the empty string.
fn check_value(
  s : StringView,
  start : Int32,
  end : Int32,
) -> Unit raise @trace.Refused {
  let mut i = start
  while i < end {
    if !is_baggage_octet(s[i].to_int()) {
      raise @trace.Refused::Field(at=i, want="a baggage value character")
    }
    i = i + 1
  }
}

///|
/// `%x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E`: printable ASCII without the
/// double quote, comma, semicolon and backslash (W3C Baggage §3.2.2.1).
fn is_baggage_octet(c : Int32) -> Bool {
  if c == 0x21 {
    return true
  }
  if c >= 0x23 && c <= 0x2b {
    return true
  }
  if c >= 0x2d && c <= 0x3a {
    return true
  }
  if c >= 0x3c && c <= 0x5b {
    return true
  }
  c >= 0x5d && c <= 0x7e
}

///|
/// Percent-decode a value (RFC 3986 §2.1), leaving everything else as the UTF-8 it
/// arrived as. A sequence that does not decode to UTF-8 becomes `U+FFFD`, which is
/// what W3C Baggage §3.2.2.2 asks for rather than refusing the header.
fn percent_decode(text : StringView) -> String {
  let raw = @utf8.encode(text)
  let out : Array[Byte] = []
  let n = raw.length()
  let mut i = 0
  while i < n {
    let byte = raw[i]
    if byte == b'%' && i + 2 < n {
      let high = hex_value(raw[i + 1])
      let low = hex_value(raw[i + 2])
      match (high, low) {
        (Some(high), Some(low)) => {
          out.push(((high << 4) | low).to_byte())
          i = i + 3
          continue
        }
        _ => ()
      }
    }
    out.push(byte)
    i = i + 1
  }
  @utf8.decode_lossy(Bytes::from_array(out))
}

///|
/// One hexadecimal digit of either case, or `None`.
fn hex_value(byte : Byte) -> Int32? {
  let c = byte.to_int()
  if c >= 0x30 && c <= 0x39 {
    Some(c - 0x30)
  } else if c >= 0x41 && c <= 0x46 {
    Some(c - 0x41 + 10)
  } else if c >= 0x61 && c <= 0x66 {
    Some(c - 0x61 + 10)
  } else {
    None
  }
}

///|
/// Percent-encode a value for the wire: every octet outside `baggage-octet`, and
/// `%` itself, in uppercase hexadecimal.
///
/// A space becomes `%20` and not `+`. Form encoding has spent decades writing a
/// space as `+`, and one reference implementation of this header still does, but
/// W3C Baggage §3.2.2.2 allows only the two escapes that keep a value unambiguous:
/// `+` is itself a `baggage-octet` and means a literal plus, so a hop that read
/// `DF+28` as "DF 28" would be inventing a space the sender never wrote, and a hop
/// that read it as "DF+28" would disagree with the one that wrote it. `%20` says
/// exactly one thing to everybody, which is why the specification asks for it and
/// why `percent_decode` reads only `%`.
///
/// Values outside `baggage-octet` are not the only case: a value that already holds
/// a `%` gets it escaped, or the next hop would read the following two characters as
/// an escape the sender never wrote.
fn encode_value(value : String, out : StringBuilder) -> Unit {
  let raw = @utf8.encode(value)
  for byte in raw {
    let c = byte.to_int()
    if is_baggage_octet(c) && c != 0x25 {
      out.write_char(c.unsafe_to_char())
    } else {
      out.write_char('%')
      out.write_char(hex_char(c >> 4))
      out.write_char(hex_char(c & 0x0f))
    }
  }
}

///|
fn hex_char(value : Int32) -> Char {
  if value < 10 {
    (0x30 + value).unsafe_to_char()
  } else {
    (0x41 + value - 10).unsafe_to_char()
  }
}