///|
pub(all) enum Status {
  Todo
  InProgress
  Done
} derive(Eq, Debug, ToJson, FromJson)

///|
pub fn Status::from_storage(value : String) -> Status raise {
  match value {
    "todo" | "Todo" => Todo
    "in-progress" | "in_progress" | "InProgress" => InProgress
    "done" | "Done" => Done
    other => fail("unknown status: \{other}")
  }
}

///|
pub fn Status::to_storage(self : Status) -> String {
  match self {
    Todo => "todo"
    InProgress => "in-progress"
    Done => "done"
  }
}

///|
pub fn Status::is_open(self : Status) -> Bool {
  self != Done
}

///|
fn Status::rank_for_next(self : Status) -> Int {
  match self {
    InProgress => 0
    Todo => 1
    Done => 2
  }
}

///|
pub(all) enum EdgeKind {
  Contains
  DependsOn
  RelatesTo
} derive(Eq, Debug, ToJson, FromJson)

///|
pub fn EdgeKind::from_storage(value : String) -> EdgeKind raise {
  match value {
    "contains" | "Contains" => Contains
    "depends_on" | "depends-on" | "DependsOn" => DependsOn
    "relates_to" | "relates-to" | "RelatesTo" => RelatesTo
    other => fail("unknown edge kind: \{other}")
  }
}

///|
pub fn EdgeKind::to_storage(self : EdgeKind) -> String {
  match self {
    Contains => "contains"
    DependsOn => "depends_on"
    RelatesTo => "relates_to"
  }
}

///|
pub(all) struct IssueItem {
  id : String
  title : String
  body : String
  status : Status
  priority : Int
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) struct IssueEdge {
  from_id : String
  to_id : String
  kind : EdgeKind
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) struct IssueGraph {
  items : Array[IssueItem]
  edges : Array[IssueEdge]
  current_id : String?
} derive(Eq, Debug, ToJson, FromJson)

///|
pub fn IssueGraph::empty() -> IssueGraph {
  { items: [], edges: [], current_id: None }
}

///|
pub fn IssueGraph::find_item(self : IssueGraph, id : String) -> IssueItem? {
  for item in self.items {
    if item.id == id {
      return Some(item)
    }
  }
  None
}

///|
pub fn IssueGraph::contains_children(
  self : IssueGraph,
  id : String,
) -> Array[IssueItem] {
  let children : Array[IssueItem] = []
  for edge in self.edges {
    if edge.kind == Contains && edge.from_id == id {
      match self.find_item(edge.to_id) {
        Some(item) => children.push(item)
        None => ()
      }
    }
  }
  sort_items_for_display(children)
  children
}

///|
pub fn IssueGraph::dependencies(
  self : IssueGraph,
  id : String,
) -> Array[IssueItem] {
  let deps : Array[IssueItem] = []
  for edge in self.edges {
    if edge.kind == DependsOn && edge.from_id == id {
      match self.find_item(edge.to_id) {
        Some(item) => deps.push(item)
        None => ()
      }
    }
  }
  sort_items_for_display(deps)
  deps
}

///|
pub fn IssueGraph::is_blocked(self : IssueGraph, id : String) -> Bool {
  for dep in self.dependencies(id) {
    if dep.status.is_open() {
      return true
    }
  }
  false
}

///|
pub fn IssueGraph::has_open_child(self : IssueGraph, id : String) -> Bool {
  for child in self.contains_children(id) {
    if child.status.is_open() {
      return true
    }
  }
  false
}

///|
pub fn IssueGraph::next_todo(self : IssueGraph, focus : String?) -> IssueItem? {
  let candidates = self.ready_todos(focus)
  candidates.sort_by(compare_next_candidate)
  match candidates {
    [first, ..] => Some(first)
    [] => None
  }
}

///|
pub fn IssueGraph::scoped_items(
  self : IssueGraph,
  focus : String?,
) -> Array[IssueItem] {
  let scoped = self.scoped_item_ids(focus)
  let items = self.items.filter(item => scoped.contains(item.id))
  sort_items_for_display(items)
  items
}

///|
pub fn IssueGraph::ready_todos(
  self : IssueGraph,
  focus : String?,
) -> Array[IssueItem] {
  let candidates : Array[IssueItem] = []
  for item in self.scoped_items(focus) {
    if item.status.is_open() &&
      !self.is_blocked(item.id) &&
      !self.has_open_child(item.id) {
      candidates.push(item)
    }
  }
  candidates.sort_by(compare_next_candidate)
  candidates
}

///|
pub fn IssueGraph::blocked_todos(
  self : IssueGraph,
  focus : String?,
) -> Array[IssueItem] {
  let blocked : Array[IssueItem] = []
  for item in self.scoped_items(focus) {
    if item.status.is_open() && self.is_blocked(item.id) {
      blocked.push(item)
    }
  }
  sort_items_for_display(blocked)
  blocked
}

///|
pub fn IssueGraph::outline(self : IssueGraph, focus : String?) -> String {
  let roots = self.outline_roots(focus)
  let buf = StringBuilder()
  let seen : Map[String, Unit] = {}
  for root in roots {
    write_outline_node(self, root.id, 0, buf, seen)
  }
  buf.to_string()
}

///|
pub fn IssueGraph::todos_markdown(self : IssueGraph, focus : String?) -> String {
  let roots = self.outline_roots(focus)
  let buf = StringBuilder()
  let seen : Map[String, Unit] = {}
  for root in roots {
    write_todo_markdown_node(self, root.id, 0, buf, seen)
  }
  buf.to_string()
}

///|
pub fn IssueGraph::ready_todos_markdown(
  self : IssueGraph,
  focus : String?,
) -> String {
  self.items_markdown(self.ready_todos(focus))
}

///|
pub fn IssueGraph::blocked_todos_markdown(
  self : IssueGraph,
  focus : String?,
) -> String {
  self.items_markdown(self.blocked_todos(focus))
}

///|
pub fn IssueGraph::all_todos_markdown(
  self : IssueGraph,
  focus : String?,
) -> String {
  self.items_markdown(self.scoped_items(focus))
}

///|
pub fn IssueGraph::open_count(self : IssueGraph) -> Int {
  self.items.fold(init=0, (count, item) => {
    if item.status.is_open() {
      count + 1
    } else {
      count
    }
  })
}

///|
pub fn IssueGraph::blocked_count(self : IssueGraph) -> Int {
  self.items.fold(init=0, (count, item) => {
    if item.status.is_open() && self.is_blocked(item.id) {
      count + 1
    } else {
      count
    }
  })
}

///|
pub fn IssueGraph::edge_count(self : IssueGraph, kind : EdgeKind) -> Int {
  self.edges.fold(init=0, (count, edge) => {
    if edge.kind == kind {
      count + 1
    } else {
      count
    }
  })
}

///|
fn IssueGraph::scoped_item_ids(
  self : IssueGraph,
  focus : String?,
) -> Map[String, Unit] {
  let seen : Map[String, Unit] = {}
  match resolve_focus(focus, self.current_id) {
    Some(root) => collect_contains_scope(self, root, seen)
    None =>
      for item in self.items {
        seen[item.id] = ()
      }
  }
  seen
}

///|
fn IssueGraph::outline_roots(
  self : IssueGraph,
  focus : String?,
) -> Array[IssueItem] {
  match resolve_focus(focus, self.current_id) {
    Some(id) =>
      match self.find_item(id) {
        Some(item) => [item]
        None => []
      }
    None => {
      let has_parent : Map[String, Unit] = {}
      for edge in self.edges {
        if edge.kind == Contains {
          has_parent[edge.to_id] = ()
        }
      }
      let roots = self.items.filter(item => !has_parent.contains(item.id))
      if roots.length() == 0 {
        let all = self.items.copy()
        sort_items_for_display(all)
        all
      } else {
        sort_items_for_display(roots)
        roots
      }
    }
  }
}

///|
fn collect_contains_scope(
  graph : IssueGraph,
  id : String,
  seen : Map[String, Unit],
) -> Unit {
  if seen.contains(id) {
    return
  }
  seen[id] = ()
  for edge in graph.edges {
    if edge.kind == Contains && edge.from_id == id {
      collect_contains_scope(graph, edge.to_id, seen)
    }
  }
}

///|
fn resolve_focus(focus : String?, current : String?) -> String? {
  match focus {
    Some(_) => focus
    None => current
  }
}

///|
fn write_outline_node(
  graph : IssueGraph,
  id : String,
  depth : Int,
  buf : StringBuilder,
  seen : Map[String, Unit],
) -> Unit {
  let indent = "  ".repeat(depth)
  if seen.contains(id) {
    buf.write_string("\{indent}- \{id} (cycle)\n")
    return
  }
  seen[id] = ()
  match graph.find_item(id) {
    None => buf.write_string("\{indent}- \{id} (missing)\n")
    Some(item) => {
      let blocked = if graph.is_blocked(id) { " blocked" } else { "" }
      let current = if graph.current_id == Some(id) { " current" } else { "" }
      let status = item.status.to_storage()
      buf.write_string(
        "\{indent}- [\{status}] #\{item.id} \{item.title} (p\{item.priority}\{blocked}\{current})\n",
      )
      for child in graph.contains_children(id) {
        write_outline_node(graph, child.id, depth + 1, buf, seen)
      }
    }
  }
}

///|
fn write_todo_markdown_node(
  graph : IssueGraph,
  id : String,
  depth : Int,
  buf : StringBuilder,
  seen : Map[String, Unit],
) -> Unit {
  let indent = "  ".repeat(depth)
  if seen.contains(id) {
    buf.write_string("\{indent}- [ ] #\{id} (cycle)\n")
    return
  }
  seen[id] = ()
  match graph.find_item(id) {
    None => buf.write_string("\{indent}- [ ] #\{id} (missing)\n")
    Some(item) => {
      buf.write_string(
        "\{indent}- \{item.status.markdown_checkbox()} #\{item.id} \{item.title}\{todo_markdown_metadata(graph, item)}\n",
      )
      for child in graph.contains_children(id) {
        write_todo_markdown_node(graph, child.id, depth + 1, buf, seen)
      }
    }
  }
}

///|
fn IssueGraph::items_markdown(
  self : IssueGraph,
  items : Array[IssueItem],
) -> String {
  let buf = StringBuilder()
  for item in items {
    buf.write_string(
      "- \{item.status.markdown_checkbox()} #\{item.id} \{item.title}\{todo_markdown_metadata(self, item)}\n",
    )
  }
  buf.to_string()
}

///|
fn Status::markdown_checkbox(self : Status) -> String {
  match self {
    Done => "[x]"
    Todo | InProgress => "[ ]"
  }
}

///|
fn todo_markdown_metadata(graph : IssueGraph, item : IssueItem) -> String {
  let metadata : Array[String] = ["p\{item.priority}"]
  if item.status == InProgress {
    metadata.push("in-progress")
  }
  if graph.is_blocked(item.id) {
    metadata.push("blocked")
  }
  if graph.current_id == Some(item.id) {
    metadata.push("current")
  }
  " (" + metadata.join(" ") + ")"
}

///|
fn sort_items_for_display(items : Array[IssueItem]) -> Unit {
  items.sort_by((left, right) => {
    let by_priority = right.priority.compare(left.priority)
    if by_priority != 0 {
      by_priority
    } else {
      left.id.compare(right.id)
    }
  })
}

///|
fn compare_next_candidate(left : IssueItem, right : IssueItem) -> Int {
  let by_status = left.status
    .rank_for_next()
    .compare(right.status.rank_for_next())
  if by_status != 0 {
    by_status
  } else {
    let by_priority = right.priority.compare(left.priority)
    if by_priority != 0 {
      by_priority
    } else {
      left.id.compare(right.id)
    }
  }
}