defmodule PowAssent.Ecto.UserIdentities.Context do
  @moduledoc """
  Handles pow assent user identity context for user identities.

  ## Usage

  This module will be used by PowAssent by default. If you wish to have control
  over context methods, you can do configure
  `lib/my_project/user_identities/user_identities.ex` the following way:

      defmodule MyApp.UserIdentities do
        use PowAssent.Ecto.UserIdentities.Context,
          repo: MyApp.Repo,
          user: MyApp.Users.User

        def all(user) do
          pow_assent_all(user)
        end
      end

  Remember to update the PowAssent configuration with
  `user_identities_context: MyApp.UserIdentities`.

  The following Pow methods can be accessed:

    * `pow_assent_get_user_by_provider_uid/3`
    * `pow_assent_upsert/2`
    * `pow_assent_create_user/3`
    * `pow_assent_delete/2`
    * `pow_assent_all/1`

  ## Configuration options

    * `:repo` - the ecto repo module (required)
    * `:user` - the user schema module (required)
  """
  alias Ecto.Changeset
  alias Pow.Ecto.Context
  alias PowAssent.Config
  import Ecto.Query

  @type changeset :: map()
  @type user :: map()
  @type user_identity :: map()
  @type user_params :: map()
  @type user_identity_params :: map()
  @type user_id_params :: map()

  @callback get_user_by_provider_uid(binary(), binary()) :: user() | nil
  @callback upsert(user(), user_identity_params()) ::
              {:ok, user_identity()}
              | {:error, {:bound_to_different_user, changeset()}}
              | {:error, changeset()}
  @callback create_user(user_identity_params(), user_params(), user_id_params() | nil) ::
              {:ok, user()}
              | {:error, {:bound_to_different_user | :invalid_user_id_field, changeset()}}
              | {:error, changeset()}
  @callback delete(user(), binary()) ::
              {:ok, {number(), nil}} | {:error, {:no_password, changeset()}}
  @callback all(user()) :: [user_identity()]

  # TODO: Remove by 0.4.0
  @callback create(user(), user_identity_params()) :: any()

  @doc false
  defmacro __using__(config) do
    quote do
      @behaviour unquote(__MODULE__)

      @pow_config unquote(config)

      def get_user_by_provider_uid(provider, uid),
        do: pow_assent_get_user_by_provider_uid(provider, uid)
      def upsert(user, user_identity_params), do: pow_assent_upsert(user, user_identity_params)
      def create_user(user_identity_params, user_params, user_id_params),
        do: pow_assent_create_user(user_identity_params, user_params, user_id_params)
      def delete(user, provider), do: pow_assent_delete(user, provider)
      def all(user), do: pow_assent_all(user)

      def pow_assent_get_user_by_provider_uid(provider, uid) do
        unquote(__MODULE__).get_user_by_provider_uid(provider, uid, @pow_config)
      end

      def pow_assent_upsert(user, user_identity_params) do
        unquote(__MODULE__).upsert(user, user_identity_params, @pow_config)
      end

      def pow_assent_create_user(user_identity_params, user_params, user_id_params) do
        unquote(__MODULE__).create_user(user_identity_params, user_params, user_id_params, @pow_config)
      end

      def pow_assent_delete(user, provider) do
        unquote(__MODULE__).delete(user, provider, @pow_config)
      end

      def pow_assent_all(user) do
        unquote(__MODULE__).all(user, @pow_config)
      end

      # TODO: Remove by 0.4.0
      @deprecated "Please use `upsert/2` instead"
      def create(user, user_identity_params), do: upsert(user, user_identity_params)

      # TODO: Remove by 0.4.0
      @deprecated "Please use `pow_assent_upsert/2` instead"
      defdelegate pow_assent_create(user, user_identity_params), to: __MODULE__, as: :pow_assent_upsert

      defoverridable unquote(__MODULE__)
    end
  end

  @doc """
  Finds a user based on the provider and uid.

  User schema module and repo module will be fetched from the config.
  """
  @spec get_user_by_provider_uid(binary(), binary() | integer(), Config.t()) :: user() | nil
  def get_user_by_provider_uid(provider, uid, config) when is_integer(uid),
    do: get_user_by_provider_uid(provider, Integer.to_string(uid), config)
  def get_user_by_provider_uid(provider, uid, config) do
    opts = repo_opts(config, [:prefix])

    config
    |> user_identity_schema_mod()
    |> where([i], i.provider == ^provider and i.uid == ^uid)
    |> join(:left, [i], i in assoc(i, :user))
    |> select([_, u], u)
    |> repo!(config).one(opts)
  end

  # TODO: Remove by 0.4.0
  @doc false
  @deprecated "Use `upsert/3` instead"
  @spec create(user(), user_identity_params(), Config.t()) :: {:ok, user_identity()} | {:error, {:bound_to_different_user, changeset()}} | {:error, changeset()}
  def create(user, user_identity_params, config), do: upsert(user, user_identity_params, config)

  @doc """
  Upserts a user identity.

  If a matching user identity already exists for the user, the identity will be updated,
  otherwise a new identity is inserted.

  Repo module will be fetched from config.
  """
  @spec upsert(user(), user_identity_params(), Config.t()) :: {:ok, user_identity()} | {:error, {:bound_to_different_user, changeset()}} | {:error, changeset()}
  def upsert(user, user_identity_params, config) do
    params                                   = convert_params(user_identity_params)
    {uid_provider_params, additional_params} = Map.split(params, ["uid", "provider"])

    user
    |> get_for_user(uid_provider_params, config)
    |> case do
      nil      -> insert_identity(user, params, config)
      identity -> update_identity(identity, additional_params, config)
    end
    |> user_identity_bound_different_user_error()
  end

  defp user_identity_bound_different_user_error({:error, %{errors: errors} = changeset}) do
    case unique_constraint_error?(errors, :uid_provider) do
      true  -> {:error, {:bound_to_different_user, changeset}}
      false -> {:error, changeset}
    end
  end
  defp user_identity_bound_different_user_error(any), do: any

  defp convert_params(params) when is_map(params) do
    params
    |> Enum.map(&convert_param/1)
    |> :maps.from_list()
  end

  defp convert_param({:uid, value}), do: convert_param({"uid", value})
  defp convert_param({"uid", value}) when is_integer(value), do: convert_param({"uid", Integer.to_string(value)})
  defp convert_param({key, value}) when is_atom(key), do: {Atom.to_string(key), value}
  defp convert_param({key, value}) when is_binary(key), do: {key, value}

  defp insert_identity(user, user_identity_params, config) do
    user_identity = Ecto.build_assoc(user, :user_identities)

    user_identity
    |> user_identity.__struct__.changeset(user_identity_params)
    |> Context.do_insert(config)
  end

  defp update_identity(user_identity, additional_params, config) do
    user_identity
    |> user_identity.__struct__.changeset(additional_params)
    |> Context.do_update(config)
  end

  defp get_for_user(user, %{"uid" => uid, "provider" => provider}, config) do
    user_identity = Ecto.build_assoc(user, :user_identities).__struct__

    repo!(config).get_by(user_identity, [user_id: user.id, provider: provider, uid: uid], repo_opts(config, [:prefix]))
  end

  @doc """
  Creates a user with user identity.

  User schema module and repo module will be fetched from config.
  """
  @spec create_user(user_identity_params(), user_params(), user_id_params() | nil, Config.t()) :: {:ok, user()} | {:error, {:bound_to_different_user | :invalid_user_id_field, changeset()}} | {:error, changeset()}
  def create_user(user_identity_params, user_params, user_id_params, config) do
    params   = convert_params(user_identity_params)
    user_mod = user!(config)

    user_mod
    |> struct()
    |> user_mod.user_identity_changeset(params, user_params, user_id_params)
    |> Context.do_insert(config)
    |> user_user_identity_bound_different_user_error()
    |> invalid_user_id_error(config)
  end

  defp user_user_identity_bound_different_user_error({:error, %{changes: %{user_identities: [%{errors: errors}]}} = changeset}) do
    case unique_constraint_error?(errors, :uid_provider) do
      true  -> {:error, {:bound_to_different_user, changeset}}
      false -> {:error, changeset}
    end
  end
  defp user_user_identity_bound_different_user_error(any), do: any

  defp unique_constraint_error?(errors, field) do
    Enum.find_value(errors, false, fn
      {^field, {_msg, [constraint: :unique, constraint_name: _name]}} -> true
      _any                                                            -> false
    end)
  end

  defp invalid_user_id_error({:error, %{errors: errors} = changeset}, config) do
    user_mod      = user!(config)
    user_id_field = user_mod.pow_user_id_field()

    Enum.find_value(errors, {:error, changeset}, fn
      {^user_id_field, _error} -> {:error, {:invalid_user_id_field, changeset}}
      _any                     -> false
    end)
  end
  defp invalid_user_id_error(any, _config), do: any

  @doc """
  Deletes a user identity for the provider and user.

  Repo module will be fetched from config.
  """
  @spec delete(user(), binary(), Config.t()) ::
          {:ok, {number(), nil}} | {:error, {:no_password, changeset()}}
  def delete(user, provider, config) do
    user = repo!(config).preload(user, :user_identities, repo_opts(config, [:prefix]) ++ [force: true])

    user.user_identities
    |> Enum.split_with(&(&1.provider == provider))
    |> maybe_delete(user, config)
  end

  defp maybe_delete({user_identities, rest}, %{password_hash: password_hash} = user, config) when length(rest) > 0 or not is_nil(password_hash) do
    opts    = repo_opts(config, [:prefix])
    results =
      user
      |> Ecto.assoc(:user_identities)
      |> where([i], i.id in ^Enum.map(user_identities, &(&1.id)))
      |> repo!(config).delete_all(opts)

    {:ok, results}
  end
  defp maybe_delete(_any, user, _config) do
    changeset =
      user
      |> Changeset.change()
      |> Changeset.validate_required(:password_hash)

    {:error, {:no_password, changeset}}
  end

  @doc """
  Fetches all user identities for user.

  Repo module will be fetched from config.
  """
  @spec all(user(), Config.t()) :: [user_identity()]
  def all(user, config) do
    opts = repo_opts(config, [:prefix])

    user
    |> Ecto.assoc(:user_identities)
    |> repo!(config).all(opts)
  end

  defp user_identity_schema_mod(config) when is_list(config) do
    config
    |> user!()
    |> user_identity_schema_mod()
  end
  defp user_identity_schema_mod(user_mod) when is_atom(user_mod) do
    association = user_mod.__schema__(:association, :user_identities) || raise_no_user_identity_error()

    association.queryable
  end

  @spec raise_no_user_identity_error :: no_return
  defp raise_no_user_identity_error do
    Config.raise_error("The `:user` configuration option doesn't have a `:user_identities` association.")
  end

  defp repo_opts(config, opts) do
    config
    |> Config.get(:repo_opts, [])
    |> Keyword.take(opts)
  end

  defdelegate user!(config), to: Pow.Config
  defdelegate repo!(config), to: Pow.Config
end
