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

defmodule AshAuthentication.AddOn.Confirmation.Transformer do
  @moduledoc """
  DSL transformer for confirmation add-on.

  Ensures that there is only ever one present and that it is correctly
  configured.
  """

  alias Ash.{Resource, Type}
  alias AshAuthentication.{AddOn.Confirmation, GenerateTokenChange}
  alias Spark.{Dsl.Transformer, Error.DslError}
  import AshAuthentication.Utils
  import AshAuthentication.Validations
  import AshAuthentication.Validations.Action
  import AshAuthentication.Validations.Attribute
  import AshAuthentication.Strategy.Custom.Helpers

  @doc false
  @spec transform(Confirmation.t(), map) ::
          {:ok, Confirmation.t() | map} | {:error, Exception.t()}
  def transform(strategy, dsl_state) do
    with :ok <-
           validate_token_generation_enabled(
             dsl_state,
             "Token generation must be enabled for confirmation to work."
           ),
         :ok <- validate_monitor_fields(dsl_state, strategy),
         strategy <- maybe_set_confirm_action_name(strategy),
         {:ok, dsl_state} <-
           maybe_build_action(
             dsl_state,
             strategy.confirm_action_name,
             &build_confirm_action(&1, strategy)
           ),
         :ok <- warn_on_require_interaction(strategy),
         :ok <- validate_confirm_action(dsl_state, strategy),
         {:ok, dsl_state} <-
           maybe_build_attribute(
             dsl_state,
             strategy.confirmed_at_field,
             &build_confirmed_at_attribute(&1, strategy)
           ),
         :ok <- validate_confirmed_at_attribute(dsl_state, strategy),
         {:ok, dsl_state} <-
           maybe_build_change(
             dsl_state,
             {Confirmation.ConfirmationHookChange, strategy_name: strategy.name}
           ),
         {:ok, resource} <- persisted_option(dsl_state, :module) do
      strategy = %{strategy | resource: resource}

      dsl_state =
        dsl_state
        |> then(&register_strategy_actions([strategy.confirm_action_name], &1, strategy))
        |> put_add_on(strategy)

      {:ok, dsl_state}
    else
      {:error, reason} when is_binary(reason) ->
        {:error,
         DslError.exception(path: [:authentication, :add_ons, :confirmation], message: reason)}

      {:error, reason} ->
        {:error, reason}

      :error ->
        {:error,
         DslError.exception(
           path: [:authentication, :add_ons, :confirmation],
           message: "Configuration error"
         )}
    end
  end

  defp warn_on_require_interaction(strategy) when strategy.require_interaction? do
    :ok
  end

  defp warn_on_require_interaction(strategy) do
    bypassing_error? =
      Application.get_env(
        :ash_authentication,
        :bypass_require_interaction_for_confirmation?,
        false
      )

    if bypassing_error? do
      :ok
    else
      {:error,
       DslError.exception(
         path: [:authentication, :add_ons, :confirmation],
         message: """
         `require_interaction?` must be set to true on the
         #{inspect(strategy.name)} confirmation for #{inspect(strategy.resource)}.
         Without it, confirmation links use a `GET` endpoint for confirmation. Some
         email clients and security tools (e.g., Outlook, virus scanners, and email
         previewers) may automatically follow these links, unintentionally
         confirming the account. This allows an attacker to register an account
         using another user’s email and potentially have it auto-confirmed by the
         victim’s email client.

         If you are *positive* that you are not affected , and *absolutely
         must*, you can bypass this warning by setting `config
         :ash_authentication, :bypass_require_interaction_for_confirmation?,
         true` in your configuration.

         Read the security advisory and follow patch the steps closely if you need to upgrade:

         https://github.com/team-alembic/ash_authentication/security/advisories/GHSA-3988-q8q7-p787
         """
       )}
    end
  end

  defp validate_monitor_fields(_dsl_state, %{monitor_fields: []}),
    do:
      {:error,
       DslError.exception(
         path: [:authentication, :add_ons, :confirmation],
         message: "You should be monitoring at least one field"
       )}

  defp validate_monitor_fields(dsl_state, strategy) do
    Enum.reduce_while(strategy.monitor_fields, :ok, fn field, :ok ->
      with {:ok, resource} <- persisted_option(dsl_state, :module),
           {:ok, attribute} <- find_attribute(dsl_state, field),
           :ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
           :ok <- validate_attribute_option(attribute, resource, :public?, [true]) do
        {:cont, :ok}
      else
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp build_confirm_action(_dsl_state, strategy) do
    arguments = [
      Transformer.build_entity!(Resource.Dsl, [:actions, :update], :argument,
        name: :confirm,
        type: Type.String,
        allow_nil?: false,
        public?: true
      )
    ]

    changes = [
      Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
        change: Confirmation.ConfirmChange
      ),
      Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
        change: GenerateTokenChange
      )
    ]

    metadata = [
      Transformer.build_entity!(Resource.Dsl, [:actions, :update], :metadata,
        name: :token,
        type: :string,
        allow_nil?: false
      )
    ]

    Transformer.build_entity(Resource.Dsl, [:actions], :update,
      name: strategy.confirm_action_name,
      accept: strategy.monitor_fields,
      arguments: arguments,
      metadata: metadata,
      changes: changes,
      require_atomic?: false
    )
  end

  defp validate_confirm_action(dsl_state, strategy) do
    with {:ok, action} <- validate_action_exists(dsl_state, strategy.confirm_action_name),
         :ok <- validate_action_has_change(action, Confirmation.ConfirmChange),
         :ok <- validate_action_argument_option(action, :confirm, :allow_nil?, [false]),
         :ok <- validate_action_argument_option(action, :confirm, :type, [Type.String]),
         :ok <- validate_action_has_change(action, GenerateTokenChange) do
      accept_fields = MapSet.new(action.accept || [])

      strategy.monitor_fields
      |> MapSet.new()
      |> MapSet.difference(accept_fields)
      |> Enum.to_list()
      |> case do
        [] ->
          :ok

        _fields ->
          {:error,
           DslError.exception(
             path: [:actions, action.name, :accept],
             message: "The confirmation action must accept the monitored fields."
           )}
      end
    end
  end

  defp build_confirmed_at_attribute(_dsl_state, strategy) do
    Transformer.build_entity(Resource.Dsl, [:attributes], :attribute,
      name: strategy.confirmed_at_field,
      type: Type.UtcDatetimeUsec,
      allow_nil?: true,
      writable?: true
    )
  end

  defp validate_confirmed_at_attribute(dsl_state, strategy) do
    with {:ok, resource} <- persisted_option(dsl_state, :module),
         {:ok, attribute} <- find_attribute(dsl_state, strategy.confirmed_at_field),
         :ok <- validate_attribute_option(attribute, resource, :writable?, [true]) do
      validate_attribute_option(attribute, resource, :allow_nil?, [true])
    end
  end

  defp maybe_build_change(dsl_state, change_module) do
    with changes <- Resource.Info.changes(dsl_state),
         false <- Enum.any?(changes, &(&1.change == change_module)),
         {:ok, change} <-
           Transformer.build_entity(Resource.Dsl, [:changes], :change,
             change: change_module,
             on: [:create, :update]
           ) do
      {:ok, Transformer.add_entity(dsl_state, [:changes], change)}
    else
      true -> {:ok, dsl_state}
      {:error, reason} -> {:error, reason}
    end
  end

  defp maybe_set_confirm_action_name(strategy)
       when is_nil(strategy.confirm_action_name),
       do: %{strategy | confirm_action_name: strategy.name}

  defp maybe_set_confirm_action_name(strategy), do: strategy
end
