///|
/// The position of a field inside a five-field cron expression. Each
/// position carries its own numeric bounds and, for month and weekday,
/// a table of accepted names.
priv enum FieldKind {
  MinuteField
  HourField
  DayOfMonthField
  MonthField
  WeekdayField
}

///|
/// Inclusive bounds of the values a field position accepts. The weekday
/// upper bound is 7 because standard cron treats both 0 and 7 as Sunday.
fn FieldKind::bounds(self : FieldKind) -> (Int, Int) {
  match self {
    MinuteField => (0, 59)
    HourField => (0, 23)
    DayOfMonthField => (1, 31)
    MonthField => (1, 12)
    WeekdayField => (0, 7)
  }
}

///|
let month_names : Array[(String, Int)] = [
  ("JAN", 1),
  ("FEB", 2),
  ("MAR", 3),
  ("APR", 4),
  ("MAY", 5),
  ("JUN", 6),
  ("JUL", 7),
  ("AUG", 8),
  ("SEP", 9),
  ("OCT", 10),
  ("NOV", 11),
  ("DEC", 12),
  ("JANUARY", 1),
  ("FEBRUARY", 2),
  ("MARCH", 3),
  ("APRIL", 4),
  ("JUNE", 6),
  ("JULY", 7),
  ("AUGUST", 8),
  ("SEPTEMBER", 9),
  ("OCTOBER", 10),
  ("NOVEMBER", 11),
  ("DECEMBER", 12),
]

///|
let weekday_names : Array[(String, Int)] = [
  ("SUN", 0),
  ("MON", 1),
  ("TUE", 2),
  ("WED", 3),
  ("THU", 4),
  ("FRI", 5),
  ("SAT", 6),
  ("SUNDAY", 0),
  ("MONDAY", 1),
  ("TUESDAY", 2),
  ("WEDNESDAY", 3),
  ("THURSDAY", 4),
  ("FRIDAY", 5),
  ("SATURDAY", 6),
]

///|
/// UTF-16 code units of the text with ASCII lowercase folded to uppercase.
fn ascii_upper_codes(text : String) -> Array[Int] {
  let codes : Array[Int] = []
  for char in text {
    let code = char.to_int()
    if code >= 97 && code <= 122 {
      codes.push(code - 32)
    } else {
      codes.push(code)
    }
  }
  codes
}

///|
fn equals_ignore_case(text : String, name : String) -> Bool {
  ascii_upper_codes(text) == ascii_upper_codes(name)
}

///|
fn lookup_name(text : String, table : Array[(String, Int)]) -> Int? {
  for entry in table {
    if equals_ignore_case(text, entry.0) {
      return Some(entry.1)
    }
  }
  None
}

///|
fn FieldKind::resolve_name(self : FieldKind, text : String) -> Int? {
  match self {
    MonthField => lookup_name(text, month_names)
    WeekdayField => lookup_name(text, weekday_names)
    _ => None
  }
}

///|
fn is_alphabetic(text : String) -> Bool {
  if text.length() == 0 {
    return false
  }
  for char in text {
    let code = char.to_int()
    let is_upper = code >= 65 && code <= 90
    let is_lower = code >= 97 && code <= 122
    if !is_upper && !is_lower {
      return false
    }
  }
  true
}

///|
/// Parse a decimal number and check it against the inclusive bounds.
/// Accumulation bails out as soon as the value exceeds the upper bound, so
/// oversized inputs cannot wrap around the integer range.
fn bounded_number(
  text : String,
  lower : Int,
  upper : Int,
) -> Result[Int, CronError] {
  if text.length() == 0 {
    return Err(InvalidNumber(text))
  }
  let mut value = 0
  for char in text {
    let code = char.to_int()
    if code < 48 || code > 57 {
      return Err(InvalidNumber(text))
    }
    value = value * 10 + code - 48
    if value > upper {
      return Err(ValueOutOfRange(text, lower, upper))
    }
  }
  if value < lower {
    return Err(ValueOutOfRange(text, lower, upper))
  }
  Ok(value)
}

///|
/// Resolve a single value: a month or weekday name where the field allows
/// one, otherwise a bounds-checked number.
fn bounded_value(text : String, kind : FieldKind) -> Result[Int, CronError] {
  let (lower, upper) = kind.bounds()
  match kind.resolve_name(text) {
    Some(value) => Ok(value)
    None =>
      if is_alphabetic(text) {
        Err(UnknownName(text))
      } else {
        bounded_number(text, lower, upper)
      }
  }
}

///|
fn parse_range(
  text : String,
  kind : FieldKind,
) -> Result[(Int, Int), CronError] {
  match text.split_once("-") {
    Some((start_view, end_view)) => {
      let start_text = start_view.to_owned()
      let end_text = end_view.to_owned()
      match (bounded_value(start_text, kind), bounded_value(end_text, kind)) {
        (Ok(start), Ok(end)) if start <= end => Ok((start, end))
        (Ok(_), Ok(_)) => Err(InvalidRange(text))
        (Err(error), _) => Err(error)
        (_, Err(error)) => Err(error)
      }
    }
    None => Err(UnsupportedSyntax(text))
  }
}

///|
/// Parse one list element: `*`, a value or name, `a-b`, `*/n`, `a-b/n` or
/// the Vixie-style `a/n` which runs from `a` to the top of the field.
fn parse_element(text : String, kind : FieldKind) -> Result[Field, CronError] {
  let (lower, upper) = kind.bounds()
  if text == "*" {
    return Ok(Any)
  }
  match text.split_once("/") {
    Some((base_view, step_view)) => {
      let base = base_view.to_owned()
      let step_text = step_view.to_owned()
      match bounded_number(step_text, 1, upper - lower + 1) {
        Err(error) => Err(error)
        Ok(step) =>
          if base == "*" {
            Ok(Every(step))
          } else if base.split_once("-") is Some(_) {
            parse_range(base, kind).map(bounds => {
              RangeEvery(bounds.0, bounds.1, step)
            })
          } else {
            bounded_value(base, kind).map(start => {
              RangeEvery(start, upper, step)
            })
          }
      }
    }
    None =>
      if text.split_once("-") is Some(_) {
        parse_range(text, kind).map(bounds => Range(bounds.0, bounds.1))
      } else {
        bounded_value(text, kind).map(value => Exact(value))
      }
  }
}

///|
/// Parse a whole field, which may be a comma-separated list of elements.
fn parse_field(text : String, kind : FieldKind) -> Result[Field, CronError] {
  let parts : Array[String] = []
  for part in text.split(",") {
    parts.push(part.to_owned())
  }
  if parts.length() == 1 {
    return parse_element(parts[0], kind)
  }
  let items : Array[Field] = []
  for part in parts {
    match parse_element(part, kind) {
      Ok(field) => items.push(field)
      Err(error) => return Err(error)
    }
  }
  Ok(List(items))
}

///|
/// Split an expression into whitespace-separated fields. Spaces and tabs
/// separate fields as in crontab files; stray newlines are tolerated.
fn tokenize(expression : String) -> Array[String] {
  let fields : Array[String] = []
  let mut current = StringBuilder::new()
  let mut pending = false
  for char in expression {
    if char == ' ' || char == '\t' || char == '\n' || char == '\r' {
      if pending {
        fields.push(current.to_string())
        current = StringBuilder::new()
        pending = false
      }
    } else {
      current.write_char(char)
      pending = true
    }
  }
  if pending {
    fields.push(current.to_string())
  }
  fields
}

///|
fn starts_with_at(text : String) -> Bool {
  for char in text {
    return char == '@'
  }
  false
}

///|
/// Expand a `@`-prefixed scheduling macro into its five-field schedule.
/// `@reboot` has no wall-clock meaning and is reported as unsupported.
fn parse_macro(text : String) -> Result[Cron, CronError] {
  if equals_ignore_case(text, "@HOURLY") {
    Ok({
      minute: Exact(0),
      hour: Any,
      day_of_month: Any,
      month: Any,
      weekday: Any,
    })
  } else if equals_ignore_case(text, "@DAILY") ||
    equals_ignore_case(text, "@MIDNIGHT") {
    Ok({
      minute: Exact(0),
      hour: Exact(0),
      day_of_month: Any,
      month: Any,
      weekday: Any,
    })
  } else if equals_ignore_case(text, "@WEEKLY") {
    Ok({
      minute: Exact(0),
      hour: Exact(0),
      day_of_month: Any,
      month: Any,
      weekday: Exact(0),
    })
  } else if equals_ignore_case(text, "@MONTHLY") {
    Ok({
      minute: Exact(0),
      hour: Exact(0),
      day_of_month: Exact(1),
      month: Any,
      weekday: Any,
    })
  } else if equals_ignore_case(text, "@YEARLY") ||
    equals_ignore_case(text, "@ANNUALLY") {
    Ok({
      minute: Exact(0),
      hour: Exact(0),
      day_of_month: Exact(1),
      month: Exact(1),
      weekday: Any,
    })
  } else {
    Err(UnsupportedSyntax(text))
  }
}

///|
/// Parse the portable cron subset: five whitespace-separated fields built
/// from `*`, numbers, month and weekday names, ranges (`a-b`), steps
/// (`*/n`, `a-b/n`, `a/n`) and comma lists, plus `@hourly`-style macros.
pub fn parse(expression : String) -> Result[Cron, CronError] {
  let fields = tokenize(expression)
  if fields.length() == 1 && starts_with_at(fields[0]) {
    return parse_macro(fields[0])
  }
  if fields.length() != 5 {
    return Err(WrongFieldCount(fields.length()))
  }
  match
    (
      parse_field(fields[0], MinuteField),
      parse_field(fields[1], HourField),
      parse_field(fields[2], DayOfMonthField),
      parse_field(fields[3], MonthField),
      parse_field(fields[4], WeekdayField),
    ) {
    (Ok(minute), Ok(hour), Ok(day_of_month), Ok(month), Ok(weekday)) =>
      Ok({ minute, hour, day_of_month, month, weekday })
    (Err(error), _, _, _, _) => Err(error)
    (_, Err(error), _, _, _) => Err(error)
    (_, _, Err(error), _, _) => Err(error)
    (_, _, _, Err(error), _) => Err(error)
    (_, _, _, _, Err(error)) => Err(error)
  }
}