///|
/// A clock value expressed in UTC. `weekday` uses cron's conventional values:
/// Sunday is 0 and Saturday is 6.
pub struct UtcTime {
  minute : Int
  hour : Int
  day_of_month : Int
  month : Int
  weekday : Int
} derive(Eq, Debug)

///|
pub fn UtcTime::new(
  minute : Int,
  hour : Int,
  day_of_month : Int,
  month : Int,
  weekday : Int,
) -> UtcTime {
  { minute, hour, day_of_month, month, weekday }
}

///|
/// One field of a cron schedule. `Every` counts from the lowest value the
/// field allows, so `*/2` in the day-of-month field means days 1, 3, 5 and
/// so on, matching standard cron behaviour.
pub enum Field {
  Any
  Exact(Int)
  Every(Int)
  Range(Int, Int)
  RangeEvery(Int, Int, Int)
  List(Array[Field])
} derive(Eq, Debug)

///|
pub enum CronError {
  WrongFieldCount(Int)
  InvalidNumber(String)
  UnknownName(String)
  ValueOutOfRange(String, Int, Int)
  InvalidRange(String)
  UnsupportedSyntax(String)
  InvalidDate(String)
  InvalidField(String)
  InvalidWindow(String)
  DuplicateSchedule(String)
  MissingSchedule(String)
  InvalidCrontabLine(Int, String)
} derive(Eq, Debug)

///|
/// A five-field cron schedule: minute, hour, day-of-month, month and weekday.
pub struct Cron {
  minute : Field
  hour : Field
  day_of_month : Field
  month : Field
  weekday : Field
} derive(Eq, Debug)

///|
/// Check whether a value satisfies this field. `lower` is the smallest value
/// the field position allows; `Every` steps count from it, so pass `lower=1`
/// for day-of-month and month fields.
pub fn Field::matches(self : Field, value : Int, lower? : Int = 0) -> Bool {
  match self {
    Any => true
    Exact(expected) => value == expected
    Every(step) => value >= lower && (value - lower) % step == 0
    Range(start, end) => value >= start && value <= end
    RangeEvery(start, end, step) =>
      value >= start && value <= end && (value - start) % step == 0
    List(items) => {
      for item in items {
        if item.matches(value, lower~) {
          return true
        }
      }
      false
    }
  }
}

///|
/// Render a field in the portable expression subset accepted by `parse`.
pub fn Field::to_expression(self : Field) -> String {
  match self {
    Any => "*"
    Exact(value) => value.to_string()
    Every(step) => "*/" + step.to_string()
    Range(start, end) => start.to_string() + "-" + end.to_string()
    RangeEvery(start, end, step) =>
      start.to_string() + "-" + end.to_string() + "/" + step.to_string()
    List(items) => {
      let writer = StringBuilder::new()
      let mut first = true
      for item in items {
        if !first {
          writer.write_char(',')
        }
        writer.write_string(item.to_expression())
        first = false
      }
      writer.to_string()
    }
  }
}

///|
/// True when this schedule's weekday field accepts the given weekday.
/// Sunday may be written as either 0 or 7, so both spellings are honoured.
fn Cron::weekday_field_matches(self : Cron, weekday : Int) -> Bool {
  self.weekday.matches(weekday) ||
  (weekday == 0 && self.weekday.matches(7)) ||
  (weekday == 7 && self.weekday.matches(0))
}

///|
/// Apply cron's calendar rule: when both day-of-month and weekday are
/// restricted, a date matches if either field matches (the OR rule).
fn Cron::calendar_matches(
  self : Cron,
  day_of_month : Int,
  weekday : Int,
) -> Bool {
  let day_matches = self.day_of_month.matches(day_of_month, lower=1)
  let weekday_matches = self.weekday_field_matches(weekday)
  match (self.day_of_month, self.weekday) {
    (Any, Any) => true
    (Any, _) => weekday_matches
    (_, Any) => day_matches
    _ => day_matches || weekday_matches
  }
}

///|
/// Check whether a UTC wall-clock minute satisfies this schedule. When both
/// day-of-month and weekday are restricted, standard cron's OR rule is used.
pub fn Cron::matches(self : Cron, time : UtcTime) -> Bool {
  self.minute.matches(time.minute) &&
  self.hour.matches(time.hour) &&
  self.month.matches(time.month, lower=1) &&
  self.calendar_matches(time.day_of_month, time.weekday)
}

///|
pub fn Cron::to_expression(self : Cron) -> String {
  let writer = StringBuilder::new()
  writer.write_string(self.minute.to_expression())
  writer.write_char(' ')
  writer.write_string(self.hour.to_expression())
  writer.write_char(' ')
  writer.write_string(self.day_of_month.to_expression())
  writer.write_char(' ')
  writer.write_string(self.month.to_expression())
  writer.write_char(' ')
  writer.write_string(self.weekday.to_expression())
  writer.to_string()
}

///|
/// A schedule that fires at minute zero of every hour.
pub fn hourly() -> Cron {
  { minute: Exact(0), hour: Any, day_of_month: Any, month: Any, weekday: Any }
}

///|
/// A schedule that fires at minute zero on every weekday hour.
pub fn weekday_hourly() -> Cron {
  {
    minute: Exact(0),
    hour: Any,
    day_of_month: Any,
    month: Any,
    weekday: Range(1, 5),
  }
}