defmodule Ash.Resource.Verifiers.VerifyActionsAtomic do
  @moduledoc """
  Raises an error on update or destroy actions with `require_atomic?` set to
  true when it is known at compile time that they will not be atomic.
  """
  use Spark.Dsl.Verifier

  def verify(dsl) do
    module = Spark.Dsl.Verifier.get_persisted(dsl, :module)

    warnings =
      dsl
      |> Ash.Resource.Info.actions()
      |> Enum.filter(
        &(&1.type in [:update, :destroy] && &1.require_atomic? &&
            data_layer_can_atomic?(module, &1))
      )
      |> Enum.flat_map(fn action ->
        warnings =
          if action.manual do
            [
              Spark.Error.DslError.exception(
                module: module,
                message:
                  non_atomic_message(
                    module,
                    action.name,
                    "manual actions cannot be done atomically"
                  ),
                path: [:actions, action.name]
              )
            ]
          else
            []
          end

        change_warnings =
          action.changes
          |> Enum.concat(Ash.Resource.Info.changes(dsl, action.type))
          |> Enum.concat(Ash.Resource.Info.validations(dsl, action.type))
          |> Enum.map(fn
            %{change: {module, _}} ->
              module

            %{validation: {module, _}} ->
              module
          end)
          |> Enum.reject(fn module ->
            module.atomic?()
          end)
          |> case do
            [] ->
              []

            non_atomic_change_modules ->
              [
                Spark.Error.DslError.exception(
                  module: module,
                  message:
                    non_atomic_message(
                      module,
                      action.name,
                      "the changes `#{inspect(non_atomic_change_modules)}` cannot be done atomically"
                    ),
                  path: [:actions, action.name]
                )
              ]
          end

        notifier_warnings =
          dsl
          |> Ash.Resource.Info.notifiers()
          |> Enum.filter(fn notifier ->
            notifier.requires_original_data?(module, action)
          end)
          |> case do
            [] ->
              []

            non_atomic_notifier_modules ->
              [
                Spark.Error.DslError.exception(
                  module: module,
                  message:
                    non_atomic_message(
                      module,
                      action.name,
                      "the notifiers `#{inspect(non_atomic_notifier_modules)}` cannot be done atomically"
                    ),
                  path: [:actions, action.name]
                )
              ]
          end

        attribute_warnings =
          action.accept
          |> Enum.map(&Ash.Resource.Info.attribute(dsl, &1))
          |> Enum.filter(fn %{type: type, constraints: constraints} ->
            case type do
              {:array, {:array, _}} ->
                true

              {:array, type} when is_atom(type) ->
                not_atomic?(type, constraints, action)

              type when is_atom(type) ->
                not_atomic?(type, constraints, action)

              _ ->
                false
            end
          end)
          |> case do
            [] ->
              []

            not_atomic_attributes ->
              [
                Spark.Error.DslError.exception(
                  module: module,
                  message:
                    non_atomic_message(
                      module,
                      action.name,
                      "the attributes `#{Enum.map_join(not_atomic_attributes, ", ", & &1.name)}` cannot be updated atomically"
                    ),
                  path: [:actions, action.name]
                )
              ]
          end

        warnings ++ change_warnings ++ notifier_warnings ++ attribute_warnings
      end)

    {:warn, Enum.map(warnings, &Exception.message/1)}
  end

  defp data_layer_can_atomic?(resource, %{type: :update}) do
    Ash.DataLayer.can?(:update_query, resource)
  end

  defp data_layer_can_atomic?(resource, %{type: :destroy}) do
    Ash.DataLayer.can?(:destroy_query, resource)
  end

  defp not_atomic?(type, constraints, %{type: :update}) do
    type = Ash.Type.get_type(type)

    new_type =
      if Ash.Type.NewType.new_type?(type) do
        Ash.Type.NewType.subtype_of(type)
      else
        type
      end

    constraints =
      if Ash.Type.NewType.new_type?(type) do
        Ash.Type.NewType.constraints(new_type, constraints)
      else
        constraints
      end

    !new_type.may_support_atomic_update?(constraints)
  end

  defp not_atomic?(type, constraints, %{type: :destroy, soft?: true} = action)
       when is_atom(type) do
    not_atomic?(type, constraints, %{action | type: :update})
  end

  defp not_atomic?(_, _, _), do: false

  defp non_atomic_message(module, action_name, reason) do
    """
    `#{inspect(module)}.#{action_name}` cannot be done atomically, because #{reason}

    You must either address the issue or set `require_atomic? false` on `#{inspect(module)}.#{action_name}`.

    If this action is defined in defaults, i.e `defaults [update: [:accepted, :attr]]`, you must define it like so:

        update :update do
          accept [:accepted, :attr]
          require_atomic? false
        end
    """
  end
end
