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

///|
/// An entity-tag (RFC 9110 §8.8.3).
///
/// `text` is what is inside the quotes; keeping them would put the field's
/// syntax inside the value.
///
/// `weak` is the `W/` in front. Two weak tags mark representations that are
/// equivalent, not identical — enough for "has this changed", not enough for
/// "may I splice these two ranges".
pub(all) struct Tag {
  text : String
  weak : Bool
} derive(Eq, Debug)

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

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

///|
/// What a precondition field names (§13.1.1): every representation, or these
/// tags.
///
/// `*` is not a tag that matches everything. §13.1.1 and §13.1.2 both read it as
/// "does a representation exist", and answer in opposite directions.
pub(all) enum Tags {
  Any
  These(Array[Tag])
} derive(Eq, Debug)

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

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

///|
/// Either kind of validator (§8.8). `If-Range` takes one or the other
/// (§13.1.5); the other four fields each take one kind.
pub(all) enum Validator {
  Marked(Tag)
  Dated(@moondate.Moment)
} derive(Eq, Debug)

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

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

///|
/// What §13.2.2 says to do with the request once the preconditions are weighed.
pub(all) enum Verdict {
  /// No precondition failed: carry on with the method.
  Go
  /// The client's copy is still good — 304, with no body (§15.4.5).
  Fresh
  /// A precondition failed — 412 (§15.5.13).
  Failed
} derive(Eq, Debug)

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

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

///|
/// A tag with this text.
pub fn Tag::new(text : String, weak? : Bool = false) -> Tag {
  { text, weak, }
}

// ----------------------------------------------------------------- comparing

///|
/// Strong comparison (§8.8.3.2): neither is weak and the texts match.
///
/// `If-Match` and `If-Range` use this one — both are about replacing or splicing
/// octets, which a weak tag does not promise.
pub fn Tag::same(self : Tag, other : Tag) -> Bool {
  !self.weak && !other.weak && self.text == other.text
}

///|
/// Weak comparison (§8.8.3.2): the texts match, weakness ignored.
///
/// `If-None-Match` uses this one — a weak tag can answer "has it changed".
pub fn Tag::alike(self : Tag, other : Tag) -> Bool {
  self.text == other.text
}

// ---------------------------------------------------------------- generating

///|
/// The hexadecimal both generators write their numbers in.
let hex : @moonbase.Alphabet = @moonbase.Alphabet::of("0123456789abcdef")

///|
/// An entity-tag from the octets themselves: their count and their digest, both
/// in hexadecimal. Strong, which is the point of hashing the content.
///
/// `digest` is a parameter — which hash to spend on every response is the
/// server's trade. The shape is the `etag` package Express serves static files
/// with, `"-"`, in nginx and Apache's hexadecimal.
pub fn hash(
  raw : BytesView,
  digest~ : &@spec.Hash,
  weak? : Bool = false,
) -> Tag {
  digest.reset()
  digest.write(raw)
  let text = number(raw.length().to_int64()) +
    "-" +
    @base16.encode(digest.finish()[:])
  { text, weak, }
}

///|
/// An entity-tag from what a file system knows without opening the file: when
/// it last changed and how long it is. nginx's `"-"`, one stat.
///
/// Weak, as a one-second timestamp must be: two writes inside the same second
/// leave both numbers unchanged.
pub fn stamp(
  length~ : Int64,
  modified~ : @moondate.Moment,
  weak? : Bool = true,
) -> Tag {
  let seconds = match modified.epoch() {
    Some(epoch) => epoch
    None => 0L
  }
  { text: number(seconds) + "-" + number(length), weak, }
}

///|
fn number(value : Int64) -> String {
  let value = if value < 0L { 0L } else { value }
  @moonbase.encode_int(value.reinterpret_as_uint64(), hex)
}

// ------------------------------------------------------- fields, both ways

///|
/// Write the tag as a field value: `"x"`, or `W/"x"` (§8.8.3).
pub fn Tag::encode(self : Tag) -> String {
  if self.weak {
    "W/\"" + self.text + "\""
  } else {
    "\"" + self.text + "\""
  }
}

///|
/// Read one entity-tag. `None` when it is not one — usually an unquoted value,
/// which §8.8.3 has no room for.
pub fn Tag::decode(text : StringView) -> Tag? {
  let text = trim(text)
  let (text, weak) = if text.length() >= 2 &&
    text[0].to_int() == 0x57 &&
    text[1].to_int() == 0x2F {
    (text[2:], true)
  } else {
    (text, false)
  }
  if text.length() < 2 ||
    text[0].to_int() != 0x22 ||
    text[text.length() - 1].to_int() != 0x22 {
    return None
  }
  Some({ text: text[1:text.length() - 1].to_owned(), weak, })
}

///|
/// Write a whole field value: `*`, or the tags separated by commas.
pub fn Tags::encode(self : Tags) -> String {
  match self {
    Any => "*"
    These(tags) => {
      let out = StringBuilder()
      for i = 0; i < tags.length(); i = i + 1 {
        if i > 0 {
          out.write_string(", ")
        }
        out.write_string(tags[i].encode())
      }
      out.to_string()
    }
  }
}

///|
/// Read a whole field value.
///
/// Commas are found outside the quotes: `,` is in the `etagc` set (§8.8.3), so a
/// tag may contain one. `None` when any member is not an entity-tag — §13.1.1
/// has no reading for half a list.
pub fn Tags::decode(text : StringView) -> Tags? {
  let text = trim(text)
  if text.length() == 0 {
    return None
  }
  if text == "*" {
    return Some(Any)
  }
  let tags = []
  let mut start = 0
  let mut quoted = false
  for i = 0; i < text.length(); i = i + 1 {
    let c = text[i].to_int()
    if c == 0x22 {
      quoted = !quoted
    } else if c == 0x2C && !quoted {
      guard Tag::decode(text[start:i]) is Some(tag) else { return None }
      tags.push(tag)
      start = i + 1
    }
  }
  guard Tag::decode(text[start:]) is Some(tag) else { return None }
  tags.push(tag)
  Some(These(tags))
}

///|
/// Read an `If-Range` value (§13.1.5).
///
/// The first two characters tell them apart, as §13.1.5 says: only an
/// entity-tag starts with a quote, only a weak one with `W/`.
pub fn Validator::decode(text : StringView) -> Validator? {
  let text = trim(text)
  if text.length() == 0 {
    return None
  }
  if text[0].to_int() == 0x22 ||
    (text.length() >= 2 && text[0].to_int() == 0x57 && text[1].to_int() == 0x2F) {
    return match Tag::decode(text) {
      Some(tag) => Some(Marked(tag))
      None => None
    }
  }
  Some(Dated(@moondate.http(text))) catch {
    _ => None
  }
}

///|
/// Write an `If-Range` value. Raises for a moment with no zone — an HTTP-date
/// is GMT by definition (§5.6.7).
pub fn Validator::encode(self : Validator) -> String raise @moondate.Refused {
  match self {
    Marked(tag) => tag.encode()
    Dated(moment) => moment.http_text()
  }
}

// ------------------------------------------------------------ what was asked

///|
/// The five precondition fields of one request (§13.1), already read.
pub(all) struct Ask {
  /// `If-Match` (§13.1.1).
  matching : Tags?
  /// `If-None-Match` (§13.1.2).
  none_matching : Tags?
  /// `If-Modified-Since` (§13.1.3).
  modified_since : @moondate.Moment?
  /// `If-Unmodified-Since` (§13.1.4).
  unmodified_since : @moondate.Moment?
  /// `If-Range` (§13.1.5).
  ranging : Validator?
} derive(Eq, Debug)

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

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

///|
/// A request that carried no preconditions at all.
pub let ask : Ask = {
  matching: None,
  none_matching: None,
  modified_since: None,
  unmodified_since: None,
  ranging: None,
}

///|
/// The preconditions a request carried, named one at a time.
pub fn Ask::new(
  matching? : Tags,
  none_matching? : Tags,
  modified_since? : @moondate.Moment,
  unmodified_since? : @moondate.Moment,
  ranging? : Validator,
) -> Ask {
  { matching, none_matching, modified_since, unmodified_since, ranging, }
}

///|
/// Pull the five fields out of a request's headers.
///
/// Names match without regard to case (§5.1). Two lines of one list-valued field
/// are one list (§5.3), so the tag fields are joined before reading; the date
/// fields are not lists, so a repeat is ignored.
///
/// A field present but unreadable comes back absent, per §13.1.3 and §13.1.4.
pub fn Ask::read(headers : ArrayView[(String, String)]) -> Ask {
  let matching = StringBuilder()
  let none_matching = StringBuilder()
  let mut modified_since = None
  let mut unmodified_since = None
  let mut ranging = None
  for pair in headers {
    match lower(pair.0) {
      "if-match" => join(matching, pair.1)
      "if-none-match" => join(none_matching, pair.1)
      "if-modified-since" =>
        if modified_since is None {
          modified_since = date(pair.1)
        }
      "if-unmodified-since" =>
        if unmodified_since is None {
          unmodified_since = date(pair.1)
        }
      "if-range" => if ranging is None { ranging = Validator::decode(pair.1) }
      _ => ()
    }
  }
  {
    matching: Tags::decode(matching.to_string()),
    none_matching: Tags::decode(none_matching.to_string()),
    modified_since,
    unmodified_since,
    ranging,
  }
}

///|
fn join(out : StringBuilder, value : String) -> Unit {
  if out.to_string().length() > 0 {
    out.write_string(", ")
  }
  out.write_string(value)
}

///|
fn date(value : String) -> @moondate.Moment? {
  Some(@moondate.http(value)) catch {
    _ => None
  }
}

// -------------------------------------------------------------- the verdict

///|
/// Weigh the preconditions in the order §13.2.2 sets out.
///
/// `verb` is the request method (`method` is reserved). It decides what a failed
/// `If-None-Match` means: 304 for a `GET` or `HEAD`, 412 otherwise. No default —
/// guessing `GET` would tell a `PUT` its copy is current.
///
/// `etag` and `modified` are what the server holds; `present` is whether it
/// holds anything at all, which is all `*` asks.
pub fn Ask::evaluate(
  self : Ask,
  verb~ : String,
  etag? : Tag,
  modified? : @moondate.Moment,
  present? : Bool = true,
) -> Verdict {
  let reading = verb == "GET" || verb == "HEAD"
  match self.matching {
    Some(matching) => if !matches(matching, etag, present) { return Failed }
    None =>
      match self.unmodified_since {
        Some(since) => if changed(modified, since) { return Failed }
        None => ()
      }
  }
  match self.none_matching {
    Some(none_matching) =>
      if matches_weakly(none_matching, etag, present) {
        return if reading { Fresh } else { Failed }
      }
    None =>
      if reading {
        match self.modified_since {
          Some(since) => if !changed(modified, since) { return Fresh }
          None => ()
        }
      }
  }
  Go
}

///|
/// Whether `If-Range` still holds, and so whether a `Range` may be honoured
/// (§13.1.5). Absent holds: no condition, nothing in the way.
///
/// Both kinds compare strongly, and a weak tag is refused rather than compared —
/// "may these octets be spliced onto yours" is what a weak tag may not answer.
pub fn Ask::unchanged(
  self : Ask,
  etag? : Tag,
  modified? : @moondate.Moment,
) -> Bool {
  match self.ranging {
    None => true
    Some(Marked(given)) =>
      match etag {
        Some(etag) => given.same(etag)
        None => false
      }
    Some(Dated(given)) =>
      match (modified.bind(fn(m) { m.epoch() }), given.epoch()) {
        (Some(ours), Some(theirs)) => ours == theirs
        _ => false
      }
  }
}

///|
/// `If-Match` (§13.1.1): `*` asks whether anything is there, a list whether one
/// member strongly equals what is.
fn matches(tags : Tags, etag : Tag?, present : Bool) -> Bool {
  match (tags, etag) {
    (Any, _) => present
    (These(_), None) => false
    (These(tags), Some(etag)) => {
      for tag in tags {
        if tag.same(etag) {
          return true
        }
      }
      false
    }
  }
}

///|
/// `If-None-Match` (§13.1.2), answered the way the field is named: true means
/// one matched, which fails the condition.
fn matches_weakly(tags : Tags, etag : Tag?, present : Bool) -> Bool {
  match (tags, etag) {
    (Any, _) => present
    (These(_), None) => false
    (These(tags), Some(etag)) => {
      for tag in tags {
        if tag.alike(etag) {
          return true
        }
      }
      false
    }
  }
}

///|
/// Whether the representation changed after `since` (§13.1.3, §13.1.4).
///
/// Unknown counts as changed. Both sections say only when the condition is
/// false, and say nothing about a representation with no date.
fn changed(modified : @moondate.Moment?, since : @moondate.Moment) -> Bool {
  match (modified.bind(fn(m) { m.epoch() }), since.epoch()) {
    (Some(ours), Some(theirs)) => ours > theirs
    _ => true
  }
}

// ------------------------------------------------------------------ plumbing

///|
fn trim(text : StringView) -> StringView {
  let mut lo = 0
  let mut hi = text.length()
  while lo < hi && space(text[lo].to_int()) {
    lo += 1
  }
  while hi > lo && space(text[hi - 1].to_int()) {
    hi -= 1
  }
  text[lo:hi]
}

///|
fn space(c : Int) -> Bool {
  c == 0x20 || c == 0x09
}

///|
fn lower(text : String) -> String {
  let out = StringBuilder()
  for i = 0; i < text.length(); i = i + 1 {
    let b = text[i].to_int()
    if b >= 0x41 && b <= 0x5A {
      out.write_char((b + 32).unsafe_to_char())
    } else {
      out.write_char(b.unsafe_to_char())
    }
  }
  out.to_string()
}