///|
priv struct LocalEntry {
  path : String
  relative : String
  directory : Bool
}

///|
fn local_component(name : String) -> Unit raise NfsError {
  // Use a portable filename subset to keep remote names from becoming local paths.
  if name == "" ||
    name == "." ||
    name == ".." ||
    name.contains("/") ||
    name.contains("\\") ||
    name.contains(":") ||
    name.contains("\u{0000}") ||
    name.has_suffix(".") ||
    name.has_suffix(" ") {
    raise InvalidArgument("filename cannot be safely mapped to a local path")
  }
  for character in name {
    if character < ' ' ||
      character == '<' ||
      character == '>' ||
      character == '"' ||
      character == '|' ||
      character == '?' ||
      character == '*' {
      raise InvalidArgument("filename contains a reserved local character")
    }
  }
  let stem = match name.split_once(".") {
    Some((stem, _)) => stem.to_owned().to_upper()
    None => name.to_upper()
  }
  if [
      "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6",
      "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7",
      "LPT8", "LPT9",
    ].contains(stem) {
    raise InvalidArgument("filename is a reserved local device name")
  }
}

///|
fn local_child(parent : String, relative : String) -> String raise {
  if relative == "" {
    return parent
  }
  for component in relative.split("/") {
    local_component(component.to_owned())
  }
  parent + "/" + relative
}

///|
async fn collect_local_tree(
  path : String,
  max_nodes : Int,
  max_depth : Int,
) -> Array[LocalEntry] {
  let result : Array[LocalEntry] = []
  let pending : Array[(String, String, Int)] = [(path, "", 0)]
  while pending.pop() is Some((path, relative, depth)) {
    if result.length() >= max_nodes || depth > max_depth {
      raise InvalidArgument("local tree exceeds traversal budget")
    }
    let kind = @fs.kind(path, follow_symlink=false)
    let directory = kind == @fs.Directory
    if !directory && kind != @fs.Regular {
      raise InvalidArgument(
        "local tree upload accepts only regular files and directories",
      )
    }
    result.push({ path, relative, directory, })
    if directory {
      let names = @fs.readdir(path, include_hidden=true, sort=true)
      if names.length() > max_nodes - result.length() - pending.length() {
        raise InvalidArgument("local tree exceeds node budget")
      }
      for i = names.length() - 1; i >= 0; i = i - 1 {
        let name = names[i]
        local_component(name)
        let child = if relative == "" { name } else { relative + "/" + name }
        pending.push((local_child(path, name), child, depth + 1))
      }
    }
  }
  result
}

///|
/// Creates a fresh remote tree. Special nodes and local symlinks are rejected.
pub async fn Client::upload_tree(
  self : Client,
  local_path : String,
  remote_path : String,
) -> TreeTransfer {
  let entries = collect_local_tree(local_path, self.options.max_entries, 128)
  let mut files = 0
  let mut directories = 0
  let mut bytes = 0UL
  for entry in entries {
    let destination = join_path(remote_path, entry.relative)
    if entry.directory {
      ignore(self.mkdir(destination))
      directories += 1
    } else {
      let copied = self.upload(entry.path, destination)
      bytes = checked_sum(bytes, copied)
      files += 1
    }
  }
  { files, directories, symlinks: 0, bytes, }
}

///|
/// Creates a fresh local tree. Rejects links and special nodes before writing files.
pub async fn Client::download_tree(
  self : Client,
  remote_path : String,
  local_path : String,
) -> TreeTransfer {
  let entries = self.collect_tree(
    remote_path,
    max_nodes=self.options.max_entries,
  )
  for entry in entries {
    if entry.attributes.kind != 1U && entry.attributes.kind != 2U {
      raise InvalidArgument(
        "local tree download accepts only regular files and directories",
      )
    }
    ignore(local_child(local_path, entry.relative))
  }
  if @fs.exists(local_path) {
    raise InvalidArgument("local tree destination already exists")
  }
  let mut files = 0
  let mut directories = 0
  let mut bytes = 0UL
  for entry in entries {
    let destination = local_child(local_path, entry.relative)
    if entry.attributes.kind == 2U {
      @fs.mkdir(destination)
      directories += 1
    } else {
      let file = @fs.open(destination, mode=WriteOnly, create_mode=CreateNew)
      defer file.close()
      let copied = self.download_handle(entry.handle, file)
      file.sync()
      bytes = checked_sum(bytes, copied)
      files += 1
    }
  }
  { files, directories, symlinks: 0, bytes, }
}