///|
pub fn list_tasks(project : Project) -> Array[String] {
  let names = project.tasks.keys().to_array()
  names.sort()
  names
}

///|
pub fn render_graph(
  project : Project,
  target : String?,
) -> Array[String] raise MoonforgeError {
  let order = task_order(project, target)
  let lines = []
  for name in order {
    let task = get_task(project, name)
    let deps = if task.deps.is_empty() {
      "(root)"
    } else {
      join_strings(task.deps, ", ")
    }
    lines.push("\{name} <- \{deps}")
  }
  lines
}

///|
pub fn explain_task(
  project : Project,
  target : String?,
) -> Array[String] raise MoonforgeError {
  let cache = load_cache(project)
  let (_order, _levels, plans) = make_plan(project, cache, target)
  let lines = []
  for plan in plans {
    lines.push(task_summary_line(plan))
  }
  lines
}

///|
pub fn project_stats(project : Project) -> ProjectStats raise MoonforgeError {
  validate_project(project)
  let names = list_tasks(project)
  let reverse_deps = reverse_dependency_map(project)
  let default_target = default_task_name(project)
  let default_order = task_order(project, Some(default_target))
  let missing_inputs = missing_input_findings(project)
  let duplicate_outputs = duplicate_outputs(project)
  let overlapping_outputs = overlapping_output_findings(project)
  let mut phony_tasks = 0
  let mut concrete_tasks = 0
  let mut tasks_with_inputs = 0
  let mut tasks_with_outputs = 0
  let mut total_declared_inputs = 0
  let mut total_declared_outputs = 0
  let mut root_tasks = 0
  let mut leaf_tasks = 0
  let mut max_depth = 0
  for name in names {
    let task = get_task(project, name)
    total_declared_inputs += task.inputs.length()
    total_declared_outputs += task.outputs.length()
    if task.is_effectively_phony() {
      phony_tasks += 1
    } else {
      concrete_tasks += 1
    }
    if !task.inputs.is_empty() {
      tasks_with_inputs += 1
    }
    if !task.outputs.is_empty() {
      tasks_with_outputs += 1
    }
    if task.deps.is_empty() {
      root_tasks += 1
    }
    if reverse_deps.get(name).unwrap_or([]).is_empty() {
      leaf_tasks += 1
    }
    let depth = dependency_depth(project, name)
    if depth > max_depth {
      max_depth = depth
    }
  }
  {
    default_target,
    total_tasks: names.length(),
    root_tasks,
    leaf_tasks,
    phony_tasks,
    concrete_tasks,
    tasks_with_inputs,
    tasks_with_outputs,
    total_declared_inputs,
    total_declared_outputs,
    max_depth,
    duplicate_outputs: duplicate_outputs.length(),
    overlapping_outputs: overlapping_outputs.length(),
    missing_inputs: missing_inputs.length(),
    unreachable_tasks: names.length() - default_order.length(),
  }
}

///|
pub fn render_project_stats(
  project : Project,
) -> Array[String] raise MoonforgeError {
  let stats = project_stats(project)
  [
    "default target: \{stats.default_target}",
    "task count: \{stats.total_tasks}",
    "root tasks: \{stats.root_tasks}",
    "leaf tasks: \{stats.leaf_tasks}",
    "phony tasks: \{stats.phony_tasks}",
    "concrete tasks: \{stats.concrete_tasks}",
    "tasks with inputs: \{stats.tasks_with_inputs}",
    "tasks with outputs: \{stats.tasks_with_outputs}",
    "declared inputs: \{stats.total_declared_inputs}",
    "declared outputs: \{stats.total_declared_outputs}",
    "max dependency depth: \{stats.max_depth}",
    "duplicate outputs: \{stats.duplicate_outputs}",
    "overlapping outputs: \{stats.overlapping_outputs}",
    "missing inputs: \{stats.missing_inputs}",
    "unreachable tasks from default target: \{stats.unreachable_tasks}",
  ]
}

///|
pub fn doctor(project : Project) -> Array[String] raise MoonforgeError {
  let findings = []
  validate_project(project)
  findings.push("config parses successfully")
  let duplicates = duplicate_outputs(project)
  if duplicates.is_empty() {
    findings.push("no duplicate outputs detected")
  } else {
    for item in duplicates {
      let (output, left, right) = item
      findings.push(
        "duplicate output '\{output}' declared by \{left} and \{right}",
      )
    }
  }
  let overlapping_outputs = overlapping_output_findings(project)
  if overlapping_outputs.is_empty() {
    findings.push("no overlapping output directories detected")
  } else {
    for item in overlapping_outputs {
      let (left, right) = item
      findings.push("overlapping outputs declared by \{left} and \{right}")
    }
  }
  let missing_inputs = missing_input_findings(project)
  if missing_inputs.is_empty() {
    findings.push("all declared inputs exist or are produced by dependencies")
  } else {
    for item in missing_inputs {
      let (task_name, input) = item
      findings.push(
        "task '\{task_name}' declares missing input '\{input}' without a producing dependency",
      )
    }
  }
  let names = list_tasks(project)
  let reachable = task_order(project, Some(default_task_name(project)))
  findings.push("task count: \{names.length()}")
  findings.push(
    "reachable from default target '\{default_task_name(project)}': \{reachable.length()}",
  )
  for name in names {
    let task = get_task(project, name)
    if task.cmd.trim(chars=" \n\r\t").is_empty() {
      findings.push("task '\{name}' has an empty command")
    }
    if task.is_effectively_phony() {
      findings.push("task '\{name}' is phony")
    }
    if !reachable.contains(name) {
      findings.push("task '\{name}' is unreachable from the default target")
    }
  }
  findings
}

///|
pub fn clean_project(project : Project) -> Array[String] raise MoonforgeError {
  let removed = []
  for name, task in project.tasks {
    ignore(name)
    for output in task.outputs {
      let resolved = resolve_project_path(project.root, output)
      if @fs.path_exists(resolved) {
        remove_path_recursive(resolved)
        removed.push(output)
      }
    }
  }
  let cache_file = cache_path(project)
  if @fs.path_exists(cache_file) {
    remove_path_recursive(cache_file)
    removed.push(cache_file)
  }
  let cache_folder = cache_dir(project)
  if @fs.path_exists(cache_folder) {
    let entries = @fs.read_dir(cache_folder) catch {
      @fs.IOError(message) =>
        execution_error("failed to read cache directory: \{message}")
    }
    if entries.is_empty() {
      remove_path_recursive(cache_folder)
      removed.push(cache_folder)
    }
  }
  removed
}

///|
fn reverse_dependency_map(project : Project) -> Map[String, Array[String]] {
  let reverse : Map[String, Array[String]] = Map([])
  for name in project.tasks.keys() {
    reverse[name] = []
  }
  for task_name, task in project.tasks {
    for dep in task.deps {
      let users = reverse.get(dep).unwrap_or([])
      users.push(task_name)
      reverse[dep] = users
    }
  }
  reverse
}

///|
fn dependency_depth(
  project : Project,
  name : String,
) -> Int raise MoonforgeError {
  let task = get_task(project, name)
  if task.deps.is_empty() {
    0
  } else {
    let mut max_dep = 0
    for dep in task.deps {
      let depth = dependency_depth(project, dep) + 1
      if depth > max_dep {
        max_dep = depth
      }
    }
    max_dep
  }
}

///|
fn overlapping_output_findings(project : Project) -> Array[(String, String)] {
  let declared = []
  for task_name, task in project.tasks {
    for output in task.outputs {
      declared.push((task_name, normalize_path(output)))
    }
  }
  let findings = []
  for i = 0; i < declared.length(); i = i + 1 {
    let (left_task, left_path) = declared[i]
    for j = i + 1; j < declared.length(); j = j + 1 {
      let (right_task, right_path) = declared[j]
      if left_path == right_path {
        ()
      } else if is_path_prefix(left_path, right_path) ||
        is_path_prefix(right_path, left_path) {
        findings.push((left_task, right_task))
      }
    }
  }
  findings
}

///|
fn missing_input_findings(
  project : Project,
) -> Array[(String, String)] raise MoonforgeError {
  let findings = []
  for task_name, task in project.tasks {
    let available = declared_outputs_from_dependencies(project, task_name)
    for input in task.inputs {
      let resolved = resolve_project_path(project.root, input)
      if @fs.path_exists(resolved) || available.contains(normalize_path(input)) {
        ()
      } else {
        findings.push((task_name, normalize_path(input)))
      }
    }
  }
  findings
}

///|
fn declared_outputs_from_dependencies(
  project : Project,
  task_name : String,
) -> Array[String] raise MoonforgeError {
  let outputs = []
  let seen : Map[String, Bool] = Map([])
  collect_dependency_outputs(project, task_name, seen, outputs)
  outputs
}

///|
fn collect_dependency_outputs(
  project : Project,
  task_name : String,
  seen : Map[String, Bool],
  outputs : Array[String],
) -> Unit raise MoonforgeError {
  let task = get_task(project, task_name)
  for dep in task.deps {
    if !seen.contains(dep) {
      seen[dep] = true
      let dep_task = get_task(project, dep)
      for output in dep_task.outputs {
        let normalized = normalize_path(output)
        if !outputs.contains(normalized) {
          outputs.push(normalized)
        }
      }
      collect_dependency_outputs(project, dep, seen, outputs)
    }
  }
}

///|
fn is_path_prefix(left : String, right : String) -> Bool {
  let normalized_left = normalize_path(left)
  let normalized_right = normalize_path(right)
  normalized_right.has_prefix("\{normalized_left}/")
}