defmodule TarakanWeb.GitHTTP do
  @moduledoc """
  Git smart-HTTP endpoint for Tarakan-hosted repositories.

  Serves `https://…/:owner/:name.git` - `info/refs` advertisements plus the
  `git-upload-pack` (clone/fetch) and `git-receive-pack` (push) RPCs. Mounted
  in the endpoint ahead of `Plug.Parsers` so request bodies stream straight
  to the git subprocess instead of being buffered and size-capped as form
  input; every other request passes through to the router untouched.

  Authentication is HTTP Basic with a scoped API credential as the password;
  the username is ignored. Anonymous clients may clone listed repositories.
  Authorization always goes through `Tarakan.Policy` (`:clone_repository`,
  `:push_repository`). Failures follow the platform's anti-oracle rule: an
  unauthenticated client gets a 401 challenge so git prompts for
  credentials; an authenticated-but-unauthorized client gets the same 404 as
  a repository that does not exist.

  Rate limits: anonymous traffic buckets per client IP (IPv6 truncated to
  its /64 prefix), authenticated traffic per account, and failed Basic
  authentications per client IP against a smaller budget whose overflow is
  answered with 429 rather than another 401 challenge.
  """

  @behaviour Plug

  import Plug.Conn

  alias Tarakan.Accounts
  alias Tarakan.HostedRepositories
  alias Tarakan.Policy
  alias Tarakan.RateLimiter
  alias Tarakan.Repositories.Repository
  alias TarakanWeb.GitHTTP.Service
  alias TarakanWeb.Plugs.ClientIp

  @services ~w(git-upload-pack git-receive-pack)

  @impl true
  def init(opts), do: opts

  @impl true
  def call(%Plug.Conn{path_info: [owner, name_dot_git | rest]} = conn, _opts) do
    case repository_name(name_dot_git) do
      {:ok, name} -> dispatch(conn, owner, name, rest)
      :error -> conn
    end
  end

  def call(conn, _opts), do: conn

  defp repository_name(segment) do
    case String.split(segment, ".git", parts: 2) do
      [name, ""] when name != "" -> {:ok, name}
      _other -> :error
    end
  end

  defp dispatch(%{method: "GET"} = conn, owner, name, ["info", "refs"]) do
    conn = fetch_query_params(conn)

    case conn.query_params["service"] do
      service when service in @services ->
        serve(conn, owner, name, service, :advertise)

      _missing ->
        # The dumb-HTTP protocol is not served; git falls back here only
        # when it cannot speak smart HTTP.
        halt_plain(conn, 400, "smart HTTP only")
    end
  end

  defp dispatch(%{method: "POST"} = conn, owner, name, [service])
       when service in @services do
    serve(conn, owner, name, service, :rpc)
  end

  defp dispatch(conn, _owner, _name, _rest) do
    halt_plain(conn, 404, "not found")
  end

  defp serve(conn, owner, name, service, mode) do
    with {:ok, conn, scope} <- authenticate(conn),
         :ok <- rate_limit(conn, scope),
         %Repository{} = repository <- HostedRepositories.resolve(owner, name),
         :ok <- authorize(scope, service, repository) do
      :telemetry.execute([:tarakan, :git, :http, :request], %{count: 1}, %{
        service: service,
        mode: mode,
        repository_id: repository.id,
        authenticated: scope != nil
      })

      case mode do
        :advertise -> Service.advertise_refs(conn, repository, service)
        :rpc -> Service.rpc(conn, repository, service, scope)
      end
    else
      {:unauthenticated, conn} ->
        challenge(conn)

      {:error, :rate_limited} ->
        halt_plain(conn, 429, "too many requests")

      # An unauthenticated client that fails authorization is challenged so
      # git retries with credentials; anything else is indistinguishable
      # from a missing repository.
      {:error, :unauthorized, nil} ->
        challenge(conn)

      {:error, :over_quota} ->
        halt_plain(conn, 413, "repository is over its storage quota")

      _denied ->
        halt_plain(conn, 404, "not found")
    end
  end

  defp authorize(scope, "git-upload-pack", repository) do
    case Policy.authorize(scope, :clone_repository, repository) do
      :ok -> :ok
      {:error, :unauthorized} -> {:error, :unauthorized, scope}
    end
  end

  # Authorization strictly precedes the quota check so an over-quota
  # response can never disclose an invisible repository.
  defp authorize(scope, "git-receive-pack", repository) do
    case Policy.authorize(scope, :push_repository, repository) do
      :ok -> ensure_not_over_quota(repository)
      {:error, :unauthorized} -> {:error, :unauthorized, scope}
    end
  end

  defp ensure_not_over_quota(repository) do
    if HostedRepositories.over_quota?(repository), do: {:error, :over_quota}, else: :ok
  end

  # Basic auth with the API credential as password. A present-but-invalid
  # credential is challenged rather than downgraded to anonymous. Failed
  # attempts count against a small per-IP budget, so once an IP exceeds it
  # the answer is a 429 instead of an endless 401 challenge.
  defp authenticate(conn) do
    case Plug.BasicAuth.parse_basic_auth(conn) do
      {_user, password} ->
        case Accounts.ApiCredentials.authenticate(password) do
          {:ok, account, credential} ->
            scope =
              Accounts.scope_for_account(account,
                token_id: credential.id,
                token_scopes: credential.scopes,
                token_repository_id: credential.repository_id,
                authentication_method: :api_credential
              )

            {:ok, conn, scope}

          :error ->
            failed_authentication(conn)
        end

      :error ->
        case get_req_header(conn, "authorization") do
          [] -> {:ok, conn, nil}
          _malformed -> failed_authentication(conn)
        end
    end
  end

  defp failed_authentication(conn) do
    {limit, window} = config(:auth_failure_rate_limit, {10, 60})
    key = {:git_http_auth_ip, ClientIp.remote_ip_bucket(conn)}

    case RateLimiter.check(key, limit, window) do
      :ok -> {:unauthenticated, conn}
      {:error, _reason, _retry_after} -> {:error, :rate_limited}
    end
  end

  defp rate_limit(_conn, %{platform_role: role}) when role in ["admin", "moderator"], do: :ok

  defp rate_limit(_conn, %{account: %{platform_role: role}})
       when role in ["admin", "moderator"],
       do: :ok

  defp rate_limit(conn, scope) do
    {key, limit, window} = rate_limit_bucket(conn, scope)

    case RateLimiter.check(key, limit, window) do
      :ok -> :ok
      {:error, _reason, _retry_after} -> {:error, :rate_limited}
    end
  end

  defp rate_limit_bucket(conn, nil) do
    limits = config(:anonymous_rate_limit, {60, 60})
    {limit, window} = limits
    {{:git_http_ip, ClientIp.remote_ip_bucket(conn)}, limit, window}
  end

  defp rate_limit_bucket(_conn, scope) do
    {limit, window} = config(:account_rate_limit, {240, 60})
    {{:git_http_account, scope.account_id}, limit, window}
  end

  defp challenge(conn) do
    conn
    |> put_resp_header("www-authenticate", ~s(Basic realm="tarakan"))
    |> halt_plain(401, "authentication required")
  end

  defp halt_plain(conn, status, message) do
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(status, message)
    |> halt()
  end

  defp config(key, default) do
    :tarakan
    |> Application.get_env(__MODULE__, [])
    |> Keyword.get(key, default)
  end
end
