///|
/// Parsed UTC timestamp and the original offset in seconds.
pub(all) struct ParsedTimestamp {
  unix_seconds : Int64
  offset_seconds : Int
} derive(Debug, Eq)

///|
/// Errors produced by the restricted ISO-8601 parser.
pub(all) enum TimestampError {
  EmptyTimestamp
  InvalidLength
  InvalidSeparator
  InvalidDigit(Int)
  InvalidYear(Int)
  InvalidMonth(Int)
  InvalidDay(Int)
  InvalidHour(Int)
  InvalidMinute(Int)
  InvalidSecond(Int)
  InvalidOffset
  TrailingContent
} derive(Debug, Eq)

///|
/// Materialize characters for predictable indexed parsing.
fn timestamp_chars(text : String) -> Array[Char] {
  let chars : Array[Char] = []
  for char in text {
    chars.push(char)
  }
  chars
}

///|
/// Decimal value of an ASCII digit.
fn ascii_digit(char : Char, index : Int) -> Result[Int, TimestampError] {
  match char {
    '0' => Ok(0)
    '1' => Ok(1)
    '2' => Ok(2)
    '3' => Ok(3)
    '4' => Ok(4)
    '5' => Ok(5)
    '6' => Ok(6)
    '7' => Ok(7)
    '8' => Ok(8)
    '9' => Ok(9)
    _ => Err(InvalidDigit(index))
  }
}

///|
/// Parse two ASCII digits from a character array.
fn parse_two_digits(
  chars : Array[Char],
  start : Int,
) -> Result[Int, TimestampError] {
  if start < 0 || start + 1 >= chars.length() {
    return Err(InvalidLength)
  }
  match
    (ascii_digit(chars[start], start), ascii_digit(chars[start + 1], start + 1)) {
    (Ok(a), Ok(b)) => Ok(a * 10 + b)
    (Err(error), _) => Err(error)
    (_, Err(error)) => Err(error)
  }
}

///|
/// Parse four ASCII digits from a character array.
fn parse_four_digits(
  chars : Array[Char],
  start : Int,
) -> Result[Int, TimestampError] {
  if start < 0 || start + 3 >= chars.length() {
    return Err(InvalidLength)
  }
  match
    (
      ascii_digit(chars[start], start),
      ascii_digit(chars[start + 1], start + 1),
      ascii_digit(chars[start + 2], start + 2),
      ascii_digit(chars[start + 3], start + 3),
    ) {
    (Ok(a), Ok(b), Ok(c), Ok(d)) => Ok(a * 1000 + b * 100 + c * 10 + d)
    (Err(error), _, _, _) => Err(error)
    (_, Err(error), _, _) => Err(error)
    (_, _, Err(error), _) => Err(error)
    (_, _, _, Err(error)) => Err(error)
  }
}

///|
/// Gregorian leap-year rule.
pub fn is_leap_year(year : Int) -> Bool {
  year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}

///|
/// Number of days in a Gregorian month, or zero for an invalid month.
pub fn days_in_month(year : Int, month : Int) -> Int {
  match month {
    1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
    4 | 6 | 9 | 11 => 30
    2 => if is_leap_year(year) { 29 } else { 28 }
    _ => 0
  }
}

///|
/// Convert a civil date to days since 1970-01-01.
/// Algorithm adapted from the public-domain civil calendar arithmetic by
/// Howard Hinnant; only the mathematical formula is used.
pub fn days_from_civil(year : Int, month : Int, day : Int) -> Int64 {
  let adjusted_year = if month <= 2 { year - 1 } else { year }
  let era = if adjusted_year >= 0 {
    adjusted_year / 400
  } else {
    (adjusted_year - 399) / 400
  }
  let year_of_era = adjusted_year - era * 400
  let shifted_month = if month > 2 { month - 3 } else { month + 9 }
  let day_of_year = (153 * shifted_month + 2) / 5 + day - 1
  let day_of_era = year_of_era * 365 +
    year_of_era / 4 -
    year_of_era / 100 +
    day_of_year
  (era * 146097 + day_of_era - 719468).to_int64()
}

///|
/// Convert validated date-time fields and an explicit offset to Unix seconds.
pub fn unix_seconds_from_parts(
  year : Int,
  month : Int,
  day : Int,
  hour : Int,
  minute : Int,
  second : Int,
  offset_seconds : Int,
) -> Int64 {
  let local_seconds = days_from_civil(year, month, day) * 86400L +
    hour.to_int64() * 3600L +
    minute.to_int64() * 60L +
    second.to_int64()
  local_seconds - offset_seconds.to_int64()
}

///|
/// Parse `YYYY-MM-DDTHH:MM:SSZ` or the same timestamp with `±HH:MM` offset.
pub fn parse_iso8601(text : String) -> Result[ParsedTimestamp, TimestampError] {
  if text.is_empty() {
    return Err(EmptyTimestamp)
  }
  let chars = timestamp_chars(text)
  if chars.length() < 20 {
    return Err(InvalidLength)
  }
  if chars[4] != '-' ||
    chars[7] != '-' ||
    (chars[10] != 'T' && chars[10] != 't' && chars[10] != ' ') ||
    chars[13] != ':' ||
    chars[16] != ':' {
    return Err(InvalidSeparator)
  }
  let year = match parse_four_digits(chars, 0) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let month = match parse_two_digits(chars, 5) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let day = match parse_two_digits(chars, 8) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let hour = match parse_two_digits(chars, 11) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let minute = match parse_two_digits(chars, 14) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let second = match parse_two_digits(chars, 17) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  if year < 1 || year > 9999 {
    return Err(InvalidYear(year))
  }
  if month < 1 || month > 12 {
    return Err(InvalidMonth(month))
  }
  let maximum_day = days_in_month(year, month)
  if day < 1 || day > maximum_day {
    return Err(InvalidDay(day))
  }
  if hour < 0 || hour > 23 {
    return Err(InvalidHour(hour))
  }
  if minute < 0 || minute > 59 {
    return Err(InvalidMinute(minute))
  }
  if second < 0 || second > 59 {
    return Err(InvalidSecond(second))
  }
  let mut offset_seconds = 0
  if chars.length() == 20 && (chars[19] == 'Z' || chars[19] == 'z') {
    offset_seconds = 0
  } else if chars.length() == 25 && (chars[19] == '+' || chars[19] == '-') {
    if chars[22] != ':' {
      return Err(InvalidOffset)
    }
    let offset_hour = match parse_two_digits(chars, 20) {
      Ok(value) => value
      Err(_) => return Err(InvalidOffset)
    }
    let offset_minute = match parse_two_digits(chars, 23) {
      Ok(value) => value
      Err(_) => return Err(InvalidOffset)
    }
    if offset_hour > 23 || offset_minute > 59 {
      return Err(InvalidOffset)
    }
    let magnitude = offset_hour * 3600 + offset_minute * 60
    offset_seconds = if chars[19] == '-' { -magnitude } else { magnitude }
  } else if chars.length() > 20 {
    return Err(TrailingContent)
  } else {
    return Err(InvalidOffset)
  }
  Ok({
    unix_seconds: unix_seconds_from_parts(
      year, month, day, hour, minute, second, offset_seconds,
    ),
    offset_seconds,
  })
}

///|
/// Floor division for positive divisor, used when formatting pre-epoch values.
fn floor_div_i64(value : Int64, divisor : Int64) -> Int64 {
  let quotient = value / divisor
  let remainder = value % divisor
  if remainder != 0L && value < 0L {
    quotient - 1L
  } else {
    quotient
  }
}

///|
/// Convert days since Unix epoch back to Gregorian year, month and day.
pub fn civil_from_days(days : Int64) -> (Int, Int, Int) {
  let shifted = days + 719468L
  let era = if shifted >= 0L {
    shifted / 146097L
  } else {
    (shifted - 146096L) / 146097L
  }
  let day_of_era = shifted - era * 146097L
  let year_of_era = (
      day_of_era -
      day_of_era / 1460L +
      day_of_era / 36524L -
      day_of_era / 146096L
    ) /
    365L
  let mut year = (year_of_era + era * 400L).to_int()
  let day_of_year = day_of_era -
    (365L * year_of_era + year_of_era / 4L - year_of_era / 100L)
  let month_prime = (5L * day_of_year + 2L) / 153L
  let day = (day_of_year - (153L * month_prime + 2L) / 5L + 1L).to_int()
  let month = if month_prime < 10L {
    (month_prime + 3L).to_int()
  } else {
    (month_prime - 9L).to_int()
  }
  if month <= 2 {
    year = year + 1
  }
  (year, month, day)
}

///|
/// Append a two-digit decimal component.
fn write_two_digits(builder : StringBuilder, value : Int) -> Unit {
  if value < 10 {
    builder.write_char('0')
  }
  builder.write_object(value)
}

///|
/// Append a four-digit year.
fn write_four_digits(builder : StringBuilder, value : Int) -> Unit {
  if value < 10 {
    builder.write_string("000")
  } else if value < 100 {
    builder.write_string("00")
  } else if value < 1000 {
    builder.write_char('0')
  }
  builder.write_object(value)
}

///|
/// Format UTC Unix seconds as canonical `YYYY-MM-DDTHH:MM:SSZ`.
pub fn format_iso8601(unix_seconds : Int64) -> String {
  let days = floor_div_i64(unix_seconds, 86400L)
  let seconds_of_day = unix_seconds - days * 86400L
  let hour = (seconds_of_day / 3600L).to_int()
  let minute = (seconds_of_day % 3600L / 60L).to_int()
  let second = (seconds_of_day % 60L).to_int()
  let (year, month, day) = civil_from_days(days)
  let builder = StringBuilder::new()
  write_four_digits(builder, year)
  builder.write_char('-')
  write_two_digits(builder, month)
  builder.write_char('-')
  write_two_digits(builder, day)
  builder.write_char('T')
  write_two_digits(builder, hour)
  builder.write_char(':')
  write_two_digits(builder, minute)
  builder.write_char(':')
  write_two_digits(builder, second)
  builder.write_char('Z')
  builder.to_string()
}

///|
/// Human readable description for timestamp parse errors.
pub fn timestamp_error_message(error : TimestampError) -> String {
  match error {
    EmptyTimestamp => "timestamp is empty"
    InvalidLength => "timestamp has an invalid length"
    InvalidSeparator => "timestamp separators do not match ISO-8601"
    InvalidDigit(index) => "timestamp contains a non-digit at index \{index}"
    InvalidYear(year) => "timestamp year is invalid: \{year}"
    InvalidMonth(month) => "timestamp month is invalid: \{month}"
    InvalidDay(day) => "timestamp day is invalid: \{day}"
    InvalidHour(hour) => "timestamp hour is invalid: \{hour}"
    InvalidMinute(minute) => "timestamp minute is invalid: \{minute}"
    InvalidSecond(second) => "timestamp second is invalid: \{second}"
    InvalidOffset => "timestamp UTC offset is invalid"
    TrailingContent => "timestamp has unsupported trailing content"
  }
}

///|
/// Convert seconds into a compact audit-friendly duration.
pub fn format_duration(seconds : Int64) -> String {
  let sign = if seconds < 0L { "-" } else { "" }
  let absolute = if seconds < 0L { -seconds } else { seconds }
  let days = absolute / 86400L
  let hours = absolute % 86400L / 3600L
  let minutes = absolute % 3600L / 60L
  let remaining_seconds = absolute % 60L
  let builder = StringBuilder::new()
  builder.write_string(sign)
  if days > 0L {
    builder.write_object(days)
    builder.write_char('d')
  }
  if hours > 0L || days > 0L {
    builder.write_object(hours)
    builder.write_char('h')
  }
  if minutes > 0L || hours > 0L || days > 0L {
    builder.write_object(minutes)
    builder.write_char('m')
  }
  builder.write_object(remaining_seconds)
  builder.write_char('s')
  builder.to_string()
}