///|
pub struct Cron {
  minutes : Array[Bool]
  hours : Array[Bool]
  days : Array[Bool]
  months : Array[Bool]
  weekdays : Array[Bool]
  days_wildcard : Bool
  weekdays_wildcard : Bool
} derive(Debug)

///|
/// Parse a five-field UTC cron expression: minute hour day-of-month month day-of-week.
pub fn Cron::parse(input : String) -> Cron raise ScheduleError {
  let fields = input
    .trim()
    .split(" ")
    .filter(field => field.length() > 0)
    .to_array()
  if fields.length() != 5 {
    raise InvalidRule("cron needs exactly five fields")
  }
  let minutes = parse_field(fields[0], 0, 59, "minute", false)
  let hours = parse_field(fields[1], 0, 23, "hour", false)
  let days = parse_field(fields[2], 1, 31, "day-of-month", false)
  let months = parse_field(fields[3], 1, 12, "month", true)
  let weekdays = parse_field(fields[4], 0, 7, "day-of-week", true)
  {
    minutes,
    hours,
    days,
    months,
    weekdays,
    days_wildcard: fields[2] == "*",
    weekdays_wildcard: fields[4] == "*",
  }
}

///|
pub fn Cron::matches(self : Cron, at : DateTime) -> Bool {
  let weekday = at.weekday()
  let day_matches = self.days[at.day]
  let weekday_matches = self.weekdays[weekday] ||
    (weekday == 0 && self.weekdays[7])
  let calendar_matches = if self.days_wildcard && self.weekdays_wildcard {
    true
  } else if self.days_wildcard {
    weekday_matches
  } else if self.weekdays_wildcard {
    day_matches
  } else {
    day_matches || weekday_matches
  }
  self.minutes[at.minute] &&
  self.hours[at.hour] &&
  self.months[at.month] &&
  calendar_matches
}

///|
/// Return the first matching minute strictly after `after`, searching at most two years.
pub fn Cron::next_after(
  self : Cron,
  after : DateTime,
) -> DateTime raise ScheduleError {
  let mut candidate = after.add_minutes(1)
  for _ in 0..<1_100_000 {
    if self.matches(candidate) {
      return candidate
    }
    candidate = candidate.add_minutes(1)
  }
  raise InvalidRule("cron has no occurrence within two years")
}

///|
fn parse_field(
  input : StringView,
  minimum : Int,
  maximum : Int,
  name : String,
  allow_names : Bool,
) -> Array[Bool] raise ScheduleError {
  let output = Array::make(maximum + 1, false)
  for part in input.split(",") {
    parse_part(part.to_owned(), minimum, maximum, name, allow_names, output)
  }
  output
}

///|
fn parse_part(
  input : String,
  minimum : Int,
  maximum : Int,
  name : String,
  allow_names : Bool,
  output : Array[Bool],
) -> Unit raise ScheduleError {
  let pieces = input.split("/").to_array()
  if pieces.length() > 2 || pieces[0] == "" {
    raise InvalidRule("invalid \{name} field: \{input}")
  }
  let step = if pieces.length() == 2 {
    let value = cron_value(pieces[1], minimum, maximum, name, allow_names)
    if value < 1 {
      raise InvalidRule("step must be positive")
    }
    value
  } else {
    1
  }
  let range = pieces[0]
  if range == "*" {
    mark_range(output, minimum, maximum, step)
  } else {
    let ends = range.split("-").to_array()
    if ends.length() == 1 {
      let value = cron_value(ends[0], minimum, maximum, name, allow_names)
      mark_range(output, value, value, step)
    } else if ends.length() == 2 {
      let start = cron_value(ends[0], minimum, maximum, name, allow_names)
      let end = cron_value(ends[1], minimum, maximum, name, allow_names)
      if start > end {
        raise InvalidRule("range start exceeds end in \{name}")
      }
      mark_range(output, start, end, step)
    } else {
      raise InvalidRule("invalid range in \{name}")
    }
  }
}

///|
fn mark_range(output : Array[Bool], start : Int, end : Int, step : Int) -> Unit {
  for value = start; value <= end; value = value + step {
    output[value] = true
  }
}

///|
fn cron_value(
  raw : StringView,
  minimum : Int,
  maximum : Int,
  name : String,
  allow_names : Bool,
) -> Int raise ScheduleError {
  let upper = raw.to_owned().to_upper()
  let named = if allow_names { cron_name(upper) } else { None }
  let value = match named {
    Some(value) => value
    None =>
      @string.parse_int(upper) catch {
        _ => raise InvalidRule("invalid \{name} value: \{raw}")
      }
  }
  if value < minimum || value > maximum {
    raise InvalidRule("\{name} must be \{minimum}..\{maximum}")
  }
  value
}

///|
fn cron_name(raw : String) -> Int? {
  match raw {
    "JAN" => Some(1)
    "FEB" => Some(2)
    "MAR" => Some(3)
    "APR" => Some(4)
    "MAY" => Some(5)
    "JUN" => Some(6)
    "JUL" => Some(7)
    "AUG" => Some(8)
    "SEP" => Some(9)
    "OCT" => Some(10)
    "NOV" => Some(11)
    "DEC" => Some(12)
    "SUN" => Some(0)
    "MON" => Some(1)
    "TUE" => Some(2)
    "WED" => Some(3)
    "THU" => Some(4)
    "FRI" => Some(5)
    "SAT" => Some(6)
    _ => None
  }
}