defmodule TarakanWeb.GitHTTPTest do
  @moduledoc """
  End-to-end smart-HTTP tests driven by a real git client against a real
  Bandit listener serving the full endpoint.
  """

  use Tarakan.DataCase, async: false

  import Tarakan.AccountsFixtures

  alias Tarakan.Accounts
  alias Tarakan.HostedRepositories

  @moduletag :git_client

  setup do
    on_exit(fn ->
      File.rm_rf(Application.fetch_env!(:tarakan, Tarakan.HostedRepositories)[:root])
    end)

    Tarakan.RepositoryCode.Cache.clear()

    port = free_port()

    start_supervised!(
      {Bandit, plug: TarakanWeb.Endpoint, scheme: :http, ip: {127, 0, 0, 1}, port: port}
    )

    account = account_fixture()
    scope = Accounts.scope_for_account(account)
    {:ok, repository} = HostedRepositories.create(scope, %{"name" => "hosted"})

    {:ok, token, _credential} =
      Accounts.ApiCredentials.create(account, %{
        "name" => "git",
        "scopes" => ["repo:read", "repo:write"]
      })

    %{
      port: port,
      account: account,
      repository: repository,
      token: token,
      base_url: "http://127.0.0.1:#{port}"
    }
  end

  defp free_port do
    {:ok, socket} = :gen_tcp.listen(0, ip: {127, 0, 0, 1})
    {:ok, port} = :inet.port(socket)
    :gen_tcp.close(socket)
    port
  end

  defp git_env do
    [
      {"GIT_TERMINAL_PROMPT", "0"},
      {"GIT_CONFIG_GLOBAL", "/dev/null"},
      {"GIT_CONFIG_SYSTEM", "/dev/null"},
      {"GIT_AUTHOR_NAME", "t"},
      {"GIT_AUTHOR_EMAIL", "t@example.com"},
      {"GIT_COMMITTER_NAME", "t"},
      {"GIT_COMMITTER_EMAIL", "t@example.com"}
    ]
  end

  defp git(args, opts \\ []) do
    System.cmd("git", args, [stderr_to_stdout: true, env: git_env()] ++ opts)
  end

  defp clone_url(%{base_url: base_url, account: account}, name),
    do: "#{base_url}/#{account.handle}/#{name}.git"

  defp authed_url(%{token: token} = context, name) do
    %URI{URI.parse(clone_url(context, name)) | userinfo: "git:#{token}"} |> URI.to_string()
  end

  defp seed_local_repository(tmp_dir) do
    work = Path.join(tmp_dir, "seed")
    File.mkdir_p!(work)
    {_out, 0} = git(["init", "--quiet", "-b", "main", work])
    File.write!(Path.join(work, "README.md"), "# Pushed\n")
    {_out, 0} = git(["-C", work, "add", "."])
    {_out, 0} = git(["-C", work, "commit", "--quiet", "-m", "seed"])
    work
  end

  defp list_repository(repository) do
    repository
    |> Ecto.Changeset.change(listing_status: "listed")
    |> Repo.update!()
  end

  describe "push over HTTP" do
    @tag :tmp_dir
    test "steward pushes with a repo:write credential", context do
      %{repository: repository, tmp_dir: tmp_dir} = context
      work = seed_local_repository(tmp_dir)

      {output, status} =
        git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])

      assert status == 0, output

      repository = Repo.reload!(repository)
      assert repository.default_branch == "main"
      assert %DateTime{} = repository.pushed_at
      assert repository.disk_size_bytes > 0

      assert %{action: "repository_pushed"} =
               Repo.get_by(Tarakan.Audit.Event, action: "repository_pushed")
    end

    @tag :tmp_dir
    test "push is denied without credentials, with read-only scope, and for non-stewards",
         context do
      %{tmp_dir: tmp_dir, account: account} = context
      work = seed_local_repository(tmp_dir)

      # Anonymous: the server answers 401 so git asks for credentials, and
      # with prompts disabled the push dies without touching the repository.
      {:ok, {{_http, 401, _reason}, _headers, _body}} =
        :httpc.request(
          :get,
          {~c"#{clone_url(context, "hosted")}/info/refs?service=git-receive-pack", []},
          [autoredirect: false],
          []
        )

      {output, status} =
        git(["-C", work, "push", "--quiet", clone_url(context, "hosted"), "main"])

      assert status != 0
      assert output =~ "Authentication" or output =~ "401" or output =~ "prompts disabled"

      # Authenticated but read-only scope.
      {:ok, read_token, _credential} =
        Accounts.ApiCredentials.create(account, %{
          "name" => "read-only",
          "scopes" => ["repo:read"]
        })

      read_url =
        %URI{URI.parse(clone_url(context, "hosted")) | userinfo: "git:#{read_token}"}
        |> URI.to_string()

      {output, status} = git(["-C", work, "push", "--quiet", read_url, "main"])
      assert status != 0
      assert output =~ "404" or output =~ "not found"

      # A different account with a valid repo:write credential but no
      # steward membership.
      other = account_fixture()

      {:ok, other_token, _credential} =
        Accounts.ApiCredentials.create(other, %{
          "name" => "other",
          "scopes" => ["repo:write"]
        })

      other_url =
        %URI{URI.parse(clone_url(context, "hosted")) | userinfo: "git:#{other_token}"}
        |> URI.to_string()

      {output, status} = git(["-C", work, "push", "--quiet", other_url, "main"])
      assert status != 0
      assert output =~ "404" or output =~ "not found"

      assert Repo.reload!(context.repository).pushed_at == nil
    end
  end

  describe "clone over HTTP" do
    @tag :tmp_dir
    test "anonymous clone of a listed repository round-trips content", context do
      %{repository: repository, tmp_dir: tmp_dir} = context
      work = seed_local_repository(tmp_dir)

      {_output, 0} =
        git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])

      list_repository(repository)

      dest = Path.join(tmp_dir, "clone")
      {output, status} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])
      assert status == 0, output
      assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"
    end

    @tag :tmp_dir
    test "anonymous clone succeeds at creation and is refused after quarantine", context do
      %{repository: repository, tmp_dir: tmp_dir} = context
      work = seed_local_repository(tmp_dir)

      {_output, 0} =
        git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])

      # Hosted repositories are listed at creation, so an anonymous clone
      # works without any moderation step.
      dest = Path.join(tmp_dir, "clone")
      {output, status} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])
      assert status == 0, output
      assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"

      # A moderator quarantine is the only thing that hides the repository.
      repository
      |> Tarakan.Repositories.Repository.listing_changeset(%{listing_status: "quarantined"})
      |> Repo.update!()

      {:ok, {{_http, 401, _reason}, _headers, _body}} =
        :httpc.request(
          :get,
          {~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack", []},
          [autoredirect: false],
          []
        )

      quarantined_dest = Path.join(tmp_dir, "clone-quarantined")

      {output, status} =
        git(["clone", "--quiet", clone_url(context, "hosted"), quarantined_dest])

      assert status != 0
      assert output =~ "Authentication" or output =~ "401" or output =~ "prompts disabled"
    end

    @tag :tmp_dir
    test "the owner clones a repository with a credential", context do
      %{tmp_dir: tmp_dir} = context
      work = seed_local_repository(tmp_dir)

      {_output, 0} =
        git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])

      dest = Path.join(tmp_dir, "clone")
      {output, status} = git(["clone", "--quiet", authed_url(context, "hosted"), dest])
      assert status == 0, output
      assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"
    end

    @tag :tmp_dir
    test "fetch after a new push exercises negotiation", context do
      %{repository: repository, tmp_dir: tmp_dir} = context
      work = seed_local_repository(tmp_dir)

      {_output, 0} =
        git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])

      list_repository(repository)

      dest = Path.join(tmp_dir, "clone")
      {_output, 0} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])

      File.write!(Path.join(work, "second.txt"), "more\n")
      {_out, 0} = git(["-C", work, "add", "."])
      {_out, 0} = git(["-C", work, "commit", "--quiet", "-m", "second"])

      {_output, 0} =
        git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])

      {output, status} = git(["-C", dest, "pull", "--quiet", "origin", "main"])
      assert status == 0, output
      assert File.read!(Path.join(dest, "second.txt")) == "more\n"
    end

    test "unknown repositories 404 without an existence oracle", context do
      %{base_url: base_url} = context

      {:ok, {{_http, 404, _reason}, _headers, _body}} =
        :httpc.request(
          :get,
          {~c"#{base_url}/nobody/nothing.git/info/refs?service=git-upload-pack", []},
          [],
          []
        )
    end

    test "non-git routes still reach Phoenix", context do
      {:ok, {{_http, 200, _reason}, _headers, body}} =
        :httpc.request(:get, {~c"#{context.base_url}/", []}, [], [])

      assert to_string(body) =~ "<html"
    end
  end

  describe "protocol details" do
    @tag :tmp_dir
    test "info/refs advertisement uses the smart content type", context do
      %{repository: repository} = context
      list_repository(repository)

      url =
        ~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack"

      {:ok, {{_http, 200, _reason}, headers, body}} = :httpc.request(:get, {url, []}, [], [])

      content_type =
        headers
        |> Enum.find_value(fn
          {~c"content-type", value} -> to_string(value)
          _other -> nil
        end)

      assert content_type == "application/x-git-upload-pack-advertisement"
      assert to_string(body) =~ "# service=git-upload-pack"
      assert String.starts_with?(to_string(body), "001e# service=git-upload-pack\n0000")
    end

    @tag :tmp_dir
    test "a truncated RPC body cannot wedge the subprocess", context do
      %{repository: repository, tmp_dir: tmp_dir} = context
      work = seed_local_repository(tmp_dir)

      {_output, 0} =
        git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])

      list_repository(repository)

      # A syntactically incomplete upload-pack request: git waits for the
      # rest of the negotiation, and only the wall-clock deadline ends it.
      original = Application.get_env(:tarakan, TarakanWeb.GitHTTP.Service, [])

      Application.put_env(
        :tarakan,
        TarakanWeb.GitHTTP.Service,
        Keyword.put(original, :upload_pack_timeout_ms, 300)
      )

      on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP.Service, original) end)

      truncated = "00a4want "

      {time_us, {:ok, {{_http, 200, _reason}, _headers, _body}}} =
        :timer.tc(fn ->
          :httpc.request(
            :post,
            {~c"#{clone_url(context, "hosted")}/git-upload-pack",
             [{~c"content-type", ~c"application/x-git-upload-pack-request"}],
             ~c"application/x-git-upload-pack-request", truncated},
            [timeout: 5_000],
            []
          )
        end)

      # Bounded by the configured deadline, not the httpc timeout.
      assert div(time_us, 1000) < 4_000
    end

    test "a gzip bomb is refused without inflating it", context do
      %{repository: repository} = context
      list_repository(repository)

      original = Application.get_env(:tarakan, TarakanWeb.GitHTTP.Service, [])

      # A small cap makes the limit trip deterministically, before git has a
      # chance to reject the garbage on its own and end the request first.
      Application.put_env(
        :tarakan,
        TarakanWeb.GitHTTP.Service,
        Keyword.put(original, :max_upload_pack_request_bytes, 64 * 1024)
      )

      on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP.Service, original) end)

      # ~64MB of compressible input in well under a megabyte on the wire.
      zstream = :zlib.open()
      :ok = :zlib.deflateInit(zstream, 9, :deflated, 31, 8, :default)

      bomb =
        zstream
        |> :zlib.deflate(:binary.copy("A", 64 * 1024 * 1024), :finish)
        |> IO.iodata_to_binary()

      :ok = :zlib.deflateEnd(zstream)
      :ok = :zlib.close(zstream)

      assert byte_size(bomb) < 1_000_000, "the bomb must be small on the wire to be a bomb"

      memory_before = :erlang.memory(:total)

      {:ok, {{_http, status, _reason}, _headers, _body}} =
        :httpc.request(
          :post,
          {~c"#{clone_url(context, "hosted")}/git-upload-pack",
           [
             {~c"content-type", ~c"application/x-git-upload-pack-request"},
             {~c"content-encoding", ~c"gzip"}
           ], ~c"application/x-git-upload-pack-request", bomb},
          [timeout: 30_000],
          []
        )

      assert status == 413

      # The cap is enforced during expansion, so the whole 64MB is never
      # materialized. Allow generous headroom for unrelated test allocation.
      growth = :erlang.memory(:total) - memory_before
      assert growth < 32 * 1024 * 1024, "inflated #{div(growth, 1024 * 1024)}MB before refusing"
    end

    test "a well-formed gzip request body is still accepted", context do
      %{repository: repository} = context
      list_repository(repository)

      zstream = :zlib.open()
      :ok = :zlib.deflateInit(zstream, 9, :deflated, 31, 8, :default)

      # A truncated but legitimately compressed negotiation body: the point is
      # that gzip decoding still works, not that git likes the contents.
      body = zstream |> :zlib.deflate("0000", :finish) |> IO.iodata_to_binary()
      :ok = :zlib.deflateEnd(zstream)
      :ok = :zlib.close(zstream)

      {:ok, {{_http, status, _reason}, _headers, _body}} =
        :httpc.request(
          :post,
          {~c"#{clone_url(context, "hosted")}/git-upload-pack",
           [
             {~c"content-type", ~c"application/x-git-upload-pack-request"},
             {~c"content-encoding", ~c"gzip"}
           ], ~c"application/x-git-upload-pack-request", body},
          [timeout: 10_000],
          []
        )

      assert status == 200
    end

    test "a corrupt gzip body is rejected as a bad request", context do
      %{repository: repository} = context
      list_repository(repository)

      {:ok, {{_http, status, _reason}, _headers, _body}} =
        :httpc.request(
          :post,
          {~c"#{clone_url(context, "hosted")}/git-upload-pack",
           [
             {~c"content-type", ~c"application/x-git-upload-pack-request"},
             {~c"content-encoding", ~c"gzip"}
           ], ~c"application/x-git-upload-pack-request", "not actually gzip at all"},
          [timeout: 10_000],
          []
        )

      assert status == 400
    end

    test "dumb-protocol requests are refused", context do
      %{repository: repository} = context
      list_repository(repository)

      {:ok, {{_http, 400, _reason}, _headers, _body}} =
        :httpc.request(
          :get,
          {~c"#{clone_url(context, "hosted")}/info/refs", []},
          [],
          []
        )
    end
  end

  describe "rate limits" do
    test "failed basic authentications are throttled to 429 per client IP", context do
      original = Application.get_env(:tarakan, TarakanWeb.GitHTTP, [])

      Application.put_env(
        :tarakan,
        TarakanWeb.GitHTTP,
        Keyword.put(original, :auth_failure_rate_limit, {2, 60})
      )

      on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP, original) end)

      authorization = ~c"Basic " ++ String.to_charlist(Base.encode64("git:bad-token"))
      url = ~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack"

      request = fn ->
        :httpc.request(
          :get,
          {url, [{~c"authorization", authorization}]},
          [autoredirect: false],
          []
        )
      end

      {:ok, {{_http, 401, _reason}, _headers, _body}} = request.()
      {:ok, {{_http, 401, _reason}, _headers, _body}} = request.()
      {:ok, {{_http, 429, _reason}, _headers, _body}} = request.()
    end

    test "info/refs answers 503 when the git subprocess cap is exhausted", context do
      %{repository: repository} = context
      list_repository(repository)

      checked_out =
        Stream.repeatedly(fn -> Tarakan.Git.Concurrency.checkout() end)
        |> Enum.take_while(&(&1 == :ok))

      on_exit(fn -> for _ <- checked_out, do: Tarakan.Git.Concurrency.checkin() end)

      {:ok, {{_http, 503, _reason}, headers, body}} =
        :httpc.request(
          :get,
          {~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack", []},
          [autoredirect: false],
          []
        )

      assert Enum.find(headers, fn {name, _value} -> name == ~c"retry-after" end)
      assert to_string(body) =~ "busy"
    end
  end
end
