///|
pub(all) enum Status {
  Todo(priority~ : String?)
  Done(completed_date~ : Date)
} derive(Eq, Debug)

///|
pub(all) struct Task {
  status : Status
  prepended_date : Date?
  description : String
  contexts : Array[String]
  projects : Array[String]
  metadata : Map[String, String]
} derive(Eq, Debug)

///|
pub(all) suberror ParseError {
  EmptyLine
  MultipleLines
} derive(Eq, Debug)

///|
pub fn parse_task(line : String) -> Task raise ParseError {
  let input = line.trim(chars=" ").to_owned()
  guard !input.is_empty() else { raise EmptyLine }
  guard !contains_line_break(input) else { raise MultipleLines }
  let words = split_words(input)
  let (status, index_after_status) = parse_status(words)
  let (prepended_date, index_after_date) = parse_prepended_date(
    words, index_after_status,
  )
  let description_words = words[index_after_date:]
  let (contexts, projects, metadata) = collect_annotations(description_words)
  {
    status,
    prepended_date,
    description: description_words.join(" "),
    contexts,
    projects,
    metadata,
  }
}

///|
fn parse_date(text : String) -> Date? {
  guard text =~ (
    re"^" +
    (re"[[:digit:]]{4}" as year_text) +
    re"-" +
    (re"[[:digit:]]{2}" as month_text) +
    re"-" +
    (re"[[:digit:]]{2}" as day_text) +
    re"$"
  ) else {
    None
  }
  try {
    let year : Int = @string.from_str(year_text)
    let month : Int = @string.from_str(month_text)
    let day : Int = @string.from_str(day_text)
    Date(year~, month~, day~) |> Some
  } catch {
    _ => None
  }
}

///|
fn parse_status(words : Array[String]) -> (Status, Int) {
  if words.length() >= 2 &&
    words[0] == "x" &&
    parse_date(words[1]) is Some(date) {
    (Done(completed_date=date), 2)
  } else {
    match parse_priority(words[0]) {
      Some(priority) => (Todo(priority=Some(priority)), 1)
      None => (Todo(priority=None), 0)
    }
  }
}

///|
fn parse_prepended_date(words : Array[String], index : Int) -> (Date?, Int) {
  if words.get(index) is Some(word) && parse_date(word) is Some(date) {
    (Some(date), index + 1)
  } else {
    (None, index)
  }
}

///|
fn collect_annotations(
  words : ArrayView[String],
) -> (Array[String], Array[String], Map[String, String]) {
  let contexts : Array[String] = []
  let projects : Array[String] = []
  let metadata : Map[String, String] = {}
  for word in words {
    if parse_context(word) is Some(context) {
      contexts.push(context)
    }
    if parse_project(word) is Some(project) {
      projects.push(project)
    }
    if parse_metadata(word) is Some((key, value)) {
      metadata[key] = value
    }
  }
  (contexts, projects, metadata)
}

///|
fn split_words(input : String) -> Array[String] {
  let words : Array[String] = []
  for word in input.split(" ") {
    if !word.is_empty() {
      words.push(word.to_owned())
    }
  }
  words
}

///|
fn parse_priority(word : String) -> String? {
  guard word =~ (re"^" + re"[(]" + (re"[A-Z]" as priority) + re"[)]" + re"$") else {
    None
  }
  Some(priority.to_string())
}

///|
fn parse_context(word : String) -> String? {
  guard word =~ (re"^" + re"@" + (re"[^[:space:]]+" as context) + re"$") else {
    None
  }
  Some(context.to_owned())
}

///|
fn parse_project(word : String) -> String? {
  guard word =~ (re"^" + re"[+]" + (re"[^[:space:]]+" as project) + re"$") else {
    None
  }
  Some(project.to_owned())
}

///|
fn parse_metadata(word : String) -> (String, String)? {
  guard word =~ (
    re"^" +
    (re"[^[:space:]:]+" as key) +
    re":" +
    (re"[^[:space:]:]+" as value) +
    re"$"
  ) else {
    None
  }
  Some((key.to_owned(), value.to_owned()))
}

///|
fn contains_line_break(text : String) -> Bool {
  text.contains_char('\r') || text.contains_char('\n')
}