///|
/// A proleptic Gregorian calendar date.
pub struct Date {
  year : Int
  month : Int
  day : Int
} derive(Eq, Compare, Debug)

///|
/// Clock time without a time-zone offset.
pub struct Time {
  hour : Int
  minute : Int
  second : Int
} derive(Eq, Compare, Debug)

///|
/// A local date and time. RRuleLab deliberately keeps local time separate
/// from time-zone conversion so recurrence arithmetic remains deterministic.
pub struct DateTime {
  date : Date
  time : Time
} derive(Eq, Compare, Debug)

///|
/// Days of the week use ISO order (Monday first).
pub enum Weekday {
  Monday
  Tuesday
  Wednesday
  Thursday
  Friday
  Saturday
  Sunday
} derive(Eq, Compare, Debug)

///|
pub suberror DateTimeError {
  InvalidDate(Int, Int, Int)
  InvalidTime(Int, Int, Int)
  InvalidFormat(String)
} derive(Eq, Debug)

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

///|
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
  }
}

///|
pub fn Date::new(
  year : Int,
  month : Int,
  day : Int,
) -> Date raise DateTimeError {
  let limit = days_in_month(year, month)
  if year < 1 || limit == 0 || day < 1 || day > limit {
    raise InvalidDate(year, month, day)
  }
  { year, month, day }
}

///|
pub fn Time::new(
  hour : Int,
  minute : Int,
  second : Int,
) -> Time raise DateTimeError {
  if hour < 0 ||
    hour > 23 ||
    minute < 0 ||
    minute > 59 ||
    second < 0 ||
    second > 60 {
    raise InvalidTime(hour, minute, second)
  }
  { hour, minute, second }
}

///|
pub fn DateTime::new(
  year : Int,
  month : Int,
  day : Int,
  hour : Int,
  minute : Int,
  second : Int,
) -> DateTime raise DateTimeError {
  { date: Date::new(year, month, day), time: Time::new(hour, minute, second) }
}

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

///|
fn pad4(value : Int) -> String {
  if value < 10 {
    "000" + value.to_string()
  } else if value < 100 {
    "00" + value.to_string()
  } else if value < 1000 {
    "0" + value.to_string()
  } else {
    value.to_string()
  }
}

///|
pub fn Date::to_iso_string(self : Date) -> String {
  pad4(self.year) + "-" + pad2(self.month) + "-" + pad2(self.day)
}

///|
pub fn Time::to_iso_string(self : Time) -> String {
  pad2(self.hour) + ":" + pad2(self.minute) + ":" + pad2(self.second)
}

///|
pub fn DateTime::to_iso_string(self : DateTime) -> String {
  self.date.to_iso_string() + "T" + self.time.to_iso_string()
}

///|
fn parse_decimal(text : String, label : String) -> Int raise DateTimeError {
  @string.parse_int(text, base=10) catch {
    _ => raise InvalidFormat("invalid " + label + ": " + text)
  }
}

///|
pub fn parse_date(text : String) -> Date raise DateTimeError {
  if text.length() != 10 ||
    text[4:5].to_owned() != "-" ||
    text[7:8].to_owned() != "-" {
    raise InvalidFormat("expected YYYY-MM-DD: " + text)
  }
  Date::new(
    parse_decimal(text[:4].to_owned(), "year"),
    parse_decimal(text[5:7].to_owned(), "month"),
    parse_decimal(text[8:10].to_owned(), "day"),
  )
}

///|
pub fn parse_basic_date(text : String) -> Date raise DateTimeError {
  if text.length() != 8 {
    raise InvalidFormat("expected YYYYMMDD: " + text)
  }
  Date::new(
    parse_decimal(text[:4].to_owned(), "year"),
    parse_decimal(text[4:6].to_owned(), "month"),
    parse_decimal(text[6:8].to_owned(), "day"),
  )
}

///|
pub fn parse_time(text : String) -> Time raise DateTimeError {
  if text.length() != 8 ||
    text[2:3].to_owned() != ":" ||
    text[5:6].to_owned() != ":" {
    raise InvalidFormat("expected HH:MM:SS: " + text)
  }
  Time::new(
    parse_decimal(text[:2].to_owned(), "hour"),
    parse_decimal(text[3:5].to_owned(), "minute"),
    parse_decimal(text[6:8].to_owned(), "second"),
  )
}

///|
pub fn parse_datetime(text : String) -> DateTime raise DateTimeError {
  if text.length() == 15 && text[8:9].to_owned() == "T" {
    return DateTime::new(
      parse_decimal(text[:4].to_owned(), "year"),
      parse_decimal(text[4:6].to_owned(), "month"),
      parse_decimal(text[6:8].to_owned(), "day"),
      parse_decimal(text[9:11].to_owned(), "hour"),
      parse_decimal(text[11:13].to_owned(), "minute"),
      parse_decimal(text[13:15].to_owned(), "second"),
    )
  }
  if text.length() == 19 && text[10:11].to_owned() == "T" {
    return {
      date: parse_date(text[:10].to_owned()),
      time: parse_time(text[11:19].to_owned()),
    }
  }
  raise InvalidFormat("expected basic or extended date-time: " + text)
}

///|
/// Howard Hinnant's civil calendar conversion, shifted to Unix epoch days.
pub fn Date::to_epoch_day(self : Date) -> Int {
  let adjusted_year = if self.month <= 2 { self.year - 1 } else { self.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 self.month > 2 {
    self.month - 3
  } else {
    self.month + 9
  }
  let day_of_year = (153 * shifted_month + 2) / 5 + self.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
}

///|
pub fn date_from_epoch_day(epoch_day : Int) -> Date {
  let serial = epoch_day + 719468
  let era = if serial >= 0 {
    serial / 146097
  } else {
    (serial - 146096) / 146097
  }
  let day_of_era = serial - era * 146097
  let year_of_era = (
      day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146096
    ) /
    365
  let provisional_year = year_of_era + era * 400
  let day_of_year = day_of_era -
    (365 * year_of_era + year_of_era / 4 - year_of_era / 100)
  let month_prime = (5 * day_of_year + 2) / 153
  let day = day_of_year - (153 * month_prime + 2) / 5 + 1
  let month = if month_prime < 10 { month_prime + 3 } else { month_prime - 9 }
  let year = provisional_year + (if month <= 2 { 1 } else { 0 })
  { year, month, day }
}

///|
pub fn Date::add_days(self : Date, amount : Int) -> Date {
  date_from_epoch_day(self.to_epoch_day() + amount)
}

///|
pub fn Date::add_months(self : Date, amount : Int) -> Date {
  let month_index = self.year * 12 + self.month - 1 + amount
  let year = if month_index >= 0 {
    month_index / 12
  } else {
    (month_index - 11) / 12
  }
  let month = month_index - year * 12 + 1
  let day = self.day.min(days_in_month(year, month))
  { year, month, day }
}

///|
pub fn Date::add_years(self : Date, amount : Int) -> Date {
  let year = self.year + amount
  let day = self.day.min(days_in_month(year, self.month))
  { year, month: self.month, day }
}

///|
pub fn Date::day_of_year(self : Date) -> Int {
  let mut total = self.day
  for month = 1; month < self.month; month = month + 1 {
    total = total + days_in_month(self.year, month)
  }
  total
}

///|
pub fn Date::weekday(self : Date) -> Weekday {
  let index = ((self.to_epoch_day() + 3) % 7 + 7) % 7
  match index {
    0 => Monday
    1 => Tuesday
    2 => Wednesday
    3 => Thursday
    4 => Friday
    5 => Saturday
    _ => Sunday
  }
}

///|
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 Weekday::code(self : Weekday) -> String {
  match self {
    Monday => "MO"
    Tuesday => "TU"
    Wednesday => "WE"
    Thursday => "TH"
    Friday => "FR"
    Saturday => "SA"
    Sunday => "SU"
  }
}

///|
pub fn weekday_from_code(code : String) -> Weekday? {
  match code.to_upper() {
    "MO" => Some(Monday)
    "TU" => Some(Tuesday)
    "WE" => Some(Wednesday)
    "TH" => Some(Thursday)
    "FR" => Some(Friday)
    "SA" => Some(Saturday)
    "SU" => Some(Sunday)
    _ => None
  }
}

///|
pub fn DateTime::to_epoch_second(self : DateTime) -> Int64 {
  self.date.to_epoch_day().to_int64() * 86400L +
  self.time.hour.to_int64() * 3600L +
  self.time.minute.to_int64() * 60L +
  self.time.second.to_int64()
}

///|
pub fn DateTime::add_seconds(self : DateTime, amount : Int64) -> DateTime {
  let total = self.to_epoch_second() + amount
  let day = if total >= 0L { total / 86400L } else { (total - 86399L) / 86400L }
  let seconds = total - day * 86400L
  {
    date: date_from_epoch_day(day.to_int()),
    time: {
      hour: (seconds / 3600L).to_int(),
      minute: (seconds % 3600L / 60L).to_int(),
      second: (seconds % 60L).to_int(),
    },
  }
}

///|
pub fn DateTime::add_days(self : DateTime, amount : Int) -> DateTime {
  { date: self.date.add_days(amount), time: self.time }
}

///|
pub fn DateTime::add_months(self : DateTime, amount : Int) -> DateTime {
  { date: self.date.add_months(amount), time: self.time }
}

///|
pub fn DateTime::add_years(self : DateTime, amount : Int) -> DateTime {
  { date: self.date.add_years(amount), time: self.time }
}