///|
suberror Failure {
  Failure(String, String, Int32)
}

///|
suberror Partial {
  Partial(String, String, Int32, Json)
}

///|
fn[T] invalid(message : String) -> T raise {
  raise Failure("INVALID_INPUT", message, 2)
}

///|
fn[T] failed(code : String, message : String) -> T raise {
  raise Failure(code, message, 1)
}

///|
fn field(j : Json, key : String) -> Json {
  match j {
    Object(m) => m.get(key).unwrap_or(Json::null())
    _ => Json::null()
  }
}

///|
fn str(j : Json) -> String raise {
  match j {
    String(s) => s
    _ => invalid("expected a string, got " + j.stringify())
  }
}

///|
fn string_field(j : Json, key : String) -> String raise {
  str(field(j, key))
}

///|
fn array(j : Json) -> Array[Json] raise {
  match j {
    Array(a) => a
    _ => invalid("expected an array")
  }
}

///|
fn strings(j : Json) -> Array[String] raise {
  array(j).map(x => str(x))
}

///|
fn keys(j : Json, allowed : Array[String]) -> Unit raise {
  guard j is Object(m) else { invalid("expected an object") }
  for k, _ in m {
    if !allowed.contains(k) {
      invalid("unsupported field: " + k)
    }
  }
}

///|
fn opt_string(j : Json, key : String, default : String) -> String raise {
  if field(j, key) == Json::null() {
    default
  } else {
    string_field(j, key)
  }
}

///|
fn json_strings(xs : Array[String]) -> Json {
  Json::array(xs.map(x => Json::string(x)))
}

///|
fn parent(path : String) -> String {
  let parts = path.split("/").map(x => x.to_owned()).collect()
  ignore(parts.pop())
  if parts.is_empty() {
    "."
  } else if parts.length() == 1 && parts[0] == "" {
    "/"
  } else {
    parts.join("/")
  }
}

///|
fn basename(path : String) -> String {
  path.split("/").last().unwrap_or("").to_owned()
}

///|
fn absolute(path : String) -> String {
  if path.has_prefix("/") {
    path
  } else {
    @env.current_dir().unwrap_or(".") + "/" + path
  }
}

///|
fn safe_relative(path : String) -> Unit raise {
  if path.is_empty() ||
    path.has_prefix("/") ||
    path.contains("\\") ||
    path.contains(":") ||
    path.contains("\n") ||
    path.contains("\r") {
    invalid("unsafe relative path: " + path)
  }
  for part in path.split("/") {
    if part == ".." || part == "." || part.is_empty() {
      invalid("unsafe path component: " + path)
    }
  }
}

///|
fn inside(root : String, path : String) -> Bool {
  path.has_prefix(root + "/")
}

///|
async fn contained(root : String, relative : String) -> String {
  safe_relative(relative)
  let p = @fs.realpath(root + "/" + relative)
  if !inside(root, p) {
    invalid("symlink escapes module: " + relative)
  }
  p
}

///|
async fn future_path(path : String) -> String {
  if @fs.exists(path) {
    @fs.realpath(path)
  } else {
    future_path(parent(path)) + "/" + basename(path)
  }
}

///|
async fn read_json(path : String) -> Json {
  @json.parse(@fs.read_file(path).text())
}

///|
async fn write_json(host : Host, path : String, value : Json) -> Unit {
  host.write_file(
    path + ".new",
    value.stringify(indent=2) + "\n",
    create_mode=CreateNew,
  )
  host.rename(path + ".new", path)
}

///|
async fn copy_file(host : Host, src : String, dest : String) -> Unit {
  host.mkdir(parent(dest), recursive=true, allow_exist=true)
  host.write_file(dest, @fs.read_file(src), create_mode=CreateNew)
}

///|
fn digest(bytes : Bytes) -> String {
  @crypto.bytes_to_hex_string(@crypto.sha256(bytes))
}

///|
async fn file_digest(path : String) -> String {
  digest(@fs.read_file(path).binary())
}

///|
async fn inventory(root : String, prefix~ : String = "") -> Json {
  let m : Map[String, Json] = Map([])
  for name in @fs.readdir(root, include_hidden=true, sort=true) {
    safe_relative(name)
    let path = root + "/" + name
    match @fs.kind(path, follow_symlink=false) {
      Regular => m[prefix + name] = Json::string(file_digest(path))
      Directory => {
        guard inventory(path, prefix=prefix + name + "/") is Object(child) else {
          abort("inventory")
        }
        for k, v in child {
          m[k] = v
        }
      }
      _ => invalid("non-regular snapshot input: " + path)
    }
  }
  Json::object(m)
}

///|
/// One process boundary. Tests inject this function, never production environment switches.
struct Host {
  run : async (String, Array[String], String) -> (Int32, String, String)
  preview : Bool
  mutation : (String) -> Unit raise
}

///|
fn real_host(preview : Bool) -> Host {
  {
    preview,
    mutation: fn(action) raise {
      if preview {
        failed("PREVIEW_MUTATION", "preview attempted " + action)
      }
    },
    run: async fn(cmd, args, cwd) {
      let (code, out, err) = @process.collect_output(cmd, args[:], cwd=cwd[:])
      (code, out.text(), err.text())
    },
  }
}

///|
async fn Host::call(
  self : Host,
  cmd : String,
  args : Array[String],
  cwd : String,
) -> (Int32, String, String) {
  if self.preview {
    failed("PREVIEW_MUTATION", "preview attempted a process")
  }
  (self.run)(cmd, args, cwd)
}

///|
async fn Host::checked(
  self : Host,
  cmd : String,
  args : Array[String],
  cwd : String,
) -> String {
  let (code, out, err) = self.call(cmd, args, cwd)
  if !out.is_empty() {
    eprintln(redact(out).trim_end())
  }
  if !err.is_empty() {
    eprintln(redact(err).trim_end())
  }
  if code != 0 {
    failed("SUBPROCESS_FAILED", "\{cmd} exited \{code}: " + redact(err))
  }
  out
}

///|
fn redact(text : String) -> String {
  let mut value = text
  for
    key in [
      "NPM_TOKEN", "NODE_AUTH_TOKEN", "MOONCAKES_USER_TOKEN", "NPM_ID_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
    ] {
    if @env.get_env_var(key) is Some(token) && token.length() > 3 {
      value = value.replace(old=token, new="[REDACTED]")
    }
  }
  let tokens = value.split(" ").map(x => x.to_owned()).collect()
  for token in tokens {
    if token.contains("npm_") ||
      token.contains("_authToken=") ||
      token.contains("token=") {
      value = value.replace(old=token, new="[REDACTED]")
    }
  }
  if value.contains("Bearer ") {
    let parts = value.split("Bearer ").map(x => x.to_owned()).collect()
    for p in parts[1:] {
      let token = p.split(" ").next().unwrap_or("").trim().to_owned()
      if !token.is_empty() {
        value = value.replace(old=token, new="[REDACTED]")
      }
    }
  }
  value
}

///|
async fn Host::mkdir(
  self : Host,
  path : String,
  recursive~ : Bool = false,
  allow_exist~ : Bool = false,
) -> Unit {
  (self.mutation)("mkdir " + path)
  @fs.mkdir(path, recursive~, allow_exist~)
}

///|
async fn Host::write_file(
  self : Host,
  path : String,
  data : &@io.Data,
  create_mode~ : @fs.CreateMode = CreateOrTruncate,
) -> Unit {
  (self.mutation)("write " + path)
  @fs.write_file(path, data, create_mode~)
}

///|
async fn Host::create(
  self : Host,
  path : String,
  allow_existing~ : Bool = false,
) -> @fs.File {
  (self.mutation)("lock " + path)
  @fs.create(path, allow_existing~)
}

///|
async fn Host::rename(
  self : Host,
  from : String,
  to : String,
  replace~ : Bool = true,
) -> Unit {
  (self.mutation)("rename " + from + " to " + to)
  @fs.rename(from, to, replace~)
}

///|
async fn Host::remove(self : Host, path : String) -> Unit {
  (self.mutation)("remove " + path)
  @fs.remove(path)
}

///|
async fn Host::rmdir(
  self : Host,
  path : String,
  recursive~ : Bool = false,
) -> Unit {
  (self.mutation)("rmdir " + path)
  @fs.rmdir(path, recursive~)
}

///|
async fn Host::tmpdir(self : Host, prefix~ : String) -> String {
  (self.mutation)("tmpdir " + prefix)
  @fs.realpath(@fs.tmpdir(prefix~))
}