///|
/// The precision actually declared by a source date. UTC rendering always
/// includes seconds, but this marker distinguishes an RSS minute-only value.
/// `Fraction(digits)` retains the full count beyond nanosecond precision.
pub enum DatePrecision {
  Minute
  Second
  Fraction(Int32)
} derive(Debug, Eq)

///|
/// A validated instant. `utc` preserves declared fractional-second digits;
/// `unix_seconds` and `nanosecond` expose seconds and nanoseconds without
/// local time zones. `utc` retains any finer digits for callers that need them.
/// `nanosecond` truncates any sub-nanosecond digits; `utc` keeps them intact.
/// `offset_known` is false for RFC 3339 `-00:00` or RSS `-0000`: UTC is
/// known but the source's local offset is not. In that case `offset_minutes`
/// is zero only for UTC arithmetic; it does not assert a local offset.
/// `is_leap_second` distinguishes an announced `23:59:60` from the preceding
/// second: POSIX/Unix time has no separate leap-second value, so
/// `unix_seconds` uses the preceding `23:59:59` second in that case.
pub struct NormalizedDate {
  utc : String
  unix_seconds : Int64
  nanosecond : Int32
  offset_minutes : Int32
  offset_known : Bool
  is_leap_second : Bool
  precision : DatePrecision
} derive(Debug, Eq)

///|
pub enum DateError {
  InvalidSyntax
  InvalidCalendarDate
  InvalidOffset
  // Retained for API compatibility; fractional digits are now unbounded.
  UnsupportedPrecision
  UnsupportedTimeZone
  OutOfRange
} derive(Debug, Eq)

///|
pub extend DatePrecision with @moonbitlang/core/debug.Debug::{to_repr}

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

///|
pub extend NormalizedDate with @moonbitlang/core/debug.Debug::{to_repr}

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

///|
pub extend DateError with @moonbitlang/core/debug.Debug::{to_repr}

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

///|
fn date_chars(text : String) -> Array[Char] {
  let chars : Array[Char] = []
  for character in text {
    chars.push(character)
  }
  chars
}

///|
fn date_digits(chars : Array[Char], start : Int32, count : Int32) -> Int32? {
  if start < 0 || count < 1 || start + count > chars.length() {
    return None
  }
  let mut value = 0
  for index = start; index < start + count; index = index + 1 {
    let digit = chars[index].to_int() - 48
    if digit < 0 || digit > 9 {
      return None
    }
    value = value * 10 + digit
  }
  Some(value)
}

///|
fn date_literal(chars : Array[Char], index : Int32, expected : Char) -> Bool {
  index < chars.length() && chars[index] == expected
}

///|
fn date_slice(chars : Array[Char], start : Int32, stop : Int32) -> String {
  let builder = StringBuilder()
  for index = start; index < stop; index = index + 1 {
    builder.write_char(chars[index])
  }
  builder.to_string()
}

///|
// Positive leap seconds announced through 2026-10-23. NIST's published table:
// https://www.nist.gov/pml/time-and-frequency-division/time-realization/leap-seconds
// Refresh this list after a new IERS Bulletin C announces another event.
fn announced_positive_leap_second(date : String) -> Bool {
  for
    known in [
      "1972-06-30", "1972-12-31", "1973-12-31", "1974-12-31", "1975-12-31", "1976-12-31",
      "1977-12-31", "1978-12-31", "1979-12-31", "1981-06-30", "1982-06-30", "1983-06-30",
      "1985-06-30", "1987-12-31", "1989-12-31", "1990-12-31", "1992-06-30", "1993-06-30",
      "1994-06-30", "1995-12-31", "1997-06-30", "1998-12-31", "2005-12-31", "2008-12-31",
      "2012-06-30", "2015-06-30", "2016-12-31",
    ] {
    if date == known {
      return true
    }
  }
  false
}

///|
fn normalized_date(
  year : Int32,
  month : Int32,
  day : Int32,
  hour : Int32,
  minute : Int32,
  second : Int32,
  nanosecond : Int32,
  fraction : String,
  offset_minutes : Int32,
  offset_known : Bool,
  precision : DatePrecision,
  weekday : @time.Weekday?,
) -> Result[NormalizedDate, DateError] {
  if year < 1 || year > 9999 || second > 60 {
    return Err(InvalidCalendarDate)
  }
  let is_leap_second = second == 60
  let local_date = @time.PlainDateTime::of(
    year,
    month,
    day,
    hour~,
    minute~,
    second=if is_leap_second { 59 } else { second },
  ) catch {
    _ => return Err(InvalidCalendarDate)
  }
  match weekday {
    Some(expected) if local_date.weekday() != expected =>
      return Err(InvalidCalendarDate)
    _ => ()
  }
  let unix_seconds = local_date.to_unix_second() -
    offset_minutes.to_int64() * 60L
  let utc = @time.PlainDateTime::from_unix_second(
    unix_seconds, 0, @time.utc_offset,
  ) catch {
    _ => return Err(OutOfRange)
  }
  if utc.year() < 1 || utc.year() > 9999 {
    return Err(OutOfRange)
  }
  let utc_text = utc.to_string()
  let displayed_utc = if is_leap_second {
    guard utc_text.split_once("T") is Some((date, time)) &&
      time == "23:59:59" &&
      announced_positive_leap_second(date.to_owned()) else {
      return Err(InvalidCalendarDate)
    }
    date.to_owned() + "T23:59:60"
  } else {
    utc_text
  }
  Ok({
    utc: displayed_utc + fraction + "Z",
    unix_seconds,
    nanosecond,
    offset_minutes,
    offset_known,
    is_leap_second,
    precision,
  })
}

///|
fn normalize_rfc3339(
  text : String,
  atom_case : Bool,
) -> Result[NormalizedDate, DateError] {
  let chars = date_chars(text)
  if chars.length() < 20 ||
    !date_literal(chars, 4, '-') ||
    !date_literal(chars, 7, '-') ||
    (chars[10] != 'T' && chars[10] != 't') ||
    (atom_case && chars[10] != 'T') ||
    !date_literal(chars, 13, ':') ||
    !date_literal(chars, 16, ':') {
    return Err(InvalidSyntax)
  }
  let year = date_digits(chars, 0, 4)
  let month = date_digits(chars, 5, 2)
  let day = date_digits(chars, 8, 2)
  let hour = date_digits(chars, 11, 2)
  let minute = date_digits(chars, 14, 2)
  let second = date_digits(chars, 17, 2)
  guard (year, month, day, hour, minute, second)
    is (
      Some(year),
      Some(month),
      Some(day),
      Some(hour),
      Some(minute),
      Some(second),
    ) else {
    return Err(InvalidSyntax)
  }
  let mut index = 19
  let mut fraction = ""
  let mut nanosecond = 0
  let mut precision = Second
  if date_literal(chars, index, '.') {
    index = index + 1
    let first = index
    while index < chars.length() {
      let digit = chars[index].to_int() - 48
      if digit < 0 || digit > 9 {
        break
      }
      if index - first < 9 {
        nanosecond = nanosecond * 10 + digit
      }
      index = index + 1
    }
    let digits = index - first
    if digits == 0 {
      return Err(InvalidSyntax)
    }
    precision = Fraction(digits)
    fraction = "." + date_slice(chars, first, index)
    let mut padding = if digits < 9 { digits } else { 9 }
    while padding < 9 {
      nanosecond = nanosecond * 10
      padding = padding + 1
    }
  }
  if index >= chars.length() {
    return Err(InvalidSyntax)
  }
  let (offset_minutes, offset_known) = if chars[index] == 'Z' ||
    chars[index] == 'z' {
    if atom_case && chars[index] != 'Z' {
      return Err(InvalidSyntax)
    }
    if index + 1 != chars.length() {
      return Err(InvalidSyntax)
    }
    (0, true)
  } else if chars[index] == '+' || chars[index] == '-' {
    if index + 6 != chars.length() || !date_literal(chars, index + 3, ':') {
      return Err(InvalidOffset)
    }
    guard (date_digits(chars, index + 1, 2), date_digits(chars, index + 4, 2))
      is (Some(offset_hour), Some(offset_minute)) else {
      return Err(InvalidOffset)
    }
    if offset_hour > 23 || offset_minute > 59 {
      return Err(InvalidOffset)
    }
    (
      (offset_hour * 60 + offset_minute) *
      (if chars[index] == '-' { -1 } else { 1 }),
      !(chars[index] == '-' && offset_hour == 0 && offset_minute == 0),
    )
  } else {
    return Err(InvalidOffset)
  }
  normalized_date(
    year,
    month,
    day,
    hour,
    minute,
    second,
    nanosecond,
    fraction,
    offset_minutes,
    offset_known,
    precision,
    None,
  )
}

///|
fn rss_date_tokens(text : String) -> Array[String] {
  let tokens : Array[String] = []
  let mut current = StringBuilder()
  for character in text {
    if character == ' ' ||
      character == '\t' ||
      character == '\r' ||
      character == '\n' {
      let token = current.to_string()
      if !token.is_empty() {
        tokens.push(token)
      }
      current = StringBuilder()
    } else {
      current.write_char(character)
    }
  }
  let token = current.to_string()
  if !token.is_empty() {
    tokens.push(token)
  }
  tokens
}

///|
fn rss_month(month : String) -> Int32? {
  match month.to_lower() {
    "jan" => Some(1)
    "feb" => Some(2)
    "mar" => Some(3)
    "apr" => Some(4)
    "may" => Some(5)
    "jun" => Some(6)
    "jul" => Some(7)
    "aug" => Some(8)
    "sep" => Some(9)
    "oct" => Some(10)
    "nov" => Some(11)
    "dec" => Some(12)
    _ => None
  }
}

///|
fn rss_weekday(day : String) -> @time.Weekday? {
  match day.to_lower() {
    "mon," => Some(@time.Monday)
    "tue," => Some(@time.Tuesday)
    "wed," => Some(@time.Wednesday)
    "thu," => Some(@time.Thursday)
    "fri," => Some(@time.Friday)
    "sat," => Some(@time.Saturday)
    "sun," => Some(@time.Sunday)
    _ => None
  }
}

///|
fn rss_offset(zone : String) -> Result[(Int32, Bool), DateError] {
  match zone.to_lower() {
    "ut" | "utc" | "gmt" | "z" => return Ok((0, true))
    "est" => return Ok((-300, true))
    "edt" => return Ok((-240, true))
    "cst" => return Ok((-360, true))
    "cdt" => return Ok((-300, true))
    "mst" => return Ok((-420, true))
    "mdt" => return Ok((-360, true))
    "pst" => return Ok((-480, true))
    "pdt" => return Ok((-420, true))
    _ => ()
  }
  let chars = date_chars(zone)
  if chars.length() != 5 || (chars[0] != '+' && chars[0] != '-') {
    return Err(UnsupportedTimeZone)
  }
  guard (date_digits(chars, 1, 2), date_digits(chars, 3, 2))
    is (Some(hour), Some(minute)) else {
    return Err(InvalidOffset)
  }
  if hour > 23 || minute > 59 {
    return Err(InvalidOffset)
  }
  Ok(
    (
      (hour * 60 + minute) * (if chars[0] == '-' { -1 } else { 1 }),
      !(chars[0] == '-' && hour == 0 && minute == 0),
    ),
  )
}

///|
fn normalize_rss_date(text : String) -> Result[NormalizedDate, DateError] {
  let tokens = rss_date_tokens(text)
  let mut start = 0
  let mut weekday : @time.Weekday? = None
  if tokens.length() == 6 {
    weekday = rss_weekday(tokens[0])
    if weekday is None {
      return Err(InvalidSyntax)
    }
    start = 1
  } else if tokens.length() != 5 {
    return Err(InvalidSyntax)
  }
  let day_chars = date_chars(tokens[start])
  let year_chars = date_chars(tokens[start + 2])
  if day_chars.length() < 1 ||
    day_chars.length() > 2 ||
    (year_chars.length() != 2 && year_chars.length() != 4) {
    return Err(InvalidSyntax)
  }
  guard (
      date_digits(day_chars, 0, day_chars.length()),
      rss_month(tokens[start + 1]),
      date_digits(year_chars, 0, year_chars.length()),
    )
    is (Some(day), Some(month), Some(raw_year)) else {
    return Err(InvalidSyntax)
  }
  // RSS 2.0 permits two- and four-digit years. RFC 2822 supplies a stable
  // interpretation for the former without reference to the current year.
  let year = if year_chars.length() == 2 {
    raw_year + (if raw_year < 50 { 2000 } else { 1900 })
  } else {
    raw_year
  }
  let time = date_chars(tokens[start + 3])
  if (time.length() != 5 && time.length() != 8) ||
    !date_literal(time, 2, ':') ||
    (time.length() == 8 && !date_literal(time, 5, ':')) {
    return Err(InvalidSyntax)
  }
  guard (date_digits(time, 0, 2), date_digits(time, 3, 2))
    is (Some(hour), Some(minute)) else {
    return Err(InvalidSyntax)
  }
  let second = if time.length() == 8 {
    match date_digits(time, 6, 2) {
      Some(second) => second
      None => return Err(InvalidSyntax)
    }
  } else {
    0
  }
  let (offset_minutes, offset_known) = match rss_offset(tokens[start + 4]) {
    Ok(offset) => offset
    Err(error) => return Err(error)
  }
  normalized_date(
    year,
    month,
    day,
    hour,
    minute,
    second,
    0,
    "",
    offset_minutes,
    offset_known,
    if time.length() == 8 {
      Second
    } else {
      Minute
    },
    weekday,
  )
}

///|
/// Validate an optional source date without changing the raw feed field.
/// RSS uses the supported RFC 822/1123 profile; Atom and JSON Feed use RFC
/// 3339. Atom additionally requires uppercase `T` and `Z`. A missing date
/// is `Ok(None)`, while malformed dates are `Err`.
pub fn normalize_date(
  format : FeedFormat,
  raw : String?,
) -> Result[NormalizedDate?, DateError] {
  match raw {
    None => Ok(None)
    Some(text) => {
      let date = match format {
        Rss2 => normalize_rss_date(text)
        Atom1 => normalize_rfc3339(text, true)
        JsonFeed1 => normalize_rfc3339(text, false)
      }
      match date {
        Ok(value) => Ok(Some(value))
        Err(error) => Err(error)
      }
    }
  }
}