defprotocol Livebook.FileSystem do
  # This protocol defines an interface for a virtual file system that
  # can be plugged into Livebook.

  @typedoc """
  An identifier uniquely identifying the given file system.

  Every file system struct is expected to have an `:id` field.

  The identifier should be computed deterministically based on the
  specific resource used as the file system. This ensures that
  identifiers persisted in a notebook work for multiple users, as
  long as they have a file system using the same resource.

  Ths identifier should also include file system type and hub id
  (if applicable) in order to avoid conflicts.
  """
  @type id :: String.t()

  @typedoc """
  A path uniquely identifies a file in the file system.

  Path has most of the semantics of regular file paths, with the
  following exceptions:

    * path must be absolute for consistency

    * directory path must have a trailing slash, whereas regular file
      path must not have a trailing slash. Rationale: certain file
      systems allow a directory and a file with the same name to
      co-exist, while path needs to distinguish between them

  """
  @type path :: String.t()

  @typedoc """
  A human-readable error message clarifying the operation failure
  reason.
  """
  @type error :: String.t()

  @type access :: :read | :write | :read_write | :none

  @doc """
  Returns the file system type.

  Based on the underlying resource, the type can be either:

    * `:local` - if the resource is local to its node

    * `:global` - if the resource is external and accessible from any
      node

  """
  @spec type(t()) :: :local | :global
  def type(file_system)

  @doc """
  Returns the default directory path.

  To some extent this is similar to current working directory in a
  regular file system. For most file systems this will just be the
  root path.
  """
  @spec default_path(t()) :: path()
  def default_path(file_system)

  @doc """
  Returns a list of files located in the given directory.

  When `recursive` is set to `true`, nested directories are traversed
  and the final list includes all the paths.
  """
  @spec list(t(), path(), boolean()) :: {:ok, list(path())} | {:error, error()}
  def list(file_system, path, recursive)

  @doc """
  Returns binary content of the given file.
  """
  @spec read(t(), path()) :: {:ok, binary()} | {:error, error()}
  def read(file_system, path)

  @doc """
  Writes the given binary content to the given file.

  If the file exists, it gets overridden.

  If the file doesn't exist, it gets created along with all the
  necessary directories.
  """
  @spec write(t(), path(), binary()) :: :ok | {:error, error()}
  def write(file_system, path, content)

  @doc """
  Returns the current access level to the given file.

  If determining the access is costly, then this function may always
  return the most liberal access, since all access functions return
  error on an invalid attempt.
  """
  @spec access(t(), path()) :: {:ok, access()} | {:error, error()}
  def access(file_system, path)

  @doc """
  Creates the given directory unless it already exists.

  All necessary parent directories are created as well.
  """
  @spec create_dir(t(), path()) :: :ok | {:error, error()}
  def create_dir(file_system, path)

  @doc """
  Removes the given file.

  If a directory is given, all of its contents are removed recursively.

  If the file doesn't exist, no error is returned.
  """
  @spec remove(t(), path()) :: :ok | {:error, error()}
  def remove(file_system, path)

  @doc """
  Copies the given file.

  The given files must be of the same type.

  If regular files are given, the contents are copied, potentially
  overriding the destination if it already exists.

  If directories are given, the directory contents are copied
  recursively.
  """
  @spec copy(t(), path(), path()) :: :ok | {:error, error()}
  def copy(file_system, source_path, destination_path)

  @doc """
  Renames the given file.

  If a directory is given, it gets renamed as expected and consequently
  all of the child paths change.

  If the destination exists, an error is returned.
  """
  @spec rename(t(), path(), path()) :: :ok | {:error, error()}
  def rename(file_system, source_path, destination_path)

  @doc """
  Returns a version identifier for the given file.

  The resulting value must be a string of ASCII characters placed
  between double quotes, suitable for use as the value of the ETag
  HTTP header.
  """
  @spec etag_for(t(), path()) :: {:ok, String.t()} | {:error, error()}
  def etag_for(file_system, path)

  @doc """
  Checks if the given path exists in the file system.
  """
  @spec exists?(t(), path()) :: {:ok, boolean()} | {:error, error()}
  def exists?(file_system, path)

  @doc """
  Resolves `subject` against a valid directory path.

  The `subject` may be either relative or absolute, contain special
  sequences such as ".." and ".", but the interpretation is left up
  to the file system.

  In other words, this has the semantics of path join followed by
  expand.
  """
  @spec resolve_path(t(), path(), String.t()) :: path()
  def resolve_path(file_system, dir_path, subject)

  @doc """
  Initializes chunked write to the given file.

  Should return the initial state, which is then reduced over in
  `write_stream_chunk/3`.
  """
  @spec write_stream_init(t(), path(), keyword()) :: {:ok, state} | {:error, error()}
        when state: term()
  def write_stream_init(file_system, path, opts)

  @doc """
  Writes a file chunk.

  There is no assumption on the chunk size, you can accumulate chunks
  in `state` and perform the write operation once the desired chunk
  size is achieved.
  """
  @spec write_stream_chunk(t(), state, binary()) :: {:ok, state} | {:error, error()}
        when state: term()
  def write_stream_chunk(file_system, state, chunk)

  @doc """
  Finalizes chunked write operation.

  This function is called when all chunks have been successfully
  written.

  Note that if the finish operation fails, `write_stream_halt/2`
  is **not** expected to be called, so you should do the necessary
  cleanup here in case of failure as well.
  """
  @spec write_stream_finish(t(), state) :: :ok | {:error, error()} when state: term()
  def write_stream_finish(file_system, state)

  @doc """
  Halts chunked write operation.

  This function is called when writing any of the chunks fails or the
  writing is aborted by the caller.
  """
  @spec write_stream_halt(t(), state) :: :ok | {:error, error()} when state: term()
  def write_stream_halt(file_system, state)

  @doc """
  Similar to `read/2`, but streams file contents into `collectable`
  chunk by chunk.

  The `Collectable` protocol does not make room for gracefully
  signalling an error, so implementations generally raise an
  exception. `read_stream_into/3` is not expected to raise, so make
  sure to convert collectable exceptions into an error tuple.
  """
  @spec read_stream_into(t(), path(), Collectable.t()) ::
          {:ok, Collectable.t()} | {:error, error()}
  def read_stream_into(file_system, path, collectable)

  @doc """
  Loads fields into given file system.
  """
  @spec load(t(), map()) :: struct()
  def load(file_system, fields)

  @doc """
  Transforms file system to the attributes map.
  """
  @spec dump(t()) :: map()
  def dump(file_system)

  @doc """
  Returns file system metadata for external storages.
  """
  @spec external_metadata(t()) :: %{name: String.t(), error_field: String.t()}
  def external_metadata(file_system)
end
