lib/huggingface_client/hub/repos.ex

defmodule HuggingfaceClient.Hub.Repos do
  @moduledoc """
  Repository management on the HuggingFace Hub.

  Covers creating, updating, and deleting repos, branch/tag operations,
  and individual file downloads.

  ## Usage

      # Create a new model repository
      {:ok, repo} = HuggingfaceClient.Hub.Repos.create("my-model",
        repo_type:    :model,
        private:      false,
        access_token: token
      )

      # Download a single file
      {:ok, content} = HuggingfaceClient.Hub.Repos.download_file(
        "gpt2", "config.json",
        access_token: token
      )
  """

  alias HuggingfaceClient.Error.InputError
  alias HuggingfaceClient.Error.HubApiError
  alias HuggingfaceClient.Hub.Client

  # ── Create / Delete ───────────────────────────────────────────────────────────

  @doc """
  Creates a new repository on the Hub.

  ## Required arguments

  - `repo_id` — `"namespace/name"` or just `"name"` (owner inferred from token)

  ## Optional options

  - `:repo_type` — `:model` (default), `:dataset`, `:space`
  - `:private`   — `false` (default)
  - `:exist_ok`  — if `true`, return `:ok` on 409 Conflict (default `false`)
  - `:access_token`
  """
  @spec create(String.t(), keyword()) :: {:ok, map()} | {:error, Exception.t()}
  def create(repo_id, opts \\ []) do
    type = opts[:repo_type] || :model
    private = opts[:private] || false
    exist_ok = opts[:exist_ok] || false

    payload = %{
      "name" => repo_id,
      "type" => type_string(type),
      "private" => private
    }

    case Client.post("/api/repos/create", payload, opts) do
      {:error, %{http_response: %{status: 409}}} when exist_ok ->
        {:ok, %{"id" => repo_id, "type" => type_string(type)}}

      other ->
        other
    end
  end

  @doc """
  Deletes a repository. Irreversible.

  ## Options

  - `:repo_type`, `:access_token`
  """
  @spec delete(String.t(), keyword()) :: :ok | {:error, Exception.t()}
  def delete(repo_id, opts \\ []) do
    type = opts[:repo_type] || :model
    payload = %{"name" => repo_id, "type" => type_string(type)}
    Client.post("/api/repos/delete", payload, Keyword.put(opts, :method, :delete))
  end

  @doc """
  Updates repository metadata (visibility, description, etc.).
  """
  @spec update(String.t(), keyword()) :: {:ok, map()} | {:error, Exception.t()}
  def update(repo_id, opts \\ []) do
    type = opts[:repo_type] || :model
    prefix = type_prefix(type)

    payload =
      %{}
      |> maybe_put("private", opts[:private])
      |> maybe_put("description", opts[:description])
      |> maybe_put("gated", opts[:gated])

    Client.put("/api/#{prefix}/#{repo_id}/settings", payload, opts)
  end

  # ── Branch management ─────────────────────────────────────────────────────────

  @doc """
  Creates a new branch in a repository.

  ## Required arguments

  - `repo_id`     — full repo id (e.g. `"my-org/my-model"`)
  - `branch_name` — name for the new branch

  ## Options

  - `:starting_point` — commit SHA or existing branch (default: `"main"`)
  - `:repo_type`, `:access_token`
  """
  @spec create_branch(String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, Exception.t()}
  def create_branch(repo_id, branch_name, opts \\ [])

  def create_branch(repo_id, branch_name, opts) when is_binary(repo_id) and is_binary(branch_name) do
    type = opts[:repo_type] || :model
    prefix = type_prefix(type)

    payload = %{
      "branch" => branch_name,
      "startingPoint" => opts[:starting_point] || "main"
    }

    Client.post("/api/#{prefix}/#{repo_id}/branch", payload, opts)
  end

  def create_branch(_repo_id, _branch_name, _opts) do
    {:error, InputError.exception("create_branch/3 requires binary repo_id and branch_name")}
  end

  @doc """
  Deletes a branch from a repository.

  ## Required arguments

  - `repo_id`     — full repo id
  - `branch_name` — branch to delete
  """
  @spec delete_branch(String.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()}
  def delete_branch(repo_id, branch_name, opts \\ [])

  def delete_branch(repo_id, branch_name, opts) when is_binary(repo_id) and is_binary(branch_name) do
    type = opts[:repo_type] || :model
    prefix = type_prefix(type)
    Client.delete("/api/#{prefix}/#{repo_id}/branch/#{branch_name}", opts)
  end

  def delete_branch(_repo_id, _branch_name, _opts) do
    {:error, InputError.exception("delete_branch/3 requires binary repo_id and branch_name")}
  end

  @doc """
  Creates a new tag pointing to a commit or branch.
  """
  @spec create_tag(String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, Exception.t()}
  def create_tag(repo_id, tag, opts \\ []) do
    type = opts[:repo_type] || :model
    prefix = type_prefix(type)

    payload = %{
      "tag" => tag,
      "startingPoint" => opts[:starting_point] || "main"
    }

    Client.post("/api/#{prefix}/#{repo_id}/tag", payload, opts)
  end

  @doc "Deletes a tag from a repository."
  @spec delete_tag(String.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()}
  def delete_tag(repo_id, tag, opts \\ []) do
    type = opts[:repo_type] || :model
    prefix = type_prefix(type)
    Client.delete("/api/#{prefix}/#{repo_id}/tag/#{tag}", opts)
  end

  # ── File download ─────────────────────────────────────────────────────────────

  @doc """
  Downloads a single file from a Hub repository.

  Returns `{:ok, binary}` on success.

  ## Required arguments

  - `repo_id`   — e.g. `"gpt2"` or `"meta-llama/Llama-3.1-8B-Instruct"`
  - `file_path` — path inside the repo, e.g. `"config.json"`

  ## Options

  - `:repo_type`  — `:model` (default), `:dataset`, `:space`
  - `:revision`   — branch/tag/commit SHA (default: `"main"`)
  - `:access_token`
  """
  @spec download_file(String.t(), String.t(), keyword()) :: {:ok, binary()} | {:error, Exception.t()}
  def download_file(repo_id, file_path, opts \\ [])

  def download_file(repo_id, file_path, opts) when is_binary(repo_id) and is_binary(file_path) do
    type = opts[:repo_type] || :model
    revision = opts[:revision] || "main"
    prefix = type_prefix(type)

    url =
      "#{HuggingfaceClient.Inference.Config.hub_url()}/#{prefix}/#{repo_id}/resolve/#{revision}/#{file_path}"

    headers = Client.build_headers(nil, nil, opts)
    req_opts = opts[:req_opts] || []

    case Req.get(
           url,
           [finch: HuggingfaceClient.Finch, headers: headers, receive_timeout: 60_000] ++ req_opts
         ) do
      {:ok, %{status: 200, body: body}} ->
        {:ok, body}

      {:ok, %{status: status, body: body}} ->
        {:error,
         %HubApiError{
           message: "download_file failed with HTTP #{status}",
           http_request: %{url: url, method: "GET", headers: headers, body: nil},
           http_response: %{request_id: nil, status: status, body: body}
         }}

      {:error, reason} ->
        {:error,
         %HubApiError{
           message: "download_file network error: #{inspect(reason)}",
           http_request: %{url: url, method: "GET", headers: headers, body: nil},
           http_response: %{request_id: nil, status: nil, body: nil}
         }}
    end
  end

  def download_file(_repo_id, _file_path, _opts) do
    {:error, InputError.exception("download_file/3 requires binary repo_id and file_path")}
  end

  @doc """
  Lists all files in a repository at a given revision.

  Returns `{:ok, [file_map]}` where each map has `"path"`, `"size"`, `"type"`, `"oid"`.
  """
  @spec list_files(String.t(), keyword()) :: {:ok, list()} | {:error, Exception.t()}
  def list_files(repo_id, opts \\ []) do
    type = opts[:repo_type] || :model
    revision = opts[:revision] || "main"
    prefix = type_prefix(type)

    Client.get("/api/#{prefix}/#{repo_id}/tree/#{revision}", opts)
  end

  # ── Commit listing ────────────────────────────────────────────────────────────

  @doc """
  Returns a lazy stream of commits for a repository.
  """
  @spec list_commits(String.t(), keyword()) :: Enumerable.t()
  def list_commits(repo_id, opts \\ []) do
    type = opts[:repo_type] || :model
    prefix = type_prefix(type)

    Client.paginated_stream("/api/#{prefix}/#{repo_id}/commits/main", opts)
    |> Stream.map(&normalise_commit/1)
  end

  # ── Private helpers ───────────────────────────────────────────────────────────

  defp normalise_commit(c) do
    %{
      "oid" => c["id"],
      "title" => c["title"],
      "message" => c["message"],
      "date" => c["date"],
      "authors" =>
        Enum.map(c["authors"] || [], fn a ->
          %{"username" => a["user"], "avatar_url" => a["avatar"]}
        end)
    }
  end

  defp type_string(:model), do: "model"
  defp type_string(:dataset), do: "dataset"
  defp type_string(:space), do: "space"
  defp type_string(s) when is_binary(s), do: s

  defp type_prefix(:model), do: "models"
  defp type_prefix(:dataset), do: "datasets"
  defp type_prefix(:space), do: "spaces"
  defp type_prefix(nil), do: "models"
  defp type_prefix(s) when is_binary(s), do: s

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
end