# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT

defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
  @moduledoc """
  Set the hash based on the password input.

  Uses the configured `AshAuthentication.HashProvider` to generate a hash of the
  user's password input and store it in the changeset.

  You can use this change in your actions where you want to change the user's
  password.  If you're not using one of the actions generated by the password
  strategy then you'll need to manually pass the strategy name in the changeset
  context.  Eg:

  ```elixir
  Changeset.new(user, %{})
  |> Changeset.set_context(%{strategy_name: :password})
  |> Changeset.for_update(:update, params)
  |> Accounts.update()
  ```

  or by adding it statically to your action definition:

  ```elixir
  update :change_password do
    change set_context(%{strategy_name: :password})
    change AshAuthentication.Strategy.Password.HashPasswordChange
  end
  ```

  or by adding it as an option to the change definition:

  ```elixir
  update :change_password do
    change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
  end
  ```
  """

  use Ash.Resource.Change
  alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
  alias AshAuthentication.Info

  @doc false
  @impl true
  @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
  def change(changeset, options, context) do
    Changeset.before_action(changeset, fn changeset ->
      hash_password_in_changeset(changeset, context, options)
    end)
  end

  @impl true
  def atomic(changeset, options, context) do
    with {:ok, strategy} <- Info.find_strategy(changeset, context, options),
         value when is_binary(value) <-
           Changeset.get_argument(changeset, strategy.password_field) do
      {:atomic, changeset,
       %{
         strategy.hashed_password_field =>
           {:atomic, expr(lazy({__MODULE__, :hash_or_raise, [strategy.hash_provider, value]}))}
       }}
    else
      _ ->
        changeset
    end
  end

  @doc false
  def hash_or_raise(hash_provider, value) do
    case hash_provider.hash(value) do
      {:ok, hash} ->
        hash

      :error ->
        raise AssumptionFailed, message: "Error hashing password."
    end
  end

  defp hash_password_in_changeset(changeset, context, options) do
    case Info.find_strategy(changeset, context, options) do
      {:ok, strategy} -> apply_password_hashing(changeset, strategy)
      :error -> raise_strategy_not_found_error(changeset)
    end
  end

  defp apply_password_hashing(changeset, strategy) do
    value = Changeset.get_argument(changeset, strategy.password_field)

    if is_binary(value) do
      hash_and_update_changeset(changeset, strategy, value)
    else
      changeset
    end
  end

  defp hash_and_update_changeset(changeset, strategy, value) do
    case strategy.hash_provider.hash(value) do
      {:ok, hash} ->
        Changeset.force_change_attribute(changeset, strategy.hashed_password_field, hash)

      :error ->
        raise AssumptionFailed,
          message: "Error hashing password with #{inspect(strategy.hash_provider)}."
    end
  end

  defp raise_strategy_not_found_error(changeset) do
    raise AssumptionFailed,
      message: """
      Strategy not found for action #{changeset.action.name}.

      Add strategy context to your action:
      change set_context(%{strategy_name: :password})

      Or set it on the changeset:
      changeset |> Ash.Changeset.set_context(%{strategy_name: :password})
      """
  end
end
