///|
/// Tool management, the `mb tool` command group.
///
/// A "tool" is a mooncakes.io module that publishes at least one executable
/// package. `mb tool install` downloads the module source, compiles it with a
/// managed (or system) MoonBit toolchain and copies the resulting native
/// binaries into `~/.local/bin`.

///|
pub const MOONCAKES_API : String = "https://mooncakes.io"

///|
pub const MOONCAKES_DOWNLOAD : String = "https://download.mooncakes.io"

///|
/// Persisted metadata for one installed tool version. `bin` records where the
/// executables were copied; it is optional because records written before it
/// existed only have `bins`.
pub struct ToolMeta {
  name : String
  version : String
  bins : Array[String]
  bin : String?
} derive(ToJson, FromJson)

///|
pub extend ToolMeta with ToJson::{to_json}

///|
pub extend ToolMeta with @json.FromJson::{from_json}

///|
/// Root directory holding all installed tools.
pub fn tool_root() -> String raise MbError {
  path_join(mb_home(), "tool")
}

///|
fn tool_key(name : String) -> String {
  name.replace(old="/", new="-")
}

///|
/// Directory for one tool version.
pub fn tool_dir(name : String, version : String) -> String raise MbError {
  path_join(path_join(tool_root(), tool_key(name)), version)
}

///|
fn split_spec(spec : String) -> (String, String?) raise MbError {
  match spec.split_once("@") {
    Some((m, v)) => (m.to_owned(), Some(v.to_owned()))
    None =>
      if spec == "" {
        raise MbError(
          "expected a package name in the form /[@]",
        )
      } else {
        (spec, None)
      }
  }
}

///|
/// Fetch the registry manifest for a module, optionally pinned to a version.
pub fn fetch_manifest(name : String, version : String?) -> Json raise MbError {
  let url = match version {
    Some(v) => MOONCAKES_API + "/api/v0/manifest/" + name + "@" + v
    None => MOONCAKES_API + "/api/v0/manifest/" + name
  }
  let j = fetch_json(url) catch {
    _ => raise MbError("module '" + name + "' was not found on mooncakes.io")
  }
  match json_object(j, "metadata") {
    Some(_) => j
    None =>
      raise MbError("mooncakes.io returned no metadata for '" + name + "'")
  }
}

///|
/// Discover native executables produced by `moon build` under `src`.
///
/// MoonBit writes native binaries as `.exe`; we additionally verify the
/// file magic so non-executable artefacts are ignored.
pub fn discover_binaries(src : String) -> Array[(String, String)] raise MbError {
  let out : Array[(String, String)] = []
  let build = path_join(src, "_build/native/release/build")
  if !dir_exists(build) {
    return out
  }
  for rel in walk_files(build) {
    let abs = path_join(build, rel)
    if !is_executable_file(abs) {
      continue
    }
    let raw = base_name(rel)
    let tool_name = if raw.has_suffix(".exe") {
      raw[:raw.length() - 4].to_owned()
    } else {
      raw
    }
    out.push((tool_name, abs))
  }
  out
}

///|
/// Compile the extracted source at `src` with `tc` and return the discovered
/// binaries.
pub fn build_tool(
  src : String,
  tc : ToolchainEnv,
) -> Array[(String, String)] raise MbError {
  let prefix = env_prefix(tc)
  let cmd = "cd " +
    shell_quote(src) +
    " && " +
    prefix +
    shell_quote(tc.moon) +
    " build --target native --release"
  run_checked(cmd) catch {
    _ => raise MbError("failed to build the tool in " + src)
  }
  let bins = discover_binaries(src)
  if bins.is_empty() {
    raise MbError(
      "no native executable was produced; this module does not publish a runnable package",
    )
  }
  bins
}

///|
/// Install one tool from a `/[@]` spec.
pub fn tool_install(
  spec : String,
  toolchain_override : String,
  bin_override : String,
  force : Bool,
) -> Unit raise MbError {
  ensure_tools()
  let (name, requested) = split_spec(spec)
  println("Resolving " + name + " ...")
  let manifest = fetch_manifest(name, requested)
  let meta = match json_object(manifest, "metadata") {
    Some(m) => m
    None =>
      raise MbError("mooncakes.io returned no metadata for '" + name + "'")
  }
  let resolved_name = match json_string(meta, "name") {
    Some(n) => n
    None => name
  }
  let version = match json_string(meta, "version") {
    Some(v) => v
    None => raise MbError("could not determine a version for '" + name + "'")
  }
  let checksum = match json_string(meta, "checksum") {
    Some(c) => c
    None => raise MbError("could not determine a checksum for '" + name + "'")
  }
  let dir = tool_dir(resolved_name, version)
  if file_exists(path_join(dir, "tool.json")) && !force {
    println(
      resolved_name +
      " " +
      version +
      " is already installed; use --force to reinstall",
    )
    return
  }
  // Download and verify the source archive.
  let url = MOONCAKES_DOWNLOAD +
    "/user/" +
    resolved_name +
    "/" +
    version +
    ".zip"
  println("Downloading " + url + " ...")
  let zip_path = temp_path()
  download(url, zip_path, true)
  let actual = sha256_hex(read_bytes(zip_path))
  if actual != checksum {
    rm_rf(zip_path) catch {
      _ => ()
    }
    raise MbError("checksum mismatch for " + resolved_name + " " + version)
  }
  let src = path_join(dir, "src")
  rm_rf(src) catch {
    _ => ()
  }
  extract_zip(read_bytes(zip_path), src)
  rm_rf(zip_path) catch {
    _ => ()
  }
  // Build with a toolchain.
  let tc = resolve_toolchain(toolchain_override)
  println("Building " + resolved_name + " " + version + " ...")
  let built = build_tool(src, tc)
  // Install the binaries.
  let bin_dir = if bin_override != "" { bin_override } else { bin_dir() }
  mkdir_p(bin_dir)
  let installed : Array[String] = []
  for (tool_name, path) in built {
    let dest = path_join(bin_dir, tool_name)
    copy_file(path, dest)
    chmod_exec(dest)
    installed.push(tool_name)
    println("  installed " + dest)
  }
  let record : ToolMeta = {
    name: resolved_name,
    version,
    bins: installed,
    bin: Some(bin_dir),
  }
  write_text(
    path_join(dir, "tool.json"),
    @json.to_json(record).stringify(indent=2),
  )
  println("Installed " + resolved_name + " " + version)
}

///|
/// Read the metadata of every installed tool version.
pub fn installed_tools() -> Array[ToolMeta] raise MbError {
  let out : Array[ToolMeta] = []
  let root = tool_root()
  if !dir_exists(root) {
    return out
  }
  for key in list_dir(root) {
    let versions_dir = path_join(root, key)
    if !dir_exists(versions_dir) {
      continue
    }
    for version in list_dir(versions_dir) {
      let meta_path = path_join(path_join(versions_dir, version), "tool.json")
      if !file_exists(meta_path) {
        continue
      }
      let text = read_text(meta_path) catch { _ => continue }
      let meta : ToolMeta = @json.from_json(
        @json.parse(text) catch {
          _ => continue
        },
      ) catch {
        _ => continue
      }
      out.push(meta)
    }
  }
  out
}

///|
/// Where a tool's executables live: its recorded install directory, or the
/// current default for records written before that was tracked.
pub fn tool_bin_dir(meta : ToolMeta) -> String raise MbError {
  match meta.bin {
    Some(b) => b
    None => bin_dir()
  }
}

///|
/// The `mb tool list` command. With `outdated`, consult mooncakes.io and mark
/// tools that have a newer version available.
pub fn tool_list(outdated : Bool) -> Unit raise MbError {
  let tools = installed_tools()
  let root = tool_root()
  if tools.is_empty() {
    println("No tools installed (looked in " + root + ").")
    return
  }
  println("Installed tools in " + root + ":")
  for t in tools {
    let mut line = "  " + t.name + " " + t.version
    if outdated {
      let latest = latest_tool_version(t.name) catch { _ => "" }
      if latest == "" {
        line = line + "  (could not check for updates)"
      } else if latest != t.version {
        line = line + "  (newer: " + latest + ")"
      }
    }
    println(line)
    let bin = tool_bin_dir(t)
    if t.bins.is_empty() {
      println("    no binaries recorded")
    } else {
      let entries = t.bins.map(b => {
        if file_exists(path_join(bin, b)) {
          b
        } else {
          b + " (missing)"
        }
      })
      println("    " + entries.join(", ") + "  in " + bin)
    }
  }
}

///|
/// The `mb tool uninstall ` command.
pub fn tool_uninstall(spec : String) -> Unit raise MbError {
  let (name, requested) = split_spec(spec)
  let tools = installed_tools()
  let mut removed = false
  for t in tools {
    if t.name != name {
      continue
    }
    if requested is Some(v) && v != t.version {
      continue
    }
    // Remove from the directory the tool was actually installed into.
    let bin = tool_bin_dir(t)
    for b in t.bins {
      rm_rf(path_join(bin, b)) catch {
        _ => ()
      }
    }
    rm_rf(tool_dir(t.name, t.version)) catch {
      _ => ()
    }
    // Drop the per-tool directory once its last version is gone.
    let name_dir = path_join(tool_root(), tool_key(t.name))
    if dir_exists(name_dir) && list_dir_unsafe(name_dir).is_empty() {
      rm_rf(name_dir) catch {
        _ => ()
      }
    }
    println("Uninstalled " + t.name + " " + t.version)
    removed = true
  }
  if !removed {
    raise MbError("no installed tool matches '" + spec + "'")
  }
}

///|
/// The `mb tool upgrade` command. With `all`, upgrade every installed tool;
/// otherwise upgrade the named tools (any version specifier is ignored and the
/// latest version is installed). Each tool is rebuilt into the directory it was
/// originally installed into.
pub fn tool_upgrade(
  names : Array[String],
  all : Bool,
  toolchain_override : String,
) -> Unit raise MbError {
  ensure_tools()
  let tools = installed_tools()
  if tools.is_empty() {
    println("No tools installed.")
    return
  }
  let wanted : Array[ToolMeta] = []
  for t in tools {
    let mut pick = all
    for n in names {
      if fst(split_spec(n)) == t.name {
        pick = true
      }
    }
    if pick {
      // One entry per tool name, even when several versions are installed.
      let mut dup = false
      for w in wanted {
        if w.name == t.name {
          dup = true
        }
      }
      if !dup {
        wanted.push(t)
      }
    }
  }
  for t in wanted {
    let latest = latest_tool_version(t.name) catch {
      e => {
        eprintln("warning: could not check " + t.name + ": " + e.message())
        continue
      }
    }
    if latest == t.version {
      println(t.name + " " + t.version + " is already up to date")
    } else {
      println("Upgrading " + t.name + " " + t.version + " -> " + latest)
      tool_install(
        t.name + "@" + latest,
        toolchain_override,
        tool_bin_dir(t),
        true,
      )
      rm_rf(tool_dir(t.name, t.version)) catch {
        _ => ()
      }
    }
  }
  // Warn about names that matched no installed tool.
  for n in names {
    let name = fst(split_spec(n))
    let mut found = false
    for t in tools {
      if t.name == name {
        found = true
      }
    }
    if !found {
      eprintln("warning: '" + name + "' is not installed")
    }
  }
}

///|
/// The `mb tool uninstall` command: remove the named tools, or every installed
/// tool with `all`.
pub fn tool_uninstall_many(
  specs : Array[String],
  all : Bool,
) -> Unit raise MbError {
  if all {
    let tools = installed_tools()
    if tools.is_empty() {
      println("No tools installed.")
      return
    }
    let done : Array[String] = []
    for t in tools {
      let mut seen = false
      for d in done {
        if d == t.name {
          seen = true
        }
      }
      if seen {
        continue
      }
      done.push(t.name)
      tool_uninstall(t.name)
    }
    return
  }
  for s in specs {
    tool_uninstall(s)
  }
}

///|
fn fst(pair : (String, String?)) -> String {
  let (a, _) = pair
  a
}

///|
/// Latest published version of a module.
pub fn latest_tool_version(name : String) -> String raise MbError {
  let manifest = fetch_manifest(name, None)
  match json_string(manifest, "latest_version") {
    Some(v) => v
    None =>
      match json_object(manifest, "metadata") {
        Some(m) =>
          match json_string(m, "version") {
            Some(v) => v
            None =>
              raise MbError(
                "could not determine the latest version of '" + name + "'",
              )
          }
        None =>
          raise MbError(
            "could not determine the latest version of '" + name + "'",
          )
      }
  }
}