defmodule Mongo.GridFs.Download do
  @moduledoc """
  The main entry point for downloading files from the grid-fs specified by the bucket struct.
  """

  alias BSON.ObjectId
  alias Mongo.GridFs.Bucket

  @type result ::
          {:error, :unknown}
          | {:error, :length_is_zero}
          | {:error, :not_found}
          | {:ok, Mongo.cursor()}

  @doc """
  Opens a Stream from which the application can read the contents of the stored file
  specified by fileId. The fileId can be a string, an ObjectId or just a map with the
  keys `length` and `_id`. In case of the map the function tries to stream the chunks
  described by the `length` and the `_id` values.

  Returns a Stream.
  """
  @spec open_download_stream(Bucket.t(), String.t() | BSON.ObjectId.t() | map()) :: result
  def open_download_stream(%Bucket{topology_pid: topology_pid, opts: opts} = bucket, file_id)
      when is_binary(file_id) do
    topology_pid
    |> Mongo.find_one(
      Bucket.files_collection_name(bucket),
      %{"_id" => ObjectId.decode!(file_id)},
      opts
    )
    |> stream_chunk(bucket)
  end

  def open_download_stream(
        %Bucket{topology_pid: topology_pid, opts: opts} = bucket,
        %BSON.ObjectId{} = oid
      ) do
    topology_pid
    |> Mongo.find_one(Bucket.files_collection_name(bucket), %{"_id" => oid}, opts)
    |> stream_chunk(bucket)
  end

  def open_download_stream(bucket, %{"length" => _, "_id" => _} = file) do
    stream_chunk(file, bucket)
  end

  @doc """
  Same as above, but returns also the file document.
  """
  @spec find_and_stream(Bucket.t(), String.t()) :: {result, BSON.document()}
  def find_and_stream(bucket, file_id)

  def find_and_stream(%Bucket{topology_pid: topology_pid, opts: opts} = bucket, file_id)
      when is_binary(file_id) do
    file =
      Mongo.find_one(
        topology_pid,
        Bucket.files_collection_name(bucket),
        %{"_id" => ObjectId.decode!(file_id)},
        opts
      )

    {stream_chunk(file, bucket), file}
  end

  def find_and_stream(%Bucket{topology_pid: topology_pid, opts: opts} = bucket, file_id) do
    file =
      Mongo.find_one(
        topology_pid,
        Bucket.files_collection_name(bucket),
        %{"_id" => file_id},
        opts
      )

    {stream_chunk(file, bucket), file}
  end

  def find_one_file(%Bucket{topology_pid: topology_pid, opts: opts} = bucket, filename)
      when is_binary(filename) do
    Mongo.find_one(
      topology_pid,
      Bucket.files_collection_name(bucket),
      %{"filename" => filename},
      opts
    )
  end

  def find_one_file(
        %Bucket{topology_pid: topology_pid, opts: opts} = bucket,
        %BSON.ObjectId{} = file_id
      ) do
    Mongo.find_one(topology_pid, Bucket.files_collection_name(bucket), %{"_id" => file_id}, opts)
  end

  ##
  # In case that the file map is nil we return :error
  #
  defp stream_chunk(nil, _bucket), do: {:error, :not_found}

  ##
  # However, when downloading a zero length stored file the driver MUST NOT issue a query against the chunks
  # collection, since that query is not necessary. For a zero length file, drivers return either an empty
  # stream or send nothing to the provided stream (depending on the download method).
  ##
  defp stream_chunk(%{"length" => 0}, _bucket), do: {:error, :length_is_zero}

  ##
  # Streaming the chunks with `file_id` sorted ascending by n
  #
  defp stream_chunk(%{"_id" => id}, %Bucket{topology_pid: topology_pid, opts: opts} = bucket) do
    opts = Keyword.merge(opts, sort: [n: 1])

    stream =
      topology_pid
      |> Mongo.find(Bucket.chunks_collection_name(bucket), %{files_id: id}, opts)
      |> Stream.map(fn map -> map["data"].binary end)

    {:ok, stream}
  end

  ##
  # catch up for other cases
  #
  defp stream_chunk(_, _bucket), do: {:error, :unknown}
end
