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

defmodule AshAuthentication.AddOn.Confirmation.ConfirmationHookChange do
  @moduledoc """
  Triggers a confirmation flow when one of the monitored fields is changed.

  Optionally inhibits changes to monitored fields on update.

  You can use this change in your actions where you want to send the user a
  confirmation (or inhibit changes after confirmation).  If you're not using one
  of the actions generated by the confirmation add-on then you'll need to
  manually pass the strategy name in the changeset context. Eg:

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

  or by adding it statically to your action definition:

  ```elixir
  update :change_email do
    change set_context(%{strategy_name: :confirm})
    change AshAuthentication.AddOn.Confirmation.ConfirmationHookChange
  end
  ```

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

  ```elixir
  update :change_email do
    change {AshAuthentication.AddOn.Confirmation.ConfirmationHookChange, strategy_name: :confirm}
  end
  ```
  """

  use Ash.Resource.Change
  alias Ash.{Changeset, Error.Changes.InvalidChanges, Query, Resource.Change}
  alias AshAuthentication.{AddOn.Confirmation, Info}
  import Ash.Expr
  require Logger

  @doc false
  @impl true
  @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
  def change(changeset, options, context) do
    case Info.find_strategy(changeset, context, options) do
      {:ok, strategy} ->
        do_change(changeset, strategy, context)

      :error ->
        changeset
    end
  end

  defp do_change(changeset, strategy, context) do
    auto_confirm? = changeset.action.name in strategy.auto_confirm_actions

    changeset =
      if auto_confirm? do
        Changeset.change_attribute(changeset, strategy.confirmed_at_field, DateTime.utc_now())
      else
        changeset
      end

    changeset
    |> handle_upserts(
      strategy,
      auto_confirm?
    )
    |> then(fn changeset ->
      if auto_confirm? do
        changeset
      else
        Ash.Changeset.before_action(changeset, &before_action(&1, strategy, context))
      end
    end)
  end

  @impl true
  def atomic(changeset, opts, context) do
    case Info.find_strategy(changeset, context, opts) do
      {:ok, strategy} ->
        atomic_confirmation(changeset, strategy, context)

      :error ->
        changeset
    end
  end

  defp atomic_confirmation(changeset, strategy, context) do
    auto_confirm? = changeset.action.name in strategy.auto_confirm_actions

    if auto_confirm? do
      {:atomic, %{strategy.confirmed_at_field => expr(now())}}
    else
      action_types =
        if strategy.confirm_on_create? do
          [:create]
        else
          []
        end

      action_types =
        if strategy.confirm_on_update? do
          action_types ++ [:update]
        else
          action_types
        end

      cond do
        changeset.action.type not in action_types ->
          {:ok, changeset}

        changeset.action.name == strategy.confirm_action_name ->
          {:ok, changeset}

        !Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)) ->
          {:ok, changeset}

        identity = cant_atomically_inhibit_updates(changeset.resource, strategy) ->
          {:not_atomic,
           "The confirmation strategy `:#{strategy.name}` cannot inhibit updates atomically, because the identity `#{identity.name}` requires checking in advance if the fields have been taken."}

        true ->
          {:ok,
           maybe_perform_confirmation(changeset, strategy, changeset, context)
           |> validate_identities(strategy, true)}
      end
    end
  end

  defp cant_atomically_inhibit_updates(resource, strategy) do
    strategy.inhibit_updates? &&
      Enum.find(Ash.Resource.Info.identities(resource), fn identity ->
        Enum.any?(strategy.monitor_fields, &(&1 in identity.keys))
      end)
  end

  defp before_action(changeset, strategy, context) do
    changeset
    |> not_confirm_action(strategy)
    |> should_confirm_action_type(strategy)
    |> monitored_field_changing(strategy)
    |> changes_would_be_valid()
    |> maybe_inhibit_updates(strategy)
    |> maybe_perform_confirmation(strategy, changeset, context)
  end

  defp handle_upserts(
         %{action: %{type: :create}, context: %{private: %{upsert?: true}}} = changeset,
         strategy,
         auto_confirm?
       ) do
    changeset
    |> ensure_confirmed_at_is_set(strategy, auto_confirm?)
    |> prevent_hijacking(strategy)
  end

  defp handle_upserts(changeset, _, _), do: changeset

  defp ensure_confirmed_at_is_set(changeset, strategy, auto_confirm?) do
    if (auto_confirm? || changeset.action.name == strategy.confirm_action_name) &&
         changeset.context[:private][:upsert_fields] do
      update_in(
        changeset.context[:private][:upsert_fields],
        &(List.wrap(&1) ++ [strategy.confirmed_at_field])
      )
    else
      changeset
    end
  end

  defp prevent_hijacking(changeset, strategy) do
    if strategy.prevent_hijacking? &&
         Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)) do
      add_hijack_filter(changeset, strategy)
    else
      # User accepted the risk 🤷‍♂️
      changeset
    end
  end

  defp add_hijack_filter(changeset, strategy) do
    if Ash.DataLayer.data_layer_can?(changeset.resource, :expr_error) do
      Ash.Changeset.filter(
        changeset,
        expr(
          if is_nil(^ref(strategy.confirmed_at_field)) do
            error(
              AshAuthentication.Errors.CannotConfirmUnconfirmedUser,
              %{resource: ^changeset.resource, confirmation_strategy: ^strategy.name}
            )
          else
            true
          end
        )
      )
    else
      Ash.Changeset.filter(
        changeset,
        expr(not is_nil(^ref(strategy.confirmed_at_field)))
      )
    end
  end

  defp not_confirm_action(%Changeset{} = changeset, strategy)
       when changeset.action.name != strategy.confirm_action_name,
       do: changeset

  defp not_confirm_action(_changeset, _strategy), do: nil

  defp should_confirm_action_type(%Changeset{} = changeset, strategy)
       when changeset.action_type == :create and strategy.confirm_on_create?,
       do: changeset

  defp should_confirm_action_type(%Changeset{} = changeset, strategy)
       when changeset.action_type == :update and strategy.confirm_on_update?,
       do: changeset

  defp should_confirm_action_type(_changeset, _strategy), do: nil

  defp monitored_field_changing(%Changeset{} = changeset, strategy) do
    if Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)),
      do: changeset,
      else: nil
  end

  defp monitored_field_changing(_changeset, _strategy), do: nil

  defp changes_would_be_valid(%Changeset{} = changeset) when changeset.valid?, do: changeset
  defp changes_would_be_valid(_), do: nil

  defp maybe_inhibit_updates(%Changeset{} = changeset, strategy)
       when changeset.action_type == :update and strategy.inhibit_updates? do
    changeset
    |> validate_identities(strategy)
    |> case do
      {:error, violated_identity} ->
        Ash.Changeset.add_error(
          changeset,
          InvalidChanges.exception(
            fields: violated_identity.keys,
            message: "has already been taken"
          )
        )

      changeset ->
        Enum.reduce(strategy.monitor_fields, changeset, &Changeset.clear_change(&2, &1))
    end
  end

  defp maybe_inhibit_updates(changeset, _strategy), do: changeset

  defp validate_identities(changeset, strategy, after_action? \\ false) do
    identities =
      strategy.monitor_fields
      |> Enum.flat_map(fn field ->
        changeset.resource
        |> Ash.Resource.Info.identities()
        |> Enum.filter(&(field in &1.keys))
      end)

    cond do
      identities == [] ->
        changeset

      after_action? ->
        do_validate_identities_after_action(identities, changeset)

      true ->
        do_validate_identities(identities, changeset)
    end
  end

  defp do_validate_identities_after_action(identities, changeset) do
    Ash.Changeset.after_action(changeset, fn _changeset, result ->
      case do_validate_identities(identities, changeset) do
        {:error, violated_identity} ->
          InvalidChanges.exception(
            fields: violated_identity.keys,
            message: "has already been taken"
          )

        _ ->
          {:ok, result}
      end
    end)
  end

  defp do_validate_identities(identities, changeset) do
    Enum.reduce_while(identities, changeset, fn identity, changeset ->
      non_attrs =
        Enum.reject(identity.keys, &Ash.Resource.Info.attribute(changeset.resource, &1))

      changeset = %{changeset | data: Ash.load!(changeset.data, non_attrs, lazy?: true)}

      filter =
        Enum.map(identity.keys, &{&1, Ash.Changeset.get_attribute(changeset, &1)})

      has_nil? =
        Enum.any?(filter, fn {_k, v} -> is_nil(v) end)

      if (identity.nils_distinct? && has_nil?) ||
           !exists?(changeset, filter) do
        {:cont, changeset}
      else
        {:halt, {:error, identity}}
      end
    end)
  end

  defp exists?(changeset, filter) do
    changeset.resource
    |> Query.do_filter(filter)
    |> Query.set_context(%{
      private: %{
        ash_authentication?: true
      }
    })
    |> Ash.exists?(tenant: changeset.tenant)
  end

  defp maybe_perform_confirmation(%Changeset{} = changeset, strategy, original_changeset, context) do
    changeset
    |> nil_confirmed_at_field(strategy)
    |> Changeset.after_action(fn _changeset, user ->
      strategy
      |> Confirmation.confirmation_token(original_changeset, user, Ash.Context.to_opts(context))
      |> case do
        {:ok, token} ->
          {sender, send_opts} = strategy.sender

          send_opts
          |> Keyword.put(:tenant, context.tenant)
          |> Keyword.put(:changeset, original_changeset)
          |> then(&sender.send(user, token, &1))

          metadata =
            user.__metadata__
            |> Map.put(:confirmation_token, token)

          {:ok, %{user | __metadata__: metadata}}

        {:error, error} ->
          Logger.error(
            "Failed to generate confirmation token\n: #{Exception.format(:error, error)}"
          )

          {:ok, user}
      end
    end)
  end

  defp maybe_perform_confirmation(_changeset, _strategy, original_changeset, _context),
    do: original_changeset

  defp nil_confirmed_at_field(changeset, strategy) do
    # If we're updating values, and we are inhibiting values on the changes (enforced at the call site)
    # then we want to reset the confirmed_at_field to `nil`
    # However, we do it `lazily` in case something they've done is already changing
    # the `confirmed_at`. This might happen in a social sign up where the email has
    # already been verified
    if changeset.action.type == :update do
      Changeset.force_change_new_attribute(changeset, strategy.confirmed_at_field, nil)
    else
      changeset
    end
  end
end
