defmodule Towerops.ApiTokens do
  @moduledoc """
  Context for managing API tokens.

  API tokens provide programmatic access to the Towerops API and are scoped
  to a specific organization.
  """

  import Ecto.Query, warn: false

  alias Ecto.Adapters.SQL.Sandbox
  alias Towerops.ApiTokens.ApiToken
  alias Towerops.Repo

  @doc """
  Creates an API token for an organization.

  Returns {:ok, {api_token, raw_token}} where raw_token is the plain text
  token that should be shown to the user once.

  ## Examples

      iex> create_api_token(%{organization_id: org_id, name: "Production API"})
      {:ok, {%ApiToken{}, "towerops_..."}}

      iex> create_api_token(%{name: ""})
      {:error, %Ecto.Changeset{}}
  """
  @spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()}
  def create_api_token(attrs \\ %{}) do
    # Generate a random token
    raw_token = generate_token()
    token_hash = hash_token(raw_token)

    attrs = Map.put(attrs, :token_hash, token_hash)

    case %ApiToken{}
         |> ApiToken.changeset(attrs)
         |> Repo.insert() do
      {:ok, api_token} ->
        {:ok, {api_token, raw_token}}

      {:error, changeset} ->
        {:error, changeset}
    end
  end

  @doc """
  Lists all API tokens for an organization.

  ## Examples

      iex> list_organization_api_tokens(org_id)
      [%ApiToken{}, ...]
  """
  @spec list_organization_api_tokens(binary()) :: [ApiToken.t()]
  def list_organization_api_tokens(organization_id) do
    ApiToken
    |> where([t], t.organization_id == ^organization_id)
    |> order_by([t], desc: t.inserted_at)
    |> Repo.all()
  end

  @doc """
  Lists all API tokens created by a user across all organizations.

  ## Examples

      iex> list_user_api_tokens(user_id)
      [%ApiToken{}, ...]
  """
  @spec list_user_api_tokens(binary()) :: [ApiToken.t()]
  def list_user_api_tokens(user_id) do
    ApiToken
    |> where([t], t.user_id == ^user_id)
    |> order_by([t], desc: t.inserted_at)
    |> preload(:organization)
    |> Repo.all()
  end

  @doc """
  Gets a single API token by ID.

  ## Examples

      iex> get_api_token!(id)
      %ApiToken{}

      iex> get_api_token!("nonexistent")
      ** (Ecto.NoResultsError)
  """
  @spec get_api_token!(binary()) :: ApiToken.t()
  def get_api_token!(id), do: Repo.get!(ApiToken, id)

  @doc """
  Verifies an API token and returns the associated organization ID and user.

  Also updates the last_used_at timestamp.

  Returns {:ok, organization_id, user} if valid, {:error, :invalid_token} otherwise.

  ## Examples

      iex> verify_token("valid_token")
      {:ok, organization_id, %User{}}

      iex> verify_token("invalid")
      {:error, :invalid_token}
  """
  @spec verify_token(String.t()) ::
          {:ok, binary(), Towerops.Accounts.User.t() | nil} | {:error, :invalid_token}
  def verify_token(raw_token) do
    token_hash = hash_token(raw_token)

    case Repo.get_by(ApiToken, token_hash: token_hash) do
      nil ->
        {:error, :invalid_token}

      token ->
        # Check if token is expired
        if token.expires_at && DateTime.before?(token.expires_at, DateTime.utc_now()) do
          {:error, :invalid_token}
        else
          # Update last_used_at
          update_last_used(token)

          # Load user if present
          token = Repo.preload(token, :user)
          {:ok, token.organization_id, token.user}
        end
    end
  end

  @doc """
  Deletes an API token.

  ## Examples

      iex> delete_api_token(api_token)
      {:ok, %ApiToken{}}

      iex> delete_api_token(api_token)
      {:error, %Ecto.Changeset{}}
  """
  @spec delete_api_token(ApiToken.t()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
  def delete_api_token(%ApiToken{} = api_token) do
    Repo.delete(api_token)
  end

  @doc """
  Updates an API token (name, expiration).

  ## Examples

      iex> update_api_token(api_token, %{name: "New Name"})
      {:ok, %ApiToken{}}

      iex> update_api_token(api_token, %{name: nil})
      {:error, %Ecto.Changeset{}}
  """
  @spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
  def update_api_token(%ApiToken{} = api_token, attrs) do
    api_token
    |> ApiToken.changeset(attrs)
    |> Repo.update()
  end

  # Private functions

  @spec generate_token() :: String.t()
  defp generate_token do
    # Generate a 32-byte random token and encode it as base64
    # Prefix with "towerops_" for easy identification
    random_bytes = :crypto.strong_rand_bytes(32)
    "towerops_" <> Base.url_encode64(random_bytes, padding: false)
  end

  @spec hash_token(String.t()) :: String.t()
  defp hash_token(token) do
    :sha256
    |> :crypto.hash(token)
    |> Base.encode16(case: :lower)
  end

  @spec update_last_used(ApiToken.t()) :: :ok
  defp update_last_used(token) do
    timestamp = DateTime.truncate(DateTime.utc_now(), :second)

    # In test mode, update synchronously to avoid ownership issues
    # In production, use Task.start to avoid blocking the request
    case Application.get_env(:towerops, :env) do
      :test ->
        update_token_timestamp(token, timestamp)

      _ ->
        spawn_async_update(token, timestamp)
    end
  end

  @spec update_token_timestamp(ApiToken.t(), DateTime.t()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
  defp update_token_timestamp(token, timestamp) do
    token
    |> Ecto.Changeset.change(last_used_at: timestamp)
    |> Repo.update()
  end

  @spec spawn_async_update(ApiToken.t(), DateTime.t()) :: :ok
  defp spawn_async_update(token, timestamp) do
    parent = self()

    _ =
      Task.start(fn ->
        _ = maybe_allow_sandbox(parent)
        update_token_timestamp(token, timestamp)
      end)
  end

  defp maybe_allow_sandbox(parent) do
    if Application.get_env(:towerops, :sql_sandbox) do
      Sandbox.allow(Repo, parent, self())
    end
  end
end
