defmodule Coherence.Authentication.Token do
  @moduledoc """
    Implements token based authentication. To use add

      plug Coherence.Authentication.Token, source: :params, param: "auth_token"

    or

      plug Coherence.Authentication.Token, source: :session, param: "auth_token"

    or

      plug Coherence.Authentication.Token, source: :header, param: "x-auth-token"

    or

      plug Coherence.Authentication.Token, source: { module, function, ["my_param"]} end

    or

      plug Coherence.Authentication.Token, source: :params_session, param: "auth_token"

    to your pipeline.

  ## Options

    * `source` - where to locate the token
    * `error` - The error message if not authenticated
    * `assigns_key` - The key to user in assigns (:current_uer)
    * `store` - Where to store the token data
  """

  import Plug.Conn
  import Coherence.Authentication.Utils

  require Logger

  @type t :: Ecto.Schema.t() | map()
  @type conn :: Plug.Conn.t()

  @behaviour Plug

  @doc """
    Add the credentials for a `token`. `user_data` can be any term but must not be `nil`.
  """
  @spec add_credentials(String.t(), t, module) :: :ok
  def add_credentials(token, user_data, store \\ Coherence.CredentialStore.Server) do
    store.put_credentials(token, user_data)
  end

  @doc """
    Remove the credentials for a `token`.
  """
  @spec remove_credentials(String.t(), module) :: :ok
  def remove_credentials(token, store \\ Coherence.CredentialStore.Server) do
    store.delete_credentials(token)
  end

  @doc """
    Utility function to generate a random authentication token.
  """
  @spec generate_token() :: String.t()
  def generate_token() do
    16
    |> :crypto.strong_rand_bytes()
    |> Base.url_encode64()
  end

  @spec init(keyword()) :: map()
  def init(opts) do
    param = Keyword.get(opts, :param)

    %{
      source: opts |> Keyword.fetch!(:source) |> convert_source(param),
      error: Keyword.get(opts, :error, "HTTP Authentication Required"),
      assigns_key: Keyword.get(opts, :assigns_key, :current_user),
      store: Keyword.get(opts, :store, Coherence.CredentialStore.Server)
    }
  end

  defp convert_source(:params_session, param),
    do: {__MODULE__, :get_token_from_params_session, [param]}

  defp convert_source(:params, param),
    do: {__MODULE__, :get_token_from_params, [param]}

  defp convert_source(:header, param),
    do: {__MODULE__, :get_token_from_header, [param]}

  defp convert_source(:session, param),
    do: {__MODULE__, :get_token_from_session, [param]}

  defp convert_source(source = {module, fun, args}, _param)
       when is_atom(module) and is_atom(fun) and is_list(args),
       do: source

  @spec get_token_from_params(conn, atom() | binary()) :: {conn, any()}
  def get_token_from_params(conn, param),
    do: {conn, conn.params[param]}

  @spec get_token_from_header(conn, binary()) :: {conn, String.t}
  def get_token_from_header(conn, param),
    do: {conn, get_first_req_header(conn, param)}

  @spec get_token_from_session(conn, atom() | binary()) :: {conn, nil | String.t()}
  def get_token_from_session(conn, param),
    do: {conn, get_session(conn, param)}

  @spec get_token_from_params_session(conn, atom() | binary()) :: {conn, nil | String.t()}
  def get_token_from_params_session(conn, param) do
    conn
    |> get_token_from_params(param)
    |> check_token_from_session(param)
    |> save_token_in_session(param)
  end

  @spec check_token_from_session({conn, nil | String.t()}, atom() | binary()) :: {conn, nil | String.t()}
  def check_token_from_session({conn, nil}, param), do: get_token_from_session(conn, param)
  def check_token_from_session({conn, creds}, _param), do: {conn, creds}

  @spec save_token_in_session({conn, nil | String.t()}, atom() | binary()) :: {conn, nil | String.t()}
  def save_token_in_session({conn, nil}, _), do: {conn, nil}

  def save_token_in_session({conn, creds}, param) do
    conn =
      conn
      |> put_session(param, creds)
      |> put_session(param_key(), param)

    {conn, creds}
  end

  @spec call(conn, keyword()) :: conn
  def call(conn, opts) do
    if get_authenticated_user(conn) do
      conn
    else
      {module, fun, args} = opts[:source]

      module
      |> apply(fun, [conn | args])
      |> verify_creds(opts[:store])
      |> assert_creds(opts[:error])
    end
  end

  defp verify_creds({conn, creds}, store), do: {conn, store.get_user_data(creds)}

  defp assert_creds({conn, nil}, nil), do: conn
  defp assert_creds({conn, nil}, error), do: halt_with_error(conn, error)
  defp assert_creds({conn, user_data}, _), do: assign_user_data(conn, user_data)
end
