///|
/// Reports invalid civil date and clock values.
pub(all) enum DateTimeError {
  InvalidYear(Int)
  InvalidMonth(Int)
  InvalidDay(Int)
  InvalidHour(Int)
  InvalidMinute(Int)
  InvalidSecond(Int)
} derive(Eq, Debug)

///|
pub fn DateTimeError::message(self : DateTimeError) -> String {
  match self {
    InvalidYear(value) => "year must be between 1 and 9999, got \{value}"
    InvalidMonth(value) => "month must be between 1 and 12, got \{value}"
    InvalidDay(value) => "day is outside the selected month, got \{value}"
    InvalidHour(value) => "hour must be between 0 and 23, got \{value}"
    InvalidMinute(value) => "minute must be between 0 and 59, got \{value}"
    InvalidSecond(value) => "second must be between 0 and 59, got \{value}"
  }
}

///|
/// A Gregorian calendar date without a time zone.
pub struct CivilDate {
  year : Int
  month : Int
  day : Int
} derive(Eq, Compare, Debug)

///|
pub fn CivilDate::new(
  year : Int,
  month : Int,
  day : Int,
) -> Result[CivilDate, DateTimeError] {
  if year < 1 || year > 9999 {
    return Err(InvalidYear(year))
  }
  if month < 1 || month > 12 {
    return Err(InvalidMonth(month))
  }
  if day < 1 || day > days_in_month(year, month) {
    return Err(InvalidDay(day))
  }
  Ok({ year, month, day })
}

///|
pub fn CivilDate::year(self : CivilDate) -> Int {
  self.year
}

///|
pub fn CivilDate::month(self : CivilDate) -> Int {
  self.month
}

///|
pub fn CivilDate::day(self : CivilDate) -> Int {
  self.day
}

///|
pub fn CivilDate::to_iso_string(self : CivilDate) -> String {
  "\{pad_year(self.year)}-\{pad_two(self.month)}-\{pad_two(self.day)}"
}

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

///|
pub fn days_in_month(year : Int, month : Int) -> Int {
  match month {
    2 => if is_leap_year(year) { 29 } else { 28 }
    4 | 6 | 9 | 11 => 30
    1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
    _ => 0
  }
}

///|
pub(all) enum Weekday {
  Monday
  Tuesday
  Wednesday
  Thursday
  Friday
  Saturday
  Sunday
} derive(Eq, Compare, Debug)

///|
pub fn Weekday::iso_number(self : Weekday) -> Int {
  match self {
    Monday => 1
    Tuesday => 2
    Wednesday => 3
    Thursday => 4
    Friday => 5
    Saturday => 6
    Sunday => 7
  }
}

///|
pub fn CivilDate::weekday(self : CivilDate) -> Weekday {
  let offsets = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
  let adjusted_year = if self.month < 3 { self.year - 1 } else { self.year }
  let sunday_based = (
      adjusted_year +
      adjusted_year / 4 -
      adjusted_year / 100 +
      adjusted_year / 400 +
      offsets[self.month - 1] +
      self.day
    ) %
    7
  match sunday_based {
    0 => Sunday
    1 => Monday
    2 => Tuesday
    3 => Wednesday
    4 => Thursday
    5 => Friday
    _ => Saturday
  }
}

///|
/// A wall-clock time without a date or time zone.
pub struct CivilTime {
  hour : Int
  minute : Int
  second : Int
} derive(Eq, Compare, Debug)

///|
pub fn CivilTime::new(
  hour : Int,
  minute : Int,
  second : Int,
) -> Result[CivilTime, DateTimeError] {
  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))
  }
  Ok({ hour, minute, second })
}

///|
pub fn CivilTime::hour(self : CivilTime) -> Int {
  self.hour
}

///|
pub fn CivilTime::minute(self : CivilTime) -> Int {
  self.minute
}

///|
pub fn CivilTime::second(self : CivilTime) -> Int {
  self.second
}

///|
pub fn CivilTime::to_iso_string(self : CivilTime) -> String {
  "\{pad_two(self.hour)}:\{pad_two(self.minute)}:\{pad_two(self.second)}"
}

///|
pub(all) enum DateStyle {
  Numeric
  Short
  Medium
  Long
  Full
} derive(Eq, Debug)

///|
pub(all) enum HourCycle {
  LocaleDefault
  Hour12
  Hour24
} derive(Eq, Debug)

///|
pub struct DateTimeFormatter {
  locale : Locale
  date_style : DateStyle
  hour_cycle : HourCycle
  include_seconds : Bool
} derive(Eq, Debug)

///|
pub fn DateTimeFormatter::new(locale : Locale) -> DateTimeFormatter {
  {
    locale,
    date_style: Medium,
    hour_cycle: LocaleDefault,
    include_seconds: false,
  }
}

///|
pub fn DateTimeFormatter::with_date_style(
  self : DateTimeFormatter,
  style : DateStyle,
) -> DateTimeFormatter {
  { ..self, date_style: style }
}

///|
pub fn DateTimeFormatter::with_hour_cycle(
  self : DateTimeFormatter,
  cycle : HourCycle,
) -> DateTimeFormatter {
  { ..self, hour_cycle: cycle }
}

///|
pub fn DateTimeFormatter::with_seconds(
  self : DateTimeFormatter,
  enabled : Bool,
) -> DateTimeFormatter {
  { ..self, include_seconds: enabled }
}

///|
fn pad_two(value : Int) -> String {
  if value < 10 {
    "0\{value}"
  } else {
    value.to_string()
  }
}

///|
fn pad_year(value : Int) -> String {
  let rendered = value.to_string()
  repeat_string("0", 4 - rendered.length()) + rendered
}

///|
fn month_name(locale : Locale, month : Int, short : Bool) -> String {
  match locale.language() {
    "zh" | "ja" | "ko" => month.to_string()
    "fr" =>
      if short {
        [
          "janv.", "f\u{e9}vr.", "mars", "avr.", "mai", "juin", "juil.", "ao\u{fb}t",
          "sept.", "oct.", "nov.", "d\u{e9}c.",
        ][month - 1]
      } else {
        [
          "janvier", "f\u{e9}vrier", "mars", "avril", "mai", "juin", "juillet", "ao\u{fb}t",
          "septembre", "octobre", "novembre", "d\u{e9}cembre",
        ][month - 1]
      }
    "de" =>
      if short {
        [
          "Jan.", "Feb.", "M\u{e4}rz", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.",
          "Okt.", "Nov.", "Dez.",
        ][month - 1]
      } else {
        [
          "Januar", "Februar", "M\u{e4}rz", "April", "Mai", "Juni", "Juli", "August",
          "September", "Oktober", "November", "Dezember",
        ][month - 1]
      }
    "es" =>
      [
        "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto",
        "septiembre", "octubre", "noviembre", "diciembre",
      ][month - 1]
    _ =>
      if short {
        [
          "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
          "Dec",
        ][month - 1]
      } else {
        [
          "January", "February", "March", "April", "May", "June", "July", "August",
          "September", "October", "November", "December",
        ][month - 1]
      }
  }
}

///|
fn weekday_name(locale : Locale, weekday : Weekday, short : Bool) -> String {
  let index = weekday.iso_number() - 1
  match locale.language() {
    "zh" =>
      if short {
        [
          "\u{4e00}", "\u{4e8c}", "\u{4e09}", "\u{56db}", "\u{4e94}", "\u{516d}",
          "\u{65e5}",
        ][index]
      } else {
        [
          "\u{661f}\u{671f}\u{4e00}", "\u{661f}\u{671f}\u{4e8c}", "\u{661f}\u{671f}\u{4e09}",
          "\u{661f}\u{671f}\u{56db}", "\u{661f}\u{671f}\u{4e94}", "\u{661f}\u{671f}\u{516d}",
          "\u{661f}\u{671f}\u{65e5}",
        ][index]
      }
    "fr" =>
      if short {
        ["lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."][index]
      } else {
        [
          "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche",
        ][index]
      }
    "de" =>
      if short {
        ["Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa.", "So."][index]
      } else {
        [
          "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag",
        ][index]
      }
    _ =>
      if short {
        ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][index]
      } else {
        [
          "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
        ][index]
      }
  }
}

///|
fn is_month_first(locale : Locale) -> Bool {
  if locale.language() != "en" {
    return false
  }
  match locale.region() {
    Some("GB") | Some("AU") | Some("NZ") | Some("IE") => false
    _ => true
  }
}

///|
pub fn DateTimeFormatter::format_date(
  self : DateTimeFormatter,
  date : CivilDate,
) -> String {
  let language = self.locale.language()
  match self.date_style {
    Numeric =>
      if language == "zh" || language == "ja" {
        "\{date.year}/\{date.month}/\{date.day}"
      } else if is_month_first(self.locale) {
        "\{date.month}/\{date.day}/\{date.year}"
      } else {
        "\{pad_two(date.day)}/\{pad_two(date.month)}/\{date.year}"
      }
    Short =>
      if language == "zh" || language == "ja" {
        "\{date.year}-\{pad_two(date.month)}-\{pad_two(date.day)}"
      } else if is_month_first(self.locale) {
        "\{month_name(self.locale, date.month, true)} \{date.day}, \{date.year}"
      } else {
        "\{date.day} \{month_name(self.locale, date.month, true)} \{date.year}"
      }
    Medium | Long =>
      if language == "zh" {
        "\{date.year}\u{5e74}\{date.month}\u{6708}\{date.day}\u{65e5}"
      } else if language == "ja" {
        "\{date.year}\u{5e74}\{date.month}\u{6708}\{date.day}\u{65e5}"
      } else if is_month_first(self.locale) {
        "\{month_name(self.locale, date.month, self.date_style == Medium)} \{date.day}, \{date.year}"
      } else {
        "\{date.day} \{month_name(self.locale, date.month, self.date_style == Medium)} \{date.year}"
      }
    Full => {
      let core = self.with_date_style(Long).format_date(date)
      if language == "zh" {
        "\{core}\{weekday_name(self.locale, date.weekday(), false)}"
      } else {
        "\{weekday_name(self.locale, date.weekday(), false)}, \{core}"
      }
    }
  }
}

///|
fn locale_prefers_hour12(locale : Locale) -> Bool {
  if locale.language() != "en" {
    return false
  }
  match locale.region() {
    Some("GB") | Some("AU") | Some("NZ") | Some("IE") => false
    _ => true
  }
}

///|
pub fn DateTimeFormatter::format_time(
  self : DateTimeFormatter,
  time : CivilTime,
) -> String {
  let hour12 = match self.hour_cycle {
    Hour12 => true
    Hour24 => false
    LocaleDefault => locale_prefers_hour12(self.locale)
  }
  let tail = if self.include_seconds { ":\{pad_two(time.second)}" } else { "" }
  if hour12 {
    let period = if time.hour < 12 { "AM" } else { "PM" }
    let hour = match time.hour {
      0 => 12
      value if value > 12 => value - 12
      value => value
    }
    "\{hour}:\{pad_two(time.minute)}\{tail} \{period}"
  } else {
    "\{pad_two(time.hour)}:\{pad_two(time.minute)}\{tail}"
  }
}

///|
pub fn DateTimeFormatter::format_datetime(
  self : DateTimeFormatter,
  date : CivilDate,
  time : CivilTime,
) -> String {
  self.format_date(date) + ", " + self.format_time(time)
}