///|
/// An environment assignment declared in a crontab document.
pub struct CrontabVariable {
  name : String
  value : String
  line_number : Int
} derive(Eq, Debug)

///|
/// Construct a validated crontab environment assignment.
pub fn CrontabVariable::new(
  name : String,
  value : String,
  line_number? : Int = 0,
) -> Result[CrontabVariable, CronError] {
  if !valid_variable_name(name) {
    Err(InvalidCrontabLine(line_number, "invalid environment variable name"))
  } else {
    Ok({ name, value, line_number })
  }
}

///|
/// A schedulable command from a crontab document.
pub struct CrontabEntry {
  schedule_text : String
  cron : Cron
  command : String
  line_number : Int
} derive(Eq, Debug)

///|
pub fn CrontabEntry::new(
  schedule_text : String,
  command : String,
  line_number? : Int = 0,
) -> Result[CrontabEntry, CronError] {
  if command.trim().length() == 0 {
    return Err(InvalidCrontabLine(line_number, "command must not be empty"))
  }
  match parse(schedule_text) {
    Ok(cron) => Ok({ schedule_text, cron, command, line_number })
    Err(_) => Err(InvalidCrontabLine(line_number, "invalid cron expression"))
  }
}

///|
/// Replace a command while preserving its schedule and source line.
pub fn CrontabEntry::with_command(
  self : CrontabEntry,
  command : String,
) -> Result[CrontabEntry, CronError] {
  if command.trim().length() == 0 {
    Err(InvalidCrontabLine(self.line_number, "command must not be empty"))
  } else {
    Ok({ ..self, command, })
  }
}

///|
/// Replace a schedule while preserving the command and source line.
pub fn CrontabEntry::with_schedule(
  self : CrontabEntry,
  schedule_text : String,
) -> Result[CrontabEntry, CronError] {
  match parse(schedule_text) {
    Ok(cron) => Ok({ ..self, schedule_text, cron })
    Err(_) =>
      Err(InvalidCrontabLine(self.line_number, "invalid cron expression"))
  }
}

///|
/// True when this command is due at the supplied UTC minute.
pub fn CrontabEntry::matches_at(self : CrontabEntry, at : UtcDateTime) -> Bool {
  self.cron.matches_at(at)
}

///|
/// A source-preserving crontab line.
pub(all) enum CrontabLine {
  Blank
  Comment(String)
  Variable(CrontabVariable)
  Entry(CrontabEntry)
} derive(Eq, Debug)

///|
/// A parsed crontab document. Blank lines and comments are retained so tools
/// can inspect and render documents without throwing away useful context.
pub struct CrontabDocument {
  lines : Array[CrontabLine]
} derive(Eq, Debug)

///|
pub fn CrontabDocument::new() -> CrontabDocument {
  { lines: [] }
}

///|
pub fn CrontabDocument::from_lines(
  lines : Array[CrontabLine],
) -> CrontabDocument {
  { lines: lines.copy() }
}

///|
pub fn CrontabDocument::line_count(self : CrontabDocument) -> Int {
  self.lines.length()
}

///|
pub fn CrontabDocument::entry_count(self : CrontabDocument) -> Int {
  let mut count = 0
  for line in self.lines {
    if line is Entry(_) {
      count += 1
    }
  }
  count
}

///|
pub fn CrontabDocument::variable_count(self : CrontabDocument) -> Int {
  let mut count = 0
  for line in self.lines {
    if line is Variable(_) {
      count += 1
    }
  }
  count
}

///|
pub fn CrontabDocument::comment_count(self : CrontabDocument) -> Int {
  let mut count = 0
  for line in self.lines {
    if line is Comment(_) {
      count += 1
    }
  }
  count
}

///|
pub fn CrontabDocument::all_lines(self : CrontabDocument) -> Array[CrontabLine] {
  self.lines.copy()
}

///|
pub fn CrontabDocument::entries(self : CrontabDocument) -> Array[CrontabEntry] {
  let result : Array[CrontabEntry] = []
  for line in self.lines {
    match line {
      Entry(entry) => result.push(entry)
      _ => ()
    }
  }
  result
}

///|
pub fn CrontabDocument::variables(
  self : CrontabDocument,
) -> Array[CrontabVariable] {
  let result : Array[CrontabVariable] = []
  for line in self.lines {
    match line {
      Variable(variable) => result.push(variable)
      _ => ()
    }
  }
  result
}

///|
pub fn CrontabDocument::comments(self : CrontabDocument) -> Array[String] {
  let result : Array[String] = []
  for line in self.lines {
    match line {
      Comment(comment) => result.push(comment)
      _ => ()
    }
  }
  result
}

///|
pub fn CrontabDocument::append_entry(
  self : CrontabDocument,
  entry : CrontabEntry,
) -> Unit {
  self.lines.push(Entry(entry))
}

///|
pub fn CrontabDocument::append_variable(
  self : CrontabDocument,
  variable : CrontabVariable,
) -> Unit {
  self.lines.push(Variable(variable))
}

///|
pub fn CrontabDocument::append_comment(
  self : CrontabDocument,
  comment : String,
) -> Unit {
  self.lines.push(Comment(comment))
}

///|
pub fn CrontabDocument::append_blank(self : CrontabDocument) -> Unit {
  self.lines.push(Blank)
}

///|
/// Return the last value assigned to `name`, matching crontab override rules.
pub fn CrontabDocument::variable(
  self : CrontabDocument,
  name : String,
) -> String? {
  let mut result : String? = None
  for line in self.lines {
    match line {
      Variable(variable) if variable.name == name =>
        result = Some(variable.value)
      _ => ()
    }
  }
  result
}

///|
/// Entries whose command contains a literal text fragment.
pub fn CrontabDocument::find_commands(
  self : CrontabDocument,
  fragment : String,
) -> Array[CrontabEntry] {
  let result : Array[CrontabEntry] = []
  for entry in self.entries() {
    if entry.command.contains(fragment) {
      result.push(entry)
    }
  }
  result
}

///|
/// Commands due at an exact UTC minute, in document order.
pub fn CrontabDocument::due_at(
  self : CrontabDocument,
  at : UtcDateTime,
) -> Array[CrontabEntry] {
  let result : Array[CrontabEntry] = []
  for entry in self.entries() {
    if entry.matches_at(at) {
      result.push(entry)
    }
  }
  result
}

///|
/// Convert document entries to a named schedule registry. Line numbers make
/// otherwise duplicate commands independently addressable.
pub fn CrontabDocument::to_schedule_book(
  self : CrontabDocument,
) -> ScheduleBook {
  let book = ScheduleBook::new()
  for entry in self.entries() {
    let name = "line-" + entry.line_number.to_string()
    book.add(NamedSchedule::new(name, entry.cron).unwrap()).unwrap()
  }
  book
}

///|
/// Remove all comments and blank lines while preserving executable semantics.
pub fn CrontabDocument::compact(self : CrontabDocument) -> CrontabDocument {
  let result : Array[CrontabLine] = []
  for line in self.lines {
    match line {
      Variable(_) | Entry(_) => result.push(line)
      _ => ()
    }
  }
  { lines: result }
}

///|
/// Render a normalized document. Cron macros are retained as entered, while
/// whitespace between schedule fields and commands is canonicalized.
pub fn CrontabDocument::to_text(self : CrontabDocument) -> String {
  let writer = StringBuilder::new()
  for index, line in self.lines {
    if index > 0 {
      writer.write_char('\n')
    }
    match line {
      Blank => ()
      Comment(comment) => {
        writer.write_char('#')
        if comment.length() > 0 {
          writer.write_char(' ')
          writer.write_string(comment)
        }
      }
      Variable(variable) => {
        writer.write_string(variable.name)
        writer.write_char('=')
        writer.write_string(variable.value)
      }
      Entry(entry) => {
        writer.write_string(entry.schedule_text)
        writer.write_char(' ')
        writer.write_string(entry.command)
      }
    }
  }
  writer.to_string()
}

///|
fn valid_variable_name(name : String) -> Bool {
  if name.length() == 0 {
    return false
  }
  for index, char in name {
    let code = char.to_int()
    let alphabetic = (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
    let numeric = code >= 48 && code <= 57
    if index == 0 {
      if !alphabetic && code != 95 {
        return false
      }
    } else if !alphabetic && !numeric && code != 95 {
      return false
    }
  }
  true
}

///|
fn split_assignment(line : String) -> (String, String)? {
  let name = StringBuilder::new()
  let value = StringBuilder::new()
  let mut found = false
  for char in line {
    if !found && char == '=' {
      found = true
    } else if found {
      value.write_char(char)
    } else {
      name.write_char(char)
    }
  }
  if found {
    Some(
      (name.to_string().trim().to_owned(), value.to_string().trim().to_owned()),
    )
  } else {
    None
  }
}

///|
fn join_tokens(tokens : Array[String], start : Int, end : Int) -> String {
  let writer = StringBuilder::new()
  for index in start.. start {
      writer.write_char(' ')
    }
    writer.write_string(tokens[index])
  }
  writer.to_string()
}

///|
fn parse_crontab_entry(
  trimmed : String,
  line_number : Int,
) -> Result[CrontabEntry, CronError] {
  let tokens = tokenize(trimmed)
  if tokens.length() == 0 {
    return Err(InvalidCrontabLine(line_number, "empty entry"))
  }
  let macro_entry = starts_with_at(tokens[0])
  let schedule_fields = if macro_entry { 1 } else { 5 }
  if tokens.length() <= schedule_fields {
    return Err(InvalidCrontabLine(line_number, "entry has no command"))
  }
  let schedule_text = join_tokens(tokens, 0, schedule_fields)
  let command = join_tokens(tokens, schedule_fields, tokens.length())
  CrontabEntry::new(schedule_text, command, line_number~)
}

///|
fn parse_crontab_line(
  source : String,
  line_number : Int,
) -> Result[CrontabLine, CronError] {
  let trimmed = source.trim().to_owned()
  if trimmed.length() == 0 {
    return Ok(Blank)
  }
  if trimmed[0] == '#' {
    let comment = StringBuilder::new()
    let mut first = true
    for char in trimmed {
      if first {
        first = false
      } else {
        comment.write_char(char)
      }
    }
    return Ok(Comment(comment.to_string().trim().to_owned()))
  }
  match split_assignment(trimmed) {
    Some((name, value)) if valid_variable_name(name) =>
      Ok(Variable(CrontabVariable::new(name, value, line_number~).unwrap()))
    _ =>
      match parse_crontab_entry(trimmed, line_number) {
        Ok(entry) => Ok(Entry(entry))
        Err(error) => Err(error)
      }
  }
}

///|
/// Parse a portable crontab document. Each error carries the one-based source
/// line number needed by editors and CI diagnostics.
pub fn parse_crontab(text : String) -> Result[CrontabDocument, CronError] {
  let lines : Array[CrontabLine] = []
  let mut line_number = 0
  for view in text.split("\n") {
    line_number += 1
    let source = view.to_owned().replace(old="\r", new="")
    match parse_crontab_line(source, line_number) {
      Ok(line) => lines.push(line)
      Err(error) => return Err(error)
    }
  }
  Ok({ lines, })
}