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

///|
/// A moment read from RFC 3339 text, in whichever of the four shapes it is written.
///
/// `2026-09-22T14:30:00Z` and `…+08:00` are `Zoned`; without an offset it is
/// `Plain`; a bare `2026-09-22` is `Day` and a bare `14:30:00` is `Clock`. TOML
/// 1.0.0 names these four and allows a space in place of the `T`, which is what a
/// person writes; RFC 3339 §5.6 allows it too, under NOTE.
///
/// A year outside 0000 to 9999 keeps the expanded form Temporal writes for it —
/// `+012026-01-02T03:04:05Z`, `-000001-01-02` — so what [`Moment::isoformat`]
/// writes reads back.
///
/// A name in brackets is read when the name is an offset — `[UTC]`, `[+09:00]`,
/// `[-0430]` — because then it says when the moment was by itself. A name that
/// is a place, `[America/New_York]`, is kept when the text carries the offset
/// beside it, and refused when it does not: the place alone does not say which
/// side of a transition the clock was on.
pub fn parse(src : StringView) -> Moment raise Refused {
  let s = spaced(src.to_owned())
  // A bare time has a colon where a date would have a dash.
  if s.length() >= 5 && at(s, 2) == ':' {
    return Clock(time=time_of(s, 0, s.length()))
  }
  // An expanded year is a sign and six digits, and the month and day follow the
  // dash after it; `rest` is where `MM-DD` starts.
  let mut rest = 5
  let mut year : Int32? = None
  if s.length() >= 8 && (at(s, 0) == '+' || at(s, 0) == '-') && at(s, 7) == '-' {
    let y = digits(s, 1, 6)
    year = Some(if at(s, 0) == '-' { -y } else { y })
    rest = 8
  }
  if s.length() < rest + 5 {
    raise Truncated("a date needs ten characters")
  }
  if at(s, rest + 2) != '-' {
    raise Malformed("a date is YYYY-MM-DD")
  }
  let date = match year {
    Some(y) => Date::new(y, digits(s, rest, 2), digits(s, rest + 3, 2))
    None => date_of(s, 0)
  }
  // The annotations come after everything else — the zone the moment was read in
  // and the calendar it was written in — so they are found first and set the end
  // of the text the date and time have to fill.
  let mut bracket = s.length()
  for i = rest + 5; i < s.length(); i = i + 1 {
    if at(s, i) == '[' {
      bracket = i
      break
    }
  }
  let notes = if bracket < s.length() {
    notes_of(s, bracket)
  } else {
    no_notes()
  }
  if bracket == rest + 5 {
    if notes.zone == "" {
      return Day(date~)
    }
    // A date on its own, with the offset given in the annotation:
    // `2020-01-01[+09:00]`, which is midnight where that offset runs.
    match offset_of_name(notes.zone) {
      Some(minutes) =>
        return Zoned(
          date~,
          time=Time::new(0, 0),
          zone=named(notes.zone, minutes),
        )
      None => raise Malformed("a bracketed zone needs an offset to go with it")
    }
  }
  let sep = at(s, rest + 5)
  if sep != 'T' && sep != 't' && sep != ' ' {
    raise Malformed("a date and a time are joined by T or a space")
  }
  let name = notes.zone
  // The offset, if any, begins at the first Z or at a sign that is not part of the
  // time — which is any sign at all, since the time itself carries none.
  let mut cut = bracket
  for i = rest + 6; i < bracket; i = i + 1 {
    let c = at(s, i)
    if c == 'Z' || c == 'z' || c == '+' || c == '-' {
      cut = i
      break
    }
  }
  let time = time_of(s, rest + 6, cut)
  if cut == bracket {
    if name == "" {
      return Plain(date~, time~)
    }
    // A name that is an offset says when it was all by itself, the way a trailing
    // `Z` does. A name that is a place does not: two in the morning in Paris is
    // one instant in July and another in January.
    match offset_of_name(name) {
      Some(minutes) => return Zoned(date~, time~, zone=named(name, minutes))
      None => raise Malformed("a bracketed zone needs an offset to go with it")
    }
  }
  Zoned(date~, time~, zone=with_name(zone_of(s, cut, bracket), name))
}

///|
/// The text with the separators ISO 8601 leaves out put back.
///
/// The basic form is the same grammar with three separators dropped —
/// `19761118T152330-0800` is `1976-11-18T15:23:30-08:00` — so it is rewritten here
/// and the reader below keeps one shape in mind. A text that is already extended
/// comes back unchanged, and so does one this does not recognise, which then fails
/// where it always did.
fn spaced(src : String) -> String {
  let s = src
  let n = s.length()
  let mut bracket = n
  for i = 0; i < n; i = i + 1 {
    if at(s, i) == '[' {
      bracket = i
      break
    }
  }
  let mut sep = -1
  for i = 0; i < bracket; i = i + 1 {
    let c = at(s, i)
    if c == 'T' || c == 't' || c == ' ' {
      sep = i
      break
    }
  }
  let date = spaced_date(s[:if sep < 0 { bracket } else { sep }].to_owned())
  let tail = s[bracket:].to_owned()
  if sep < 0 {
    return date + tail
  }
  date + "T" + spaced_clock(s[sep + 1:bracket].to_owned()) + tail
}

///|
/// `19761118` as `1976-11-18`, and `+0019761118` as `+001976-11-18`.
fn spaced_date(date : String) -> String {
  if date.contains("-") {
    return date
  }
  let n = date.length()
  let mut from = 0
  if n > 0 && (at(date, 0) == '+' || at(date, 0) == '-') {
    from = 1
  }
  let year_width = if from == 1 { 6 } else { 4 }
  if n - from != year_width + 4 {
    return date
  }
  for i = from; i < n; i = i + 1 {
    let c = at(date, i)
    if c < '0' || c > '9' {
      return date
    }
  }
  let cut = from + year_width
  date[:cut].to_owned() +
  "-" +
  date[cut:cut + 2].to_owned() +
  "-" +
  date[cut + 2:].to_owned()
}

///|
/// `152330.1` as `15:23:30.1`, `1500` as `15:00`, and the offset beside them.
fn spaced_clock(clock : String) -> String {
  let n = clock.length()
  let mut cut = n
  for i = 0; i < n; i = i + 1 {
    let c = at(clock, i)
    if c == 'Z' || c == 'z' || c == '+' || c == '-' {
      cut = i
      break
    }
  }
  spaced_time(clock[:cut].to_owned()) + spaced_offset(clock[cut:].to_owned())
}

///|
/// `152330` as `15:23:30`, and `1500` as `15:00`.
fn spaced_time(time : String) -> String {
  if time.contains(":") {
    return time
  }
  let lead = leading_digits(time)
  if lead == 6 {
    time[:2].to_owned() + ":" + time[2:4].to_owned() + ":" + time[4:].to_owned()
  } else if lead == 4 {
    time[:2].to_owned() + ":" + time[2:].to_owned()
  } else {
    time
  }
}

///|
/// `-0800` as `-08:00`, `-08` as `-08:00`, `-080000` as `-08:00:00`.
fn spaced_offset(offset : String) -> String {
  let n = offset.length()
  if n == 0 {
    return offset
  }
  let c = at(offset, 0)
  if c == 'Z' || c == 'z' || offset.contains(":") {
    return offset
  }
  // The fraction, when there is one, stays as it was: only the separators between
  // the fields are missing.
  let mut cut = n
  for i = 0; i < n; i = i + 1 {
    let mark = at(offset, i)
    if mark == '.' || mark == ',' {
      cut = i
      break
    }
  }
  let body = offset[:cut].to_owned()
  let fraction = offset[cut:].to_owned()
  let spaced = if body.length() == 3 {
    body + ":00"
  } else if body.length() == 5 {
    body[:3].to_owned() + ":" + body[3:].to_owned()
  } else if body.length() == 7 {
    body[:3].to_owned() + ":" + body[3:5].to_owned() + ":" + body[5:].to_owned()
  } else {
    body
  }
  spaced + fraction
}

///|
/// How many digits `text` opens with.
fn leading_digits(text : String) -> Int32 {
  let n = text.length()
  let mut k = 0
  while k < n && at(text, k) >= '0' && at(text, k) <= '9' {
    k = k + 1
  }
  k
}

///|
/// What the annotations say. The only calendar this library has is the ISO one, so
/// a calendar annotation is checked and then dropped; the zone is the part that
/// outlives the text.
priv struct Notes {
  /// The zone's name, or "" when the text names none.
  zone : String
}

///|
/// A text with no annotations at all.
fn no_notes() -> Notes {
  { zone: "", }
}

///|
/// The annotations at `from`, which run to the end of the text.
///
/// A bracket holds either a zone — a place or an offset — or a `key=value`
/// annotation. The zone comes first when both are there, a `!` before a key means
/// the reader has to understand it, and a key that is not `u-ca` is someone else's
/// business and is ignored unless it is critical. This is Temporal's grammar, and
/// the cases it turns on are the ones that say what to do with
/// `[u-ca=iso8601]`, `[foo=bar]` and `[!foo=bar]`.
fn notes_of(s : String, from : Int32) -> Notes raise Refused {
  if at(s, s.length() - 1) != ']' {
    raise Malformed("an annotation is written [key=value]")
  }
  let mut zone = ""
  let mut seen_calendar = false
  let mut i = from
  while i < s.length() {
    if at(s, i) != '[' {
      raise Malformed("an annotation is written [key=value]")
    }
    let mut end = i + 1
    while end < s.length() && at(s, end) != ']' {
      end = end + 1
    }
    if end >= s.length() {
      raise Malformed("an annotation is written [key=value]")
    }
    let note = s[i + 1:end].to_owned()
    if note.length() == 0 {
      raise Malformed("an empty annotation")
    }
    // A note with an `=` in it is a key and a value; anything else is a zone.
    let mut eq = -1
    for k = 0; k < note.length(); k = k + 1 {
      if at(note, k) == '=' {
        eq = k
        break
      }
    }
    if eq >= 0 {
      let (key, value, critical) = split_key(note, eq)
      if key == "u-ca" {
        if seen_calendar {
          raise Malformed("two calendars in one text")
        }
        // `gregory` names the same year, month and day as ISO in Temporal — it is
        // proleptic, with no Julian switch — so the two are one calendar here.
        if !same_text(value, "iso8601") && !same_text(value, "gregory") {
          raise Malformed(
            "the calendars here are iso8601 and gregory, not " + value,
          )
        }
        seen_calendar = true
      } else if critical {
        raise Malformed("this reader does not understand " + note)
      }
    } else {
      if zone != "" {
        raise Malformed("a text names one zone, not two")
      }
      if seen_calendar {
        raise Malformed("a zone comes before the annotations that follow it")
      }
      // `[!UTC]` is a zone the reader has to understand, and this one does.
      zone = if at(note, 0) == '!' { note[1:].to_owned() } else { note }
      if zone.length() == 0 {
        raise Malformed("an empty annotation")
      }
      // A zone name keeps the minute: `[+01:35]` is a name, `[+01:35:00]` is not.
      if at(zone, 0) == '+' || at(zone, 0) == '-' {
        match offset_of_name(zone) {
          Some(_) => ()
          None =>
            raise Malformed(
              "a zone name has no seconds: [+01:35], not [+01:35:00]",
            )
        }
      }
    }
    i = end + 1
  }
  { zone, }
}

///|
/// `key=value` split three ways at `at_eq`, with the `!` that marks it critical
/// taken off the key. A key or a value that breaks Temporal's grammar is refused
/// rather than read as an unknown one, which is what `[FOO=bar]` is testing.
fn split_key(
  note : String,
  at_eq : Int32,
) -> (String, String, Bool) raise Refused {
  let mut critical = false
  let mut start = 0
  if at(note, 0) == '!' {
    critical = true
    start = 1
  }
  let key = note[start:at_eq].to_owned()
  let value = note[at_eq + 1:].to_owned()
  // Temporal's grammar for a key is lowercase letters, digits, dashes and
  // underscores — `[_foo-bar0=…]` is a key — and for a value the same plus
  // capitals.
  if key.length() == 0 || value.length() == 0 {
    raise Malformed("an annotation is key=value")
  }
  for i = 0; i < key.length(); i = i + 1 {
    let c = at(key, i)
    if !(c >= 'a' && c <= 'z') &&
      !(c >= '0' && c <= '9') &&
      c != '-' &&
      c != '_' {
      raise Malformed(
        "an annotation key is lowercase letters, digits and dashes",
      )
    }
  }
  for i = 0; i < value.length(); i = i + 1 {
    let c = at(value, i)
    if !(c >= 'a' && c <= 'z') &&
      !(c >= 'A' && c <= 'Z') &&
      !(c >= '0' && c <= '9') &&
      c != '-' &&
      c != '_' {
      raise Malformed(
        "an annotation value is letters, digits, dashes and underscores",
      )
    }
  }
  (key, value, critical)
}

///|
/// Whether two texts are the same but for the case of their letters.
fn same_text(a : String, b : String) -> Bool {
  if a.length() != b.length() {
    return false
  }
  for i = 0; i < a.length(); i = i + 1 {
    let left = at(a, i)
    let right = at(b, i)
    let lower = fn(c : Char) -> Char {
      if c >= 'A' && c <= 'Z' {
        (c.to_int() + 32).unsafe_to_char()
      } else {
        c
      }
    }
    if lower(left) != lower(right) {
      return false
    }
  }
  true
}

///|
/// A zone known only by the name and the offset the text gave it.
fn named(name : String, minutes : Int32) -> Zone {
  Named({ name, offset: minutes * 60, abbrev: "", })
}

///|
/// The offset a bracketed name carries, when the name is an offset rather than a
/// place: `UTC`, `+01:00`, `-0430`.
///
/// `[Europe/Paris]` is a place, and a place is not an instant until something
/// says which offset was running; `[+01:00]` is that something.
fn offset_of_name(name : String) -> Int32? {
  if name == "UTC" {
    return Some(0)
  }
  let n = name.length()
  if n < 3 {
    return None
  }
  let sign = at(name, 0)
  if sign != '+' && sign != '-' {
    return None
  }
  let digit_at = fn(i : Int32) -> Int32? {
    if i >= n {
      return None
    }
    let c = at(name, i)
    if c < '0' || c > '9' {
      return None
    }
    Some(c.to_int() - '0'.to_int())
  }
  let hours = match (digit_at(1), digit_at(2)) {
    (Some(a), Some(b)) => a * 10 + b
    _ => return None
  }
  if hours > 23 {
    return None
  }
  let mut minutes = 0
  if n > 3 {
    if n == 5 {
      minutes = match (digit_at(3), digit_at(4)) {
        (Some(a), Some(b)) => a * 10 + b
        _ => return None
      }
    } else if n == 6 && at(name, 3) == ':' {
      minutes = match (digit_at(4), digit_at(5)) {
        (Some(a), Some(b)) => a * 10 + b
        _ => return None
      }
    } else {
      return None
    }
  }
  if minutes > 59 {
    return None
  }
  let total = hours * 60 + minutes
  Some(if sign == '-' { -total } else { total })
}

///|
/// The zone a moment carries, with the name the text gave it.
fn with_name(zone : Zone, name : String) -> Zone {
  if name == "" {
    zone
  } else {
    Named({ name, offset: zone.seconds(), abbrev: "", })
  }
}

///|
/// The ten characters `YYYY-MM-DD` at `from`.
fn date_of(s : String, from : Int32) -> Date raise Refused {
  if at(s, from + 4) != '-' || at(s, from + 7) != '-' {
    raise Malformed("a date is YYYY-MM-DD")
  }
  Date::new(digits(s, from, 4), digits(s, from + 5, 2), digits(s, from + 8, 2))
}

///|
/// `HH:MM`, `HH:MM:SS` or `HH:MM:SS.fff…` between `from` and `end`.
///
/// RFC 3339 requires the seconds; TOML's local time allows them to be left off, and
/// so does ISO 8601, so they are optional here.
fn time_of(s : String, from : Int32, end : Int32) -> Time raise Refused {
  // A clock of one field is the hour and nothing else, which is what ISO 8601
  // calls reduced precision and what `1970-01-01T00Z` means.
  if end - from == 2 {
    return Time::new(digits(s, from, 2), 0)
  }
  if end - from < 5 || at(s, from + 2) != ':' {
    raise Malformed("a time is HH:MM[:SS[.fff]]")
  }
  let hour = digits(s, from, 2)
  let minute = digits(s, from + 3, 2)
  if end - from == 5 {
    return Time::new(hour, minute)
  }
  if at(s, from + 5) != ':' {
    raise Malformed("a time is HH:MM[:SS[.fff]]")
  }
  let second = digits(s, from + 6, 2)
  if end - from == 8 {
    return Time::new(hour, minute, second~)
  }
  if at(s, from + 8) != '.' {
    raise Malformed("a fraction of a second begins with a dot")
  }
  // Nine digits is a nanosecond; more are read and dropped, which is what every
  // parser does with a precision it cannot hold.
  let mut nano = 0
  let mut seen = 0
  for i = from + 9; i < end; i = i + 1 {
    let d = digit(s, i)
    if seen < 9 {
      nano = nano * 10 + d
      seen = seen + 1
    }
  }
  if seen == 0 {
    raise Malformed("a dot with no digits after it")
  }
  for i = seen; i < 9; i = i + 1 {
    nano = nano * 10
  }
  Time::new(hour, minute, second~, nano~)
}

///|
/// `Z`, `+HH:MM` or `-HH:MM` at `from`.
///
/// RFC 3339 §5.6 has no seconds field, so `+HH:MM:SS` is read for Temporal's sake:
/// its grammar allows the seconds, and a zone read before standard time needs them
/// to write an exact instant.
fn zone_of(s : String, from : Int32, end : Int32) -> Zone raise Refused {
  let c = at(s, from)
  if c == 'Z' || c == 'z' {
    if end != from + 1 {
      raise Malformed("trailing characters after Z")
    }
    return Utc
  }
  let width = end - from
  if width < 6 || at(s, from + 3) != ':' {
    raise Malformed("an offset is ±HH:MM[:SS[:fff]]")
  }
  let hours = digits(s, from + 1, 2)
  let minutes = digits(s, from + 4, 2)
  if hours > 23 {
    raise Range(field="offset hour", got=hours, low=0, high=23)
  }
  if minutes > 59 {
    raise Range(field="offset minute", got=minutes, low=0, high=59)
  }
  let mut seconds = 0
  let mut fraction = 0L
  let mut k = from + 6
  if k < end {
    if at(s, k) != ':' {
      raise Malformed("an offset is ±HH:MM[:SS[:fff]]")
    }
    if end - k < 3 {
      raise Truncated("an offset second is two digits")
    }
    seconds = digits(s, k + 1, 2)
    if seconds > 59 {
      raise Range(field="offset second", got=seconds, low=0, high=59)
    }
    k = k + 3
    if k < end {
      let mark = at(s, k)
      if mark != '.' && mark != ',' {
        raise Malformed("an offset is ±HH:MM[:SS[:fff]]")
      }
      k = k + 1
      if k == end {
        raise Malformed("a dot with no digits after it")
      }
      if end - k > 9 {
        raise Malformed("a fraction of a second is nine digits at most")
      }
      // A fraction of a second is kept as far as nine digits, which is what a
      // nanosecond is.
      let mut seen = 0
      while k < end {
        if seen < 9 {
          fraction = fraction * 10L + digit(s, k).to_int64()
          seen = seen + 1
        }
        k = k + 1
      }
      for i = seen; i < 9; i = i + 1 {
        fraction = fraction * 10L
      }
    }
  }
  let mut nanos = (hours * 3600 + minutes * 60 + seconds).to_int64() *
    1_000_000_000L +
    fraction
  if c == '-' {
    nanos = -nanos
  }
  // A whole number of minutes is the ordinary case and keeps the ordinary shape;
  // everything else has a precision `Offset` cannot hold.
  if nanos % 60_000_000_000L == 0L {
    Offset((nanos / 60_000_000_000L).to_int())
  } else {
    Precise(nanos)
  }
}

///|
/// The character at `i`, or a space past the end so a caller's comparison fails
/// rather than reading out of bounds.
fn at(s : String, i : Int32) -> Char {
  if i >= 0 && i < s.length() {
    s[i].unsafe_to_char()
  } else {
    ' '
  }
}

///|
/// One digit at `i`.
fn digit(s : String, i : Int32) -> Int32 raise Refused {
  let c = at(s, i)
  if c < '0' || c > '9' {
    raise Malformed("expected a digit at \{i}")
  }
  c.to_int() - 48
}

///|
/// `n` digits starting at `from`, as the number they spell.
fn digits(s : String, from : Int32, n : Int32) -> Int32 raise Refused {
  let mut out = 0
  for i = 0; i < n; i = i + 1 {
    out = out * 10 + digit(s, from + i)
  }
  out
}

// -- writing ------------------------------------------------------------------

///|
/// A moment as RFC 3339 text, in the shape it is.
///
/// This is the inverse of [`parse`] and the form TOML writes, so a document read
/// and written again carries the same timestamps.
pub impl Show for Moment with fn output(self, logger) {
  logger.write_string(
    match self {
      Zoned(date~, time~, zone~) =>
        text_of(date) + "T" + text_of_time(time) + text_of_zone(zone)
      Plain(date~, time~) => text_of(date) + "T" + text_of_time(time)
      Day(date~) => text_of(date)
      Clock(time~) => text_of_time(time)
    },
  )
}

///|
pub extend Moment with Show::{to_string, output}

///|
pub impl Show for Date with fn output(self, logger) {
  logger.write_string(text_of(self))
}

///|
pub extend Date with Show::{to_string, output}

///|
pub impl Show for Time with fn output(self, logger) {
  logger.write_string(text_of_time(self))
}

///|
pub extend Time with Show::{to_string, output}

///|
/// `YYYY-MM-DD`, with a year past four digits written out in full.
fn text_of(d : Date) -> String {
  let sign = if d.year < 0 { "-" } else { "" }
  let y = if d.year < 0 { -d.year } else { d.year }
  sign + pad(y, 4) + "-" + pad(d.month, 2) + "-" + pad(d.day, 2)
}

///|
/// `HH:MM:SS`, with `.fff…` only when there is a fraction to write.
fn text_of_time(t : Time) -> String {
  let head = pad(t.hour, 2) + ":" + pad(t.minute, 2) + ":" + pad(t.second, 2)
  if t.nano == 0 {
    return head
  }
  // Trailing zeros are dropped, so a millisecond reads as one.
  let mut frac = pad(t.nano, 9)
  while frac.length() > 1 && frac[frac.length() - 1].to_int() == 48 {
    frac = frac[0:frac.length() - 1].to_owned()
  }
  head + "." + frac
}

///|
/// The offset part of a moment, in the minutes RFC 3339 has room for.
///
/// A named zone whose local mean time is not a whole number of minutes writes the
/// offset rounded towards zero: the standard has no field for the remainder, and
/// the value keeps it.
fn text_of_zone(z : Zone) -> String {
  match z {
    Utc => "Z"
    // A fixed offset finer than a minute keeps its seconds and its fraction; the
    // rest are whole minutes and are written as such.
    Precise(nanos) => hhmmss_nanos(nanos)
    _ => hhmm(z.minutes())
  }
}

///|
/// `±HH:MM`.
fn hhmm(minutes : Int32) -> String {
  let sign = if minutes < 0 { "-" } else { "+" }
  let a = if minutes < 0 { -minutes } else { minutes }
  sign + pad(a / 60, 2) + ":" + pad(a % 60, 2)
}

///|
/// `±HH:MM`, with `:SS` only when the offset is not a whole number of minutes.
///
/// Temporal's ISO grammar allows the seconds; RFC 3339 does not, so this is the
/// one form written there.
fn hhmmss(seconds : Int32) -> String {
  let a = if seconds < 0 { -seconds } else { seconds }
  let head = hhmm(seconds / 60)
  if a % 60 == 0 {
    head
  } else {
    head + ":" + pad(a % 60, 2)
  }
}

///|
/// `n` written with at least `width` digits.
fn pad(n : Int32, width : Int32) -> String {
  let s = n.to_string()
  if s.length() >= width {
    return s
  }
  let out = StringBuilder()
  for i = s.length(); i < width; i = i + 1 {
    out.write_char('0')
  }
  out.write_string(s)
  out.to_string()
}

// -- HTTP ---------------------------------------------------------------------

///|
/// The three-letter day names IMF-fixdate uses, Sunday first.
let weekdays : Array[String] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]

///|
/// The three-letter month names, January first.
let months : Array[String] = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]

///|
/// A moment as an HTTP date (RFC 9110 §5.6.7): `Sun, 06 Nov 1994 08:49:37 GMT`.
///
/// This is the one form a `Date` header may take. The two obsolete forms are read
/// by [`http`] and never written, which is what §5.6.7 asks for.
pub fn Moment::http_text(self : Moment) -> String raise Refused {
  let (d, t) = match self {
    Zoned(date~, time~, zone~) => {
      // The header is always GMT, so an offset moment is moved onto it first.
      let shift = zone.minutes()
      match
        Zoned(date~, time~, zone=Utc).plus(Span::of(minute, -shift.to_int64())) {
        Zoned(date~, time~, ..) => (date, time)
        _ => (date, time)
      }
    }
    Plain(date~, time~) => (date, time)
    Day(date~) => (date, { hour: 0, minute: 0, second: 0, nano: 0, })
    Clock(_) => raise Malformed("a time with no date is not an HTTP date")
  }
  weekdays[d.weekday()] +
  ", " +
  pad(d.day, 2) +
  " " +
  months[d.month - 1] +
  " " +
  http_year(d.year) +
  " " +
  pad(t.hour, 2) +
  ":" +
  pad(t.minute, 2) +
  ":" +
  pad(t.second, 2) +
  " GMT"
}

///|
/// A year as the HTTP date forms write it: at least four digits, with a minus for
/// a negative year and nothing extra for one that needs five, which is what
/// ECMAScript's `toUTCString` does.
fn http_year(year : Int32) -> String {
  if year < 0 {
    "-" + pad(-year, 4)
  } else {
    pad(year, 4)
  }
}

///|
/// An HTTP date read (RFC 9110 §5.6.7).
///
/// The preferred IMF-fixdate, and the two obsolete forms a recipient must still
/// accept: RFC 850's `Sunday, 06-Nov-94 08:49:37 GMT` and asctime's
/// `Sun Nov  6 08:49:37 1994`. A two-digit year is read as RFC 6265 §5.1.1 says:
/// 69 and under are 2000s, 70 and over are 1900s.
pub fn http(src : StringView) -> Moment raise Refused {
  let s = src.to_owned()
  let comma = find(s, ',')
  if comma < 0 {
    return asctime(s)
  }
  let rest = trim(s[comma + 1:].to_owned())
  // IMF-fixdate has spaces around the month; RFC 850 has dashes.
  if rest.length() >= 11 && at(rest, 2) == '-' {
    let year = digits(rest, 7, 2)
    return Zoned(
      date=Date::new(
        if year <= 69 {
          2000 + year
        } else {
          1900 + year
        },
        month_of(rest[3:6].to_owned()),
        digits(rest, 0, 2),
      ),
      time=time_of(rest, 10, 18),
      zone=Utc,
    )
  }
  if rest.length() < 20 {
    raise Truncated("an HTTP date")
  }
  Zoned(
    date=Date::new(
      digits(rest, 7, 4),
      month_of(rest[3:6].to_owned()),
      digits(rest, 0, 2),
    ),
    time=time_of(rest, 12, 20),
    zone=Utc,
  )
}

///|
/// The asctime form, whose day is space-padded rather than zero-padded.
fn asctime(s : String) -> Moment raise Refused {
  let t = trim(s)
  if t.length() < 24 {
    raise Truncated("an asctime date")
  }
  let day = if at(t, 8) == ' ' { digits(t, 9, 1) } else { digits(t, 8, 2) }
  Zoned(
    date=Date::new(digits(t, 20, 4), month_of(t[4:7].to_owned()), day),
    time=time_of(t, 11, 19),
    zone=Utc,
  )
}

///|
/// A three-letter month name as its number.
fn month_of(name : String) -> Int32 raise Refused {
  for i = 0; i < 12; i = i + 1 {
    if months[i] == name {
      return i + 1
    }
  }
  raise Malformed("not a month name: " + name)
}

///|
fn find(s : String, c : Char) -> Int32 {
  for i = 0; i < s.length(); i = i + 1 {
    if s[i].to_int() == c.to_int() {
      return i
    }
  }
  -1
}

///|
fn trim(s : String) -> String {
  let mut a = 0
  let mut b = s.length()
  while a < b && (s[a].to_int() == 0x20 || s[a].to_int() == 0x09) {
    a = a + 1
  }
  while b > a && (s[b - 1].to_int() == 0x20 || s[b - 1].to_int() == 0x09) {
    b = b - 1
  }
  s[a:b].to_owned()
}

// -- ASN.1 --------------------------------------------------------------------

///|
/// An ASN.1 UTCTime or GeneralizedTime (ITU-T X.680 §47), as DER writes them.
///
/// This is how a certificate spells its validity. DER requires the `Z` and whole
/// seconds; UTCTime's two-digit year is read as RFC 5280 §4.1.2.5.1 says — 49 and
/// under are 2000s, 50 and over are 1900s, which is not the same rule HTTP uses.
pub fn asn1(src : StringView) -> Moment raise Refused {
  let s = src.to_owned()
  let (year, from) = if s.length() == 13 {
    let y = digits(s, 0, 2)
    (if y < 50 { 2000 + y } else { 1900 + y }, 2)
  } else if s.length() == 15 {
    (digits(s, 0, 4), 4)
  } else {
    raise Malformed("an ASN.1 time is 13 or 15 characters")
  }
  if at(s, s.length() - 1) != 'Z' {
    raise Malformed("DER requires the Z")
  }
  Zoned(
    date=Date::new(year, digits(s, from, 2), digits(s, from + 2, 2)),
    time=Time::new(
      digits(s, from + 4, 2),
      digits(s, from + 6, 2),
      second=digits(s, from + 8, 2),
    ),
    zone=Utc,
  )
}

///|
/// A moment as an ASN.1 GeneralizedTime, which is the form DER uses from 2050 on
/// and the one that needs no windowing rule to read back.
pub fn Moment::asn1_text(self : Moment) -> String raise Refused {
  match self {
    Zoned(date~, time~, ..) | Plain(date~, time~) =>
      pad(date.year, 4) +
      pad(date.month, 2) +
      pad(date.day, 2) +
      pad(time.hour, 2) +
      pad(time.minute, 2) +
      pad(time.second, 2) +
      "Z"
    _ => raise Malformed("an ASN.1 time needs both a date and a time")
  }
}