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

// The four formats that name a point in time or a span of it: `date`, `time`,
// `date-time` (RFC 3339 §5.6) and `duration` (RFC 3339 Appendix A).
//
// The calendar is moondate's: which day a month has, and whether a year is a
// leap one, is arithmetic this library has no business repeating. What is read
// here is the shape of the text, because a validator's job is to refuse a
// string before asking any library what it means.

///|
/// The digit at `index`, or `-1` when it is not one.
///
/// ASCII only: `1963-06-1৪` is a Bengali four, and a year with one in it is not
/// a year a parser reading ASCII would ever produce.
fn digit_at(text : StringView, index : Int32) -> Int32 {
  if index < 0 || index >= text.length() {
    return -1
  }
  let code = text[index].to_int()
  if code < 0x30 || code > 0x39 {
    return -1
  }
  code - 0x30
}

///|
/// `count` digits from `from` as the number they spell, or `None` when they are
/// not all digits or are not all there.
fn number_at(text : StringView, from : Int32, count : Int32) -> Int32? {
  let mut value = 0
  for i = 0; i < count; i = i + 1 {
    let digit = digit_at(text, from + i)
    if digit < 0 {
      return None
    }
    value = value * 10 + digit
  }
  Some(value)
}

///|
/// Whether the day exists: the calendar's answer, asked of moondate, whose
/// months know their length and whose years know the century rule.
fn is_calendar_day(year : Int32, month : Int32, day : Int32) -> Bool {
  let _ = @moondate.Date::new(year, month, day) catch { _ => return false }
  true
}

///|
/// An RFC 3339 §5.6 `full-date`: `YYYY-MM-DD`, four digits for the year, and a
/// day the month actually has.
fn is_date(text : String) -> Bool {
  guard text.length() == 10 else { return false }
  guard text[4] == '-' && text[7] == '-' else { return false }
  let year = match number_at(text[:], 0, 4) {
    Some(found) => found
    None => return false
  }
  let month = match number_at(text[:], 5, 2) {
    Some(found) => found
    None => return false
  }
  let day = match number_at(text[:], 8, 2) {
    Some(found) => found
    None => return false
  }
  is_calendar_day(year, month, day)
}

///|
/// The reading of a `full-time`, and the one thing that makes it more than a
/// regular expression: whether a `:60` is a leap second or a typo.
priv struct Time {
  /// Seconds since midnight, the leap second counting as 59 for the arithmetic.
  seconds : Int32
  /// Offset from UTC in seconds, east positive.
  offset : Int32
  /// Whether the second field was 60.
  leap : Bool
}

///|
/// An RFC 3339 §5.6 `full-time`: `HH:MM:SS[.frac]` and an offset, which RFC 3339
/// makes mandatory — `12:00:00` alone names no instant.
fn read_time(text : StringView) -> Time? {
  guard number_at(text, 0, 2) is Some(hour) else { return None }
  guard number_at(text, 3, 2) is Some(minute) else { return None }
  guard number_at(text, 6, 2) is Some(second) else { return None }
  guard text[2] == ':' && text[5] == ':' else { return None }
  guard hour <= 23 && minute <= 59 && second <= 60 else { return None }
  let mut i = 8
  // The fraction is optional and may be any number of digits: `…999999999999999Z`
  // is a time, however little a reader can do with it.
  if i < text.length() && text[i] == '.' {
    i = i + 1
    let start = i
    while i < text.length() && digit_at(text, i) >= 0 {
      i = i + 1
    }
    guard i > start else { return None }
  }
  guard i < text.length() else { return None }
  let offset = if text[i] == 'Z' || text[i] == 'z' {
    i = i + 1
    0
  } else if text[i] == '+' || text[i] == '-' {
    let negative = text[i] == '-'
    guard number_at(text, i + 1, 2) is Some(offset_hour) else { return None }
    guard number_at(text, i + 4, 2) is Some(offset_minute) else { return None }
    guard text[i + 3] == ':' else { return None }
    guard offset_hour <= 23 && offset_minute <= 59 else { return None }
    i = i + 6
    let span = offset_hour * 3600 + offset_minute * 60
    if negative {
      -span
    } else {
      span
    }
  } else {
    return None
  }
  guard i == text.length() else { return None }
  let time : Time = {
    seconds: hour * 3600 +
    minute * 60 +
    (if second == 60 { 59 } else { second }),
    offset,
    leap: second == 60,
  }
  Some(time)
}

///|
/// A leap second exists only at 23:59:60 UTC, and RFC 3339 writes it in local
/// time: `01:29:60+01:30` is that same moment, `23:58:60Z` is not one at all.
fn is_leap_second_place(time : Time) -> Bool {
  // Wrapped in seconds first: a local time west of UTC puts the instant on the
  // day before, and dividing a negative number would round towards zero and
  // lose the minute it crossed.
  let utc = time.seconds - time.offset
  let day = 24 * 3600
  let folded = (utc % day + day) % day
  folded / 60 == 23 * 60 + 59
}

///|
/// Whether a version lets a bare time be a leap second.
///
/// v1 reads `time` against RFC 9557, which dropped it: a time with no date on
/// it cannot say which leap second it is, so the second field stops at 59.
/// `date-time` still has a date to say it with, and keeps the leap second.
priv enum Leap {
  Allowed
  Refused
}

///|
/// Whether a time reads as an instant: a `:60` must be at the one place a leap
/// second can be, and anything earlier is a time wherever it is.
fn is_instant(time : Time, leap : Leap) -> Bool {
  if !time.leap {
    true
  } else {
    match leap {
      Allowed => is_leap_second_place(time)
      Refused => false
    }
  }
}

///|
/// An RFC 3339 §5.6 `full-time`.
fn is_time(text : String, leap : Leap) -> Bool {
  match read_time(text[:]) {
    Some(time) => is_instant(time, leap)
    None => false
  }
}

///|
/// An RFC 3339 §5.6 `date-time`: a `full-date`, `T` in either case, and a
/// `full-time`.
fn is_date_time(text : String) -> Bool {
  guard text.length() > 11 else { return false }
  let separator = text[10]
  guard separator == 'T' || separator == 't' else { return false }
  if !is_date(text[0:10].to_owned()) {
    return false
  }
  match read_time(text[:][11:]) {
    Some(time) => is_instant(time, Allowed)
    None => false
  }
}

///|
/// An RFC 3339 Appendix A `duration`.
///
/// The grammar is a chain, not a set: `P1Y2D` is not a duration because a year
/// may be followed by a month and then by a day, never by a day alone. So this
/// reads it the way the grammar is written, one production at a time, rather
/// than counting unit letters in any order.
fn is_duration(text : String) -> Bool {
  let view = text[:]
  if view.length() < 2 || view[0] != 'P' {
    return false
  }
  // `dur-date / dur-time / dur-week`: a duration may be all time and no date,
  // which is `PT36H` and its kind.
  if view[1] == 'T' {
    guard read_duration_time(view, 2) is Some(after) else { return false }
    return after == view.length()
  }
  let (next, weeks) = read_duration_date(view, 1)
  guard next is Some(after) else { return false }
  let mut i = after
  if weeks {
    // A week is its own form: `P1Y2W` is not one, and neither is `P1WT1H`.
    return i == view.length()
  }
  if i < view.length() && view[i] == 'T' {
    i = i + 1
    guard read_duration_time(view, i) is Some(after) else { return false }
    i = after
  }
  i == view.length()
}

///|
/// `1*DIGIT` from `from`, and the index just past them, or `None` when there are
/// none — every number in a duration is at least one digit.
fn skip_digits(text : StringView, from : Int32) -> Int32? {
  let mut i = from
  while i < text.length() && digit_at(text, i) >= 0 {
    i = i + 1
  }
  if i == from {
    None
  } else {
    Some(i)
  }
}

///|
/// The date half of a duration, and whether it was the week form.
///
/// `dur-date = (dur-day / dur-month / dur-year) [dur-time]`, where each of the
/// three is built out of the one before it: a year may be followed by a month
/// and then by a day, and a month by a day. `P1Y2D` therefore stops at `2D`,
/// which the grammar has nowhere to put.
fn read_duration_date(text : StringView, from : Int32) -> (Int32?, Bool) {
  let at = match skip_digits(text, from) {
    Some(found) => found
    None => return (None, false)
  }
  guard at < text.length() else { return (None, false) }
  match text[at] {
    'W' => (Some(at + 1), true)
    'D' => (Some(at + 1), false)
    'M' => {
      let mut next = at + 1
      match skip_digits(text, next) {
        Some(after) =>
          if after < text.length() && text[after] == 'D' {
            next = after + 1
          }
        None => ()
      }
      (Some(next), false)
    }
    'Y' => {
      let mut next = at + 1
      match skip_digits(text, next) {
        Some(after) =>
          if after < text.length() && text[after] == 'M' {
            next = after + 1
            match skip_digits(text, next) {
              Some(day) =>
                if day < text.length() && text[day] == 'D' {
                  next = day + 1
                }
              None => ()
            }
          }
        None => ()
      }
      (Some(next), false)
    }
    _ => (None, false)
  }
}

///|
/// The time half of a duration: `dur-time = "T" (dur-hour / dur-minute /
/// dur-second)`, each of which may be followed by the one below it and by
/// nothing else.
fn read_duration_time(text : StringView, from : Int32) -> Int32? {
  let at = match skip_digits(text, from) {
    Some(found) => found
    None => return None
  }
  guard at < text.length() else { return None }
  match text[at] {
    'H' => {
      let mut next = at + 1
      match skip_digits(text, next) {
        Some(after) =>
          if after < text.length() && text[after] == 'M' {
            next = after + 1
            match skip_digits(text, next) {
              Some(second) =>
                if second < text.length() && text[second] == 'S' {
                  next = second + 1
                }
              None => ()
            }
          }
        None => ()
      }
      Some(next)
    }
    'M' => {
      let mut next = at + 1
      match skip_digits(text, next) {
        Some(after) =>
          if after < text.length() && text[after] == 'S' {
            next = after + 1
          }
        None => ()
      }
      Some(next)
    }
    'S' => Some(at + 1)
    _ => None
  }
}