///|
/// A calendar-only value in the proleptic Gregorian calendar.
pub struct UtcDate {
  year : Int
  month : Int
  day : Int
} derive(Eq, Compare, Debug)

///|
/// Construct a validated UTC calendar date.
pub fn UtcDate::new(
  year : Int,
  month : Int,
  day : Int,
) -> Result[UtcDate, CronError] {
  match UtcDateTime::new(year, month, day, 0, 0) {
    Ok(_) => Ok({ year, month, day })
    Err(error) => Err(error)
  }
}

///|
/// Convert a date-time to its calendar date.
pub fn UtcDateTime::date(self : UtcDateTime) -> UtcDate {
  { year: self.year, month: self.month, day: self.day }
}

///|
/// Start of this calendar date.
pub fn UtcDate::start(self : UtcDate) -> UtcDateTime {
  { year: self.year, month: self.month, day: self.day, hour: 0, minute: 0 }
}

///|
/// Last minute of this calendar date.
pub fn UtcDate::end(self : UtcDate) -> UtcDateTime {
  { year: self.year, month: self.month, day: self.day, hour: 23, minute: 59 }
}

///|
/// Create a date-time on this date with a validated hour and minute.
pub fn UtcDate::at(
  self : UtcDate,
  hour : Int,
  minute : Int,
) -> Result[UtcDateTime, CronError] {
  UtcDateTime::new(self.year, self.month, self.day, hour, minute)
}

///|
fn days_before_year(year : Int) -> Int {
  let previous = year - 1
  previous * 365 + previous / 4 - previous / 100 + previous / 400
}

///|
fn days_before_month(year : Int, month : Int) -> Int {
  let cumulative = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
  let base = cumulative[month - 1]
  if month > 2 && is_leap_year(year) {
    base + 1
  } else {
    base
  }
}

///|
/// Zero-based Gregorian ordinal, where 0001-01-01 is day zero.
fn UtcDate::ordinal(self : UtcDate) -> Int {
  days_before_year(self.year) +
  days_before_month(self.year, self.month) +
  self.day -
  1
}

///|
/// Number of whole calendar days from `self` to `other`.
pub fn UtcDate::days_until(self : UtcDate, other : UtcDate) -> Int {
  other.ordinal() - self.ordinal()
}

///|
fn date_from_ordinal(ordinal : Int) -> UtcDate? {
  if ordinal < 0 || ordinal > days_before_year(10000) - 1 {
    return None
  }
  let mut low = 1
  let mut high = 10000
  for ;; {
    if low >= high {
      break
    }
    let middle = low + (high - low) / 2
    if days_before_year(middle) <= ordinal {
      low = middle + 1
    } else {
      high = middle
    }
  }
  let year = low - 1
  let day_of_year = ordinal - days_before_year(year)
  let mut month = 1
  for ;; {
    if month > 12 || day_of_year < days_before_month(year, month) {
      break
    }
    if month == 12 || day_of_year < days_before_month(year, month + 1) {
      break
    }
    month += 1
  }
  let day = day_of_year - days_before_month(year, month) + 1
  Some({ year, month, day })
}

///|
/// Add signed calendar days, returning `None` outside years 1 through 9999.
pub fn UtcDate::add_days(self : UtcDate, amount : Int) -> UtcDate? {
  date_from_ordinal(self.ordinal() + amount)
}

///|
/// Add signed calendar days while preserving the wall-clock time.
pub fn UtcDateTime::add_days(self : UtcDateTime, amount : Int) -> UtcDateTime? {
  match self.date().add_days(amount) {
    Some(date) =>
      Some({
        year: date.year,
        month: date.month,
        day: date.day,
        hour: self.hour,
        minute: self.minute,
      })
    None => None
  }
}

///|
fn floor_div(value : Int, divisor : Int) -> Int {
  if value >= 0 {
    value / divisor
  } else {
    -((-value + divisor - 1) / divisor)
  }
}

///|
fn positive_mod(value : Int, divisor : Int) -> Int {
  let remainder = value % divisor
  if remainder < 0 {
    remainder + divisor
  } else {
    remainder
  }
}

///|
/// Add signed minutes, carrying across all calendar boundaries.
pub fn UtcDateTime::add_minutes(
  self : UtcDateTime,
  amount : Int,
) -> UtcDateTime? {
  let minute_of_day = self.hour * 60 + self.minute
  let total = minute_of_day + amount
  let day_delta = floor_div(total, 1440)
  let target_minute = positive_mod(total, 1440)
  match self.date().add_days(day_delta) {
    Some(date) =>
      Some({
        year: date.year,
        month: date.month,
        day: date.day,
        hour: target_minute / 60,
        minute: target_minute % 60,
      })
    None => None
  }
}

///|
/// Signed minute distance from `self` to `other`.
///
/// Intended for operational windows rather than the entire 9999-year range.
pub fn UtcDateTime::minutes_until(
  self : UtcDateTime,
  other : UtcDateTime,
) -> Int {
  let day_delta = self.date().days_until(other.date())
  let self_minute = self.hour * 60 + self.minute
  let other_minute = other.hour * 60 + other.minute
  day_delta * 1440 + other_minute - self_minute
}

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

///|
fn four_digits(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()
  }
}

///|
/// ISO-like UTC representation at minute precision.
pub fn UtcDateTime::to_iso8601(self : UtcDateTime) -> String {
  four_digits(self.year) +
  "-" +
  two_digits(self.month) +
  "-" +
  two_digits(self.day) +
  "T" +
  two_digits(self.hour) +
  ":" +
  two_digits(self.minute) +
  "Z"
}

///|
/// ISO calendar-date representation.
pub fn UtcDate::to_iso8601(self : UtcDate) -> String {
  four_digits(self.year) +
  "-" +
  two_digits(self.month) +
  "-" +
  two_digits(self.day)
}

///|
fn ascii_decimal_slice(text : String, start : Int, end : Int) -> Int? {
  if start < 0 || end > text.length() || start >= end {
    return None
  }
  let mut value = 0
  for index in start.. 57 {
      return None
    }
    value = value * 10 + code - 48
  }
  Some(value)
}

///|
/// Parse `YYYY-MM-DD`.
pub fn parse_utc_date(text : String) -> Result[UtcDate, CronError] {
  if text.length() != 10 || text[4] != '-' || text[7] != '-' {
    return Err(InvalidDate(text))
  }
  match
    (
      ascii_decimal_slice(text, 0, 4),
      ascii_decimal_slice(text, 5, 7),
      ascii_decimal_slice(text, 8, 10),
    ) {
    (Some(year), Some(month), Some(day)) => UtcDate::new(year, month, day)
    _ => Err(InvalidDate(text))
  }
}

///|
/// Parse `YYYY-MM-DDTHH:MMZ`.
pub fn parse_utc_datetime(text : String) -> Result[UtcDateTime, CronError] {
  if text.length() != 17 ||
    text[4] != '-' ||
    text[7] != '-' ||
    text[10] != 'T' ||
    text[13] != ':' ||
    text[16] != 'Z' {
    return Err(InvalidDate(text))
  }
  match
    (
      ascii_decimal_slice(text, 0, 4),
      ascii_decimal_slice(text, 5, 7),
      ascii_decimal_slice(text, 8, 10),
      ascii_decimal_slice(text, 11, 13),
      ascii_decimal_slice(text, 14, 16),
    ) {
    (Some(year), Some(month), Some(day), Some(hour), Some(minute)) =>
      UtcDateTime::new(year, month, day, hour, minute)
    _ => Err(InvalidDate(text))
  }
}

///|
/// A closed minute interval.
pub struct DateTimeRange {
  start : UtcDateTime
  end : UtcDateTime
} derive(Eq, Debug)

///|
/// Construct a non-empty closed interval.
pub fn DateTimeRange::new(
  start : UtcDateTime,
  end : UtcDateTime,
) -> Result[DateTimeRange, CronError] {
  if start <= end {
    Ok({ start, end })
  } else {
    Err(InvalidDate("range starts after it ends"))
  }
}

///|
pub fn DateTimeRange::contains(
  self : DateTimeRange,
  value : UtcDateTime,
) -> Bool {
  value >= self.start && value <= self.end
}

///|
pub fn DateTimeRange::overlaps(
  self : DateTimeRange,
  other : DateTimeRange,
) -> Bool {
  self.start <= other.end && other.start <= self.end
}

///|
pub fn DateTimeRange::intersection(
  self : DateTimeRange,
  other : DateTimeRange,
) -> DateTimeRange? {
  if !self.overlaps(other) {
    return None
  }
  let start = if self.start >= other.start { self.start } else { other.start }
  let end = if self.end <= other.end { self.end } else { other.end }
  Some({ start, end })
}

///|
/// Inclusive minute count in the range.
pub fn DateTimeRange::minute_count(self : DateTimeRange) -> Int {
  self.start.minutes_until(self.end) + 1
}

///|
/// Split the range into closed chunks containing at most `minutes` minutes.
pub fn DateTimeRange::chunks(
  self : DateTimeRange,
  minutes : Int,
) -> Array[DateTimeRange] {
  let result : Array[DateTimeRange] = []
  if minutes <= 0 {
    return result
  }
  let mut cursor = self.start
  for ;; {
    if cursor > self.end {
      break
    }
    let candidate = cursor.add_minutes(minutes - 1).unwrap_or(self.end)
    let chunk_end = if candidate <= self.end { candidate } else { self.end }
    result.push({ start: cursor, end: chunk_end })
    match chunk_end.add_minutes(1) {
      Some(next) => cursor = next
      None => break
    }
  }
  result
}