lib/ash/code_interface.ex

defmodule Ash.CodeInterface do
  @moduledoc """
  Used to define the functions of a code interface for a resource.
  """

  @doc false
  def require_action(resource, interface) do
    action = Ash.Resource.Info.action(resource, interface.action || interface.name)

    unless action do
      raise Spark.Error.DslError,
        module: resource,
        message:
          "The interface of #{inspect(resource)} refers to a non-existent action #{interface.action || interface.name}",
        path: [:interfaces, :interface, interface.name]
    end

    action
  end

  @doc false
  def default_value(resource, action, key) do
    {field_type, field} =
      case Enum.find(action.arguments, fn argument ->
             argument.name == key
           end) do
        nil ->
          {:attribute, Ash.Resource.Info.attribute(resource, key)}

        argument ->
          {:argument, argument}
      end

    cond do
      field.allow_nil? && !(field.name in Map.get(action, :require_attributes, [])) ->
        :ok

      field.name in Map.get(action, :allow_nil_input, []) ->
        :ok

      !(field.name in Map.get(action, :accept, [])) ->
        :ok

      true ->
        raise "Code interface for #{action.name} has optional argument #{key} but it is not optional"
    end

    default =
      if field_type == :argument do
        field.default
      else
        if action.type == :update || (action.type == :destroy && action.soft?) do
          if is_nil(field.update_default) do
            field.default
          else
            field.update_default
          end
        else
          field.default
        end
      end

    if is_function(default) do
      quote do
        unquote(Macro.escape(default)).()
      end
    else
      quote do
        unquote(Macro.escape(default))
      end
    end
  end

  def without_optional(keys) do
    Enum.map(keys, fn
      {:optional, key} ->
        key

      key ->
        key
    end)
  end

  def unwrap_calc_interface_args(keys, resource, arguments, function_head? \\ false) do
    {Enum.map(keys, &unwrap_calc_interface_arg_binding(resource, arguments, &1, function_head?)),
     Enum.map(keys, &unwrap_calc_interface_arg_access(&1))}
  end

  defp unwrap_calc_interface_arg_access({:optional, value}),
    do: unwrap_calc_interface_arg_access(value)

  defp unwrap_calc_interface_arg_access({:optional, value, _}),
    do: unwrap_calc_interface_arg_access(value)

  defp unwrap_calc_interface_arg_access(value) do
    case value do
      :_record ->
        [type: :_record, name: :record, value: {:record, [], Elixir}]

      {tag, name} ->
        [type: tag, name: name, value: {name, [], Elixir}]

      name ->
        [type: :both, name: name, value: {name, [], Elixir}]
    end
  end

  defp unwrap_calc_interface_arg_binding(resource, arguments, {:optional, binding}, head?) do
    access = unwrap_calc_interface_arg_binding(resource, arguments, binding, head?)

    if head? do
      {:\\, [], [access, default_calc_value(resource, arguments, binding)]}
    else
      access
    end
  end

  defp unwrap_calc_interface_arg_binding(
         resource,
         arguments,
         {:optional, binding, default},
         head?
       ) do
    access = unwrap_calc_interface_arg_binding(resource, arguments, binding, head?)

    if head? do
      {:\\, [], [access, default]}
    else
      access
    end
  end

  defp unwrap_calc_interface_arg_binding(_resource, _arguments, {tag, value}, _)
       when tag in [:arg, :ref] do
    {value, [], Elixir}
  end

  defp unwrap_calc_interface_arg_binding(resource, _arguments, :_record, false) do
    {:=, [],
     [
       {:%, [], [{:__aliases__, [alias: false], [resource]}, {:%{}, [], []}]},
       {:record, [], Elixir}
     ]}
  end

  defp unwrap_calc_interface_arg_binding(_resource, _arguments, value, _) do
    {value, [], Elixir}
  end

  @doc false
  def default_calc_value(_resource, arguments, {:arg, arg_name}) do
    arguments
    |> Enum.find(&(&1.name == arg_name))
    |> case do
      nil ->
        nil

      argument ->
        argument.default
    end
  end

  def default_calc_value(resource, _, {:ref, attribute}) do
    resource
    |> Ash.Resource.Info.attributes()
    |> Enum.find(&(&1.name == attribute))
    |> case do
      nil ->
        nil

      attribute ->
        attribute.default
    end
  end

  def default_calc_value(resource, arguments, name) do
    case default_calc_value(resource, arguments, {:arg, name}) do
      nil ->
        default_calc_value(resource, arguments, {:ref, name})

      value ->
        value
    end
  end

  @doc false
  # sobelow_skip ["DOS.BinToAtom", "DOS.StringToAtom"]
  def resolve_calc_method_names(name) do
    if name |> to_string() |> String.ends_with?("?") do
      safe_name = name |> to_string() |> String.trim_trailing("?") |> String.to_atom()
      bang_name = name
      {safe_name, bang_name}
    else
      safe_name = name
      bang_name = "#{name}!" |> String.to_atom()
      {safe_name, bang_name}
    end
  end

  @doc """
  Defines the code interface for a given resource + domain combination in the current module. For example:

  ```elixir
  defmodule MyApp.Accounting do
    require Ash.CodeInterface

    Ash.CodeInterface.define_interface(MyApp.Accounting, MyApp.Accounting.Transaction)
    Ash.CodeInterface.define_interface(MyApp.Accounting, MyApp.Accounting.Account)
    Ash.CodeInterface.define_interface(MyApp.Accounting, MyApp.Accounting.Invoice)
  end
  ```
  """
  defmacro define_interface(domain, resource, definitions \\ nil) do
    quote bind_quoted: [domain: domain, resource: resource, definitions: definitions],
          generated: true,
          location: :keep do
      calculation_interfaces =
        case definitions do
          nil ->
            Ash.Resource.Info.calculation_interfaces(resource)

          definitions ->
            Enum.filter(definitions, &match?(%Ash.Resource.CalculationInterface{}, &1))
        end

      interfaces =
        case definitions do
          nil ->
            Ash.Resource.Info.interfaces(resource)

          definitions ->
            Enum.filter(definitions, &match?(%Ash.Resource.Interface{}, &1))
        end

      interfaces_for_defaults =
        Enum.group_by(calculation_interfaces, fn interface ->
          {interface.name, Enum.count(interface.args, &is_atom/1), Enum.count(interface.args)}
        end)

      for {{name, arity, optional_arity}, interfaces} <- interfaces_for_defaults do
        args =
          case interfaces do
            [%{args: args, calculation: calculation}] ->
              calculation = Ash.Resource.Info.calculation(resource, calculation)

              {arg_bindings, _arg_access} =
                args
                |> Kernel.||([])
                |> Ash.CodeInterface.unwrap_calc_interface_args(
                  resource,
                  calculation.arguments,
                  true
                )

              arg_bindings

            multiple ->
              multiple
              |> Enum.map(fn interface ->
                interface.args
                |> Enum.flat_map(fn
                  {:optional, value} ->
                    calculation = Ash.Resource.Info.calculation(resource, interface.calculation)
                    [Ash.CodeInterface.default_calc_value(resource, calculation.arguments, value)]

                  {:optional, _, value} ->
                    [value]

                  _ ->
                    []
                end)
              end)
              |> Enum.uniq()
              |> case do
                [_] ->
                  interface = hd(multiple)
                  calculation = Ash.Resource.Info.calculation(resource, interface.calculation)

                  {arg_bindings, _arg_access} =
                    interface.args
                    |> Kernel.||([])
                    |> Ash.CodeInterface.unwrap_calc_interface_args(
                      resource,
                      calculation.arguments,
                      true
                    )

                  arg_bindings

                _duplicates ->
                  raise """
                  The generated function #{name}/#{arity + optional_arity} would have
                  multiple different sets of default values for arguments. Please use a different
                  name for conflicting code interface functions.
                  """
              end
          end

        {safe_name, bang_name} = Ash.CodeInterface.resolve_calc_method_names(name)

        def unquote(bang_name)(unquote_splicing(args), opts \\ [])
        def unquote(safe_name)(unquote_splicing(args), opts \\ [])
      end

      for interface <- calculation_interfaces do
        calculation = Ash.Resource.Info.calculation(resource, interface.calculation)

        {arg_bindings, arg_access} =
          interface.args
          |> Kernel.||([])
          |> Ash.CodeInterface.unwrap_calc_interface_args(resource, calculation.arguments)

        {safe_name, bang_name} = Ash.CodeInterface.resolve_calc_method_names(interface.name)

        opts_location = Enum.count(arg_bindings)
        interface_options = Ash.Resource.Interface.interface_options(:calculate, nil)

        @doc """
             #{calculation.description || "Calculates #{calculation.name} action on #{inspect(resource)}."}

             #{Ash.CodeInterface.describe_calculation(resource, calculation, interface.args)}

             ### Options

             #{interface_options.docs()}
             """
             |> Ash.CodeInterface.trim_double_newlines()
        @doc spark_opts: [
               {opts_location, interface_options.schema()}
             ]
        def unquote(bang_name)(unquote_splicing(arg_bindings), opts) do
          {refs, arguments, record} =
            Enum.reduce(
              [unquote_splicing(arg_access)],
              {%{}, %{}, nil},
              fn config, {refs, arguments, record} ->
                case config[:type] do
                  :_record ->
                    {refs, arguments, config[:value]}

                  :both ->
                    {Map.put(refs, config[:name], config[:value]),
                     Map.put(arguments, config[:name], config[:value]), record}

                  :ref ->
                    {Map.put(refs, config[:name], config[:value]), arguments, record}

                  :arg ->
                    {refs, Map.put(arguments, config[:name], config[:value]), record}
                end
              end
            )

          opts = [domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts
          Ash.calculate!(unquote(resource), unquote(interface.calculation), opts)
        end

        @doc """
             #{calculation.description || "Calculates #{calculation.name} action on #{inspect(resource)}."}

             #{Ash.CodeInterface.describe_calculation(resource, calculation, interface.args)}

             ### Options

             #{interface_options.docs()}
             """
             |> Ash.CodeInterface.trim_double_newlines()
        @doc spark_opts: [
               {opts_location, interface_options.schema()}
             ]
        def unquote(safe_name)(unquote_splicing(arg_bindings), opts) do
          {refs, arguments, record} =
            Enum.reduce(
              [unquote_splicing(arg_access)],
              {%{}, %{}, nil},
              fn config, {refs, arguments, record} ->
                case config[:type] do
                  :_record ->
                    {refs, arguments, config[:value]}

                  :both ->
                    {Map.put(refs, config[:name], config[:value]),
                     Map.put(arguments, config[:name], config[:value]), record}

                  :ref ->
                    {Map.put(refs, config[:name], config[:value]), arguments, record}

                  :arg ->
                    {refs, Map.put(arguments, config[:name], config[:value]), record}
                end
              end
            )

          opts = [domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts
          Ash.calculate(unquote(resource), unquote(interface.calculation), opts)
        end
      end

      for interface <- interfaces do
        action = Ash.CodeInterface.require_action(resource, interface)

        filter_keys =
          cond do
            action.type not in [:read, :update, :destroy] ->
              []

            interface.get_by_identity ->
              Ash.Resource.Info.identity(resource, interface.get_by_identity).keys

            interface.get_by ->
              interface.get_by

            true ->
              []
          end

        args = List.wrap(filter_keys) ++ Ash.CodeInterface.without_optional(interface.args || [])

        arg_vars = Enum.map(args, &{&1, [], Elixir})

        arg_vars_function =
          filter_keys
          |> List.wrap()
          |> Enum.concat(interface.args || [])
          |> Enum.map(fn
            {:optional, key} ->
              default = Ash.CodeInterface.default_value(resource, action, key)
              {:\\, [], [{key, [], Elixir}, default]}

            key ->
              {key, [], Elixir}
          end)

        unless Enum.uniq(args) == args do
          raise """
          Arguments #{inspect(args)} for #{interface.name} are not unique!
          """
        end

        interface =
          if Map.get(action, :get?) do
            %{interface | get?: true}
          else
            interface
          end

        interface_options = Ash.Resource.Interface.interface_options(action.type, interface)

        resolve_opts_params =
          quote do
            {params, opts} =
              if opts == [] && Keyword.keyword?(params_or_opts),
                do:
                  {%{},
                   unquote(interface_options).validate!(params_or_opts)
                   |> unquote(interface_options).to_options()},
                else:
                  {params_or_opts,
                   unquote(interface_options).validate!(opts)
                   |> unquote(interface_options).to_options()}

            params =
              unquote(args)
              |> Enum.zip([unquote_splicing(arg_vars)])
              |> Enum.reduce(params, fn {key, value}, params ->
                Map.put(params, key, value)
              end)
          end

        resolve_bang_opts_params =
          quote do
            {params, opts} =
              if opts == [] && Keyword.keyword?(params_or_opts),
                do:
                  {if(params_or_opts != [], do: %{}, else: []),
                   unquote(interface_options).validate!(params_or_opts)
                   |> unquote(interface_options).to_options()},
                else:
                  {if(Keyword.keyword?(params_or_opts),
                     do: Map.new(params_or_opts),
                     else: params_or_opts
                   ),
                   unquote(interface_options).validate!(opts)
                   |> unquote(interface_options).to_options()}

            params =
              if is_list(params) do
                to_merge =
                  unquote(args)
                  |> Enum.zip([unquote_splicing(arg_vars)])
                  |> Map.new()

                Enum.map(params, fn params ->
                  Map.merge(params, to_merge)
                end)
              else
                unquote(args)
                |> Enum.zip([unquote_splicing(arg_vars)])
                |> Enum.reduce(params, fn {key, value}, params ->
                  Map.put(params, key, value)
                end)
              end
          end

        {subject, subject_args, resolve_subject, act, act!} =
          case action.type do
            :action ->
              subject = quote do: input

              resolve_subject =
                quote do
                  {input_opts, opts} =
                    Keyword.split(opts, [:input, :actor, :tenant, :authorize?, :tracer])

                  {input, input_opts} = Keyword.pop(input_opts, :input)

                  input_opts = Keyword.put(input_opts, :domain, unquote(domain))

                  case input do
                    %Ash.ActionInput{resource: unquote(resource)} ->
                      input

                    %Ash.ActionInput{resource: other_resource} ->
                      raise ArgumentError,
                            "Action input resource #{inspect(other_resource)} does not match expected resource #{inspect(unquote(resource))}."

                    input ->
                      input
                  end

                  input =
                    input
                    |> Kernel.||(unquote(resource))
                    |> Ash.ActionInput.for_action(unquote(action.name), params, input_opts)
                end

              act = quote do: Ash.run_action(input, opts)
              act! = quote do: Ash.run_action!(input, opts)

              {subject, [], resolve_subject, act, act!}

            :read ->
              subject = quote do: query

              resolve_subject =
                quote do
                  {query_opts, opts} =
                    Keyword.split(opts, [:query, :actor, :tenant, :authorize?, :tracer, :context])

                  {query, query_opts} = Keyword.pop(query_opts, :query)

                  query_opts = Keyword.put(query_opts, :domain, unquote(domain))

                  query =
                    case query do
                      %Ash.Query{resource: unquote(resource)} = query ->
                        query

                      %Ash.Query{resource: other_resource} ->
                        raise ArgumentError,
                              "Query resource #{inspect(other_resource)} does not match expected resource #{inspect(unquote(resource))}."

                      unquote(resource) ->
                        unquote(resource)
                        |> Ash.Query.new()

                      other_resource
                      when is_atom(other_resource) and not is_nil(other_resource) ->
                        raise ArgumentError,
                              "Query resource #{inspect(other_resource)} does not match expected resource #{inspect(unquote(resource))}."

                      query ->
                        Ash.Query.build(unquote(resource), query || [])
                    end

                  query =
                    if unquote(filter_keys) && !Enum.empty?(unquote(filter_keys)) do
                      require Ash.Query
                      {filters, params} = Map.split(params, unquote(filter_keys))

                      query
                      |> Ash.Query.for_read(unquote(action.name), params, query_opts)
                      |> Ash.Query.filter(filters)
                    else
                      Ash.Query.for_read(query, unquote(action.name), params, query_opts)
                    end
                end

              resolve_not_found_error? =
                quote do
                  {not_found_error?, opts} = Keyword.pop(opts, :not_found_error?)

                  not_found_error? =
                    if not_found_error? != nil,
                      do: not_found_error?,
                      else: unquote(interface.not_found_error?)
                end

              act =
                if interface.get? do
                  quote do
                    unquote(resolve_not_found_error?)

                    Ash.read_one(query, Keyword.drop(opts, [:stream?, :stream_options]))
                    |> case do
                      {:ok, nil} when not_found_error? ->
                        {:error, Ash.Error.Query.NotFound.exception(resource: query.resource)}

                      result ->
                        result
                    end
                  end
                else
                  quote do: Ash.read(query, Keyword.drop(opts, [:stream?, :stream_options]))
                end

              act! =
                if interface.get? do
                  quote do
                    unquote(resolve_not_found_error?)

                    Ash.read_one!(query, Keyword.drop(opts, [:stream?, :stream_options]))
                    |> case do
                      nil when not_found_error? ->
                        raise Ash.Error.Query.NotFound, resource: query.resource

                      result ->
                        result
                    end
                  end
                else
                  quote do
                    if opts[:stream?] do
                      Ash.stream!(query, Keyword.drop(opts, [:stream?, :stream_options]))
                    else
                      Ash.read!(query, Keyword.drop(opts, [:stream?, :stream_options]))
                    end
                  end
                end

              {subject, [], resolve_subject, act, act!}

            :create ->
              subject = quote do: changeset

              resolve_subject =
                quote do
                  {changeset, opts} = Keyword.pop(opts, :changeset)

                  {changeset_opts, opts} =
                    Keyword.split(opts, [
                      :actor,
                      :tenant,
                      :authorize?,
                      :tracer,
                      :context,
                      :skip_unknown_inputs
                    ])

                  changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))

                  changeset =
                    if is_map(params) do
                      changeset
                      |> Kernel.||(unquote(resource))
                      |> case do
                        %Ash.Changeset{resource: unquote(resource)} ->
                          changeset

                        %Ash.Changeset{resource: other_resource} ->
                          raise ArgumentError,
                                "Changeset #{inspect(changeset)} does not match expected resource #{inspect(unquote(resource))}."

                        other_resource
                        when is_atom(other_resource) and other_resource != unquote(resource) ->
                          raise ArgumentError,
                                "Resource #{inspect(other_resource)} does not match expected resource #{inspect(unquote(resource))}."

                        changeset ->
                          changeset
                      end
                      |> Ash.Changeset.for_create(unquote(action.name), params, changeset_opts)
                    else
                      {:bulk, params}
                    end
                end

              act =
                quote do
                  case changeset do
                    {:bulk, inputs} ->
                      bulk_opts =
                        opts
                        |> Keyword.delete(:bulk_options)
                        |> Keyword.merge(Keyword.get(opts, :bulk_options, []))
                        |> Enum.concat(changeset_opts)

                      Ash.bulk_create(
                        inputs,
                        unquote(resource),
                        unquote(action.name),
                        bulk_opts
                      )

                    changeset ->
                      Ash.create(changeset, Keyword.delete(opts, :bulk_options))
                  end
                end

              act! =
                quote do
                  case changeset do
                    {:bulk, inputs} ->
                      bulk_opts =
                        opts
                        |> Keyword.delete(:bulk_options)
                        |> Keyword.merge(Keyword.get(opts, :bulk_options, []))
                        |> Enum.concat(changeset_opts)

                      Ash.bulk_create!(
                        inputs,
                        unquote(resource),
                        unquote(action.name),
                        bulk_opts
                      )

                    changeset ->
                      Ash.create!(changeset, Keyword.delete(opts, :bulk_options))
                  end
                end

              {subject, [], resolve_subject, act, act!}

            :update ->
              subject = quote do: changeset

              subject_args =
                if interface.require_reference? do
                  quote do: [record]
                else
                  []
                end

              resolve_subject =
                if Enum.empty?(filter_keys) and interface.require_reference? do
                  quote do
                    {changeset_opts, opts} =
                      Keyword.split(opts, [
                        :actor,
                        :tenant,
                        :authorize?,
                        :tracer,
                        :context,
                        :skip_unknown_inputs
                      ])

                    changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))

                    changeset =
                      record
                      |> case do
                        %Ash.Changeset{resource: unquote(resource)} ->
                          {filters, params} = Map.split(params, unquote(filter_keys))

                          record
                          |> Ash.Changeset.filter(filters)
                          |> Ash.Changeset.for_update(
                            unquote(action.name),
                            params,
                            changeset_opts
                          )

                        %Ash.Changeset{resource: other_resource} ->
                          raise ArgumentError,
                                "Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."

                        %struct{} = record when struct == unquote(resource) ->
                          {filters, params} = Map.split(params, unquote(filter_keys))

                          record
                          |> Ash.Changeset.new()
                          |> Ash.Changeset.filter(filters)
                          |> Ash.Changeset.for_update(
                            unquote(action.name),
                            params,
                            changeset_opts
                          )

                        %Ash.Query{} = query ->
                          {:atomic, :query, query}

                        %other_resource{} when other_resource != unquote(resource) ->
                          raise ArgumentError,
                                "Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."

                        [{_key, _val} | _] = id ->
                          {:atomic, :id, id}

                        list when is_list(list) ->
                          {:atomic, :stream, list}

                        other ->
                          {:atomic, :id, other}
                      end
                  end
                else
                  quote do
                    filters = Map.take(params, unquote(filter_keys))

                    {changeset_opts, opts} =
                      Keyword.split(opts, [
                        :actor,
                        :tenant,
                        :authorize?,
                        :tracer,
                        :context,
                        :skip_unknown_inputs
                      ])

                    changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))

                    changeset =
                      {:atomic, :query, Ash.Query.do_filter(unquote(resource), filters)}
                  end
                end

              act =
                quote do
                  {filters, params} = Map.split(params, unquote(filter_keys))

                  case changeset do
                    {:atomic, method, id} ->
                      bulk_opts =
                        opts
                        |> Keyword.drop([:bulk_options, :atomic_upgrade?])
                        |> Keyword.merge(Keyword.get(opts, :bulk_options, []))
                        |> Enum.concat(changeset_opts)
                        |> Keyword.put(:resource, unquote(resource))
                        |> then(fn bulk_opts ->
                          if method == :id || unquote(interface.get?) do
                            bulk_opts
                            |> Keyword.put(:return_records?, true)
                            |> Keyword.put(:return_errors?, true)
                            |> Keyword.put_new(:authorize_with, :error)
                            |> Keyword.put(:notify?, true)
                          else
                            bulk_opts
                          end
                        end)
                        |> Keyword.put_new(:strategy, [:atomic, :stream, :atomic_batches])

                      bulk_opts =
                        if method in [:stream, :query] do
                          Keyword.put(bulk_opts, :filter, filters)
                        else
                          bulk_opts
                        end

                      case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do
                        {:ok, query} ->
                          query
                          |> Ash.bulk_update(unquote(action.name), params, bulk_opts)
                          |> case do
                            %Ash.BulkResult{} = result
                            when method in [:stream, :query] and not unquote(interface.get?) ->
                              result

                            %Ash.BulkResult{status: :success, records: [record]} = result ->
                              if opts[:return_notifications?] do
                                {:ok, record, result.notifications}
                              else
                                {:ok, record}
                              end

                            %Ash.BulkResult{status: :success, records: []} = result ->
                              {:error,
                               Ash.Error.to_error_class(
                                 Ash.Error.Query.NotFound.exception(
                                   resource: unquote(resource),
                                   primary_key: id
                                 )
                               )}

                            %Ash.BulkResult{status: :error, errors: errors} ->
                              {:error, Ash.Error.to_error_class(errors)}
                          end

                        {:error, error} ->
                          {:error, Ash.Error.to_error_class(error)}
                      end

                    changeset ->
                      Ash.update(changeset, Keyword.delete(opts, :bulk_options))
                  end
                end

              act! =
                quote do
                  case changeset do
                    {:atomic, method, id} ->
                      {filters, params} = Map.split(params, unquote(filter_keys))

                      bulk_opts =
                        opts
                        |> Keyword.drop([:bulk_options, :atomic_upgrade?])
                        |> Keyword.merge(Keyword.get(opts, :bulk_options, []))
                        |> Enum.concat(changeset_opts)
                        |> Keyword.put(:resource, unquote(resource))
                        |> then(fn bulk_opts ->
                          if method == :id || unquote(interface.get?) do
                            bulk_opts
                            |> Keyword.put(:return_records?, true)
                            |> Keyword.put(:return_errors?, true)
                            |> Keyword.put_new(:authorize_with, :error)
                            |> Keyword.put(:notify?, true)
                          else
                            bulk_opts
                          end
                        end)
                        |> Keyword.put_new(:strategy, [:atomic, :stream, :atomic_batches])

                      bulk_opts =
                        if method in [:stream] do
                          Keyword.put(bulk_opts, :filter, filters)
                        else
                          bulk_opts
                        end

                      case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do
                        {:ok, query} ->
                          query
                          |> Ash.bulk_update!(unquote(action.name), params, bulk_opts)
                          |> case do
                            %Ash.BulkResult{} = result
                            when method in [:stream, :query] and not unquote(interface.get?) ->
                              result

                            %Ash.BulkResult{status: :success, records: [record]} = result ->
                              if opts[:return_notifications?] do
                                {record, result.notifications}
                              else
                                record
                              end

                            %Ash.BulkResult{status: :success, records: []} = result ->
                              raise Ash.Error.to_error_class(
                                      Ash.Error.Query.NotFound.exception(
                                        resource: unquote(resource),
                                        primary_key: id
                                      )
                                    )
                          end

                        {:error, error} ->
                          raise Ash.Error.to_error_class(error)
                      end

                    changeset ->
                      Ash.update!(changeset, Keyword.delete(opts, :bulk_options))
                  end
                end

              {subject, subject_args, resolve_subject, act, act!}

            :destroy ->
              subject = quote do: changeset

              subject_args =
                if interface.require_reference? do
                  quote do: [record]
                else
                  []
                end

              resolve_subject =
                if interface.require_reference? do
                  quote do
                    {changeset_opts, opts} =
                      Keyword.split(opts, [
                        :actor,
                        :tenant,
                        :authorize?,
                        :tracer,
                        :context,
                        :skip_unknown_inputs
                      ])

                    changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))

                    changeset =
                      record
                      |> case do
                        %Ash.Changeset{resource: unquote(resource)} ->
                          {filters, params} = Map.split(params, unquote(filter_keys))

                          record
                          |> Ash.Changeset.filter(filters)
                          |> Ash.Changeset.for_destroy(
                            unquote(action.name),
                            params,
                            changeset_opts
                          )

                        %Ash.Changeset{resource: other_resource} ->
                          raise ArgumentError,
                                "Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."

                        %struct{} = record when struct == unquote(resource) ->
                          {filters, params} = Map.split(params, unquote(filter_keys))

                          record
                          |> Ash.Changeset.new()
                          |> Ash.Changeset.filter(filters)
                          |> Ash.Changeset.for_destroy(
                            unquote(action.name),
                            params,
                            changeset_opts
                          )

                        %Ash.Query{} = query ->
                          {:atomic, :query, query}

                        %other_resource{} when other_resource != unquote(resource) ->
                          raise ArgumentError,
                                "Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."

                        [{_key, _val} | _] = id ->
                          {:atomic, :id, id}

                        list when is_list(list) ->
                          {:atomic, :stream, list}

                        other ->
                          {:atomic, :id, other}
                      end
                  end
                else
                  quote do
                    filters = Map.take(params, unquote(filter_keys))

                    {changeset_opts, opts} =
                      Keyword.split(opts, [
                        :actor,
                        :tenant,
                        :authorize?,
                        :tracer,
                        :context,
                        :skip_unknown_inputs
                      ])

                    changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))

                    changeset =
                      {:atomic, :query, Ash.Query.do_filter(unquote(resource), filters)}
                  end
                end

              act =
                quote do
                  case changeset do
                    {:atomic, method, id} ->
                      {filters, params} = Map.split(params, unquote(filter_keys))

                      bulk_opts =
                        opts
                        |> Keyword.drop([:bulk_options, :return_destroyed?])
                        |> Keyword.merge(Keyword.get(opts, :bulk_options, []))
                        |> Enum.concat(changeset_opts)
                        |> Keyword.put(:resource, unquote(resource))
                        |> then(fn bulk_opts ->
                          if method == :id || unquote(interface.get?) do
                            bulk_opts
                            |> Keyword.put(:return_records?, opts[:return_destroyed?])
                            |> Keyword.put(:return_errors?, true)
                            |> Keyword.put_new(:authorize_with, :error)
                            |> Keyword.put(:notify?, true)
                          else
                            Keyword.put(bulk_opts, :return_records?, opts[:return_destroyed?])
                          end
                        end)
                        |> Keyword.put_new(:strategy, [:atomic, :stream, :atomic_batches])

                      bulk_opts =
                        if method in [:stream, :query] do
                          Keyword.put(bulk_opts, :filter, filters)
                        else
                          bulk_opts
                        end

                      case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do
                        {:ok, query} ->
                          query
                          |> Ash.bulk_destroy(unquote(action.name), params, bulk_opts)
                          |> case do
                            %Ash.BulkResult{} = result
                            when method in [:stream, :query] and not unquote(interface.get?) ->
                              result

                            %Ash.BulkResult{status: :success, records: [record]} = result ->
                              if opts[:return_destroyed?] do
                                if opts[:return_notifications?] do
                                  {:ok, record, result.notifications}
                                else
                                  {:ok, record}
                                end
                              else
                                if opts[:return_notifications?] do
                                  {:ok, result.notifications}
                                else
                                  :ok
                                end
                              end

                            %Ash.BulkResult{status: :success, records: empty} = result
                            when empty in [[], nil] ->
                              if opts[:return_destroyed?] do
                                {:error,
                                 Ash.Error.to_error_class(
                                   Ash.Error.Query.NotFound.exception(
                                     resource: unquote(resource),
                                     primary_key: id
                                   )
                                 )}
                              else
                                if opts[:return_notifications?] do
                                  {:ok, result.notifications}
                                else
                                  :ok
                                end
                              end

                            %Ash.BulkResult{status: :error, errors: errors} ->
                              {:error, Ash.Error.to_error_class(errors)}
                          end

                        {:error, error} ->
                          {:error, Ash.Error.to_error_class(error)}
                      end

                    changeset ->
                      Ash.destroy(changeset, Keyword.delete(opts, :bulk_options))
                  end
                end

              act! =
                quote do
                  case changeset do
                    {:atomic, method, id} ->
                      {filters, params} = Map.split(params, unquote(filter_keys))

                      bulk_opts =
                        opts
                        |> Keyword.drop([:bulk_options, :return_destroyed?])
                        |> Keyword.merge(Keyword.get(opts, :bulk_options, []))
                        |> Enum.concat(changeset_opts)
                        |> Keyword.put(:resource, unquote(resource))
                        |> then(fn bulk_opts ->
                          if method == :id || unquote(interface.get?) do
                            bulk_opts
                            |> Keyword.put(:return_records?, opts[:return_destroyed?])
                            |> Keyword.put(:return_errors?, true)
                            |> Keyword.put_new(:authorize_with, :error)
                            |> Keyword.put(:notify?, true)
                          else
                            Keyword.put(bulk_opts, :return_records?, opts[:return_destroyed?])
                          end
                        end)
                        |> Keyword.put_new(:strategy, [:atomic, :stream, :atomic_batches])

                      bulk_opts =
                        if method in [:stream, :query] do
                          Keyword.put(bulk_opts, :filter, filters)
                        else
                          bulk_opts
                        end

                      case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do
                        {:ok, query} ->
                          query
                          |> Ash.bulk_destroy!(unquote(action.name), params, bulk_opts)
                          |> case do
                            %Ash.BulkResult{} = result
                            when method in [:stream, :query] and not unquote(interface.get?) ->
                              result

                            %Ash.BulkResult{status: :success, records: [record]} = result ->
                              if opts[:return_destroyed?] do
                                if opts[:return_notifications?] do
                                  {record, result.notifications}
                                else
                                  record
                                end
                              else
                                if opts[:return_notifications?] do
                                  result.notifications
                                else
                                  :ok
                                end
                              end

                            %Ash.BulkResult{status: :success, records: empty} = result
                            when empty in [[], nil] ->
                              if opts[:return_destroyed?] do
                                raise Ash.Error.to_error_class(
                                        Ash.Error.Query.NotFound.exception(
                                          resource: unquote(resource),
                                          primary_key: id
                                        )
                                      )
                              else
                                if opts[:return_notifications?] do
                                  {:ok, result.notifications}
                                else
                                  :ok
                                end
                              end
                          end

                        {:error, error} ->
                          raise Ash.Error.to_error_class(error)
                      end

                    changeset ->
                      Ash.destroy!(changeset, Keyword.delete(opts, :bulk_options))
                  end
                end

              {subject, subject_args, resolve_subject, act, act!}
          end

        subject_name = elem(subject, 0)

        resolve_subject =
          quote do
            unquote(resolve_subject)
          end

        common_args =
          quote do: [
                  unquote_splicing(subject_args),
                  unquote_splicing(arg_vars_function),
                  params_or_opts \\ %{},
                  opts \\ []
                ]

        first_opts_location = Enum.count(subject_args) + Enum.count(arg_vars_function)

        @doc """
             #{action.description || "Calls the #{action.name} action on #{inspect(resource)}."}

             #{Ash.CodeInterface.describe_action(resource, action, interface.args)}

             ## Options

             #{interface_options.docs()}
             """
             |> Ash.CodeInterface.trim_double_newlines()

        @dialyzer {:nowarn_function, {interface.name, length(common_args)}}
        @doc spark_opts: [
               {first_opts_location, interface_options.schema()},
               {first_opts_location + 1, interface_options.schema()}
             ]
        def unquote(interface.name)(unquote_splicing(common_args)) do
          unquote(resolve_opts_params)
          unquote(resolve_subject)
          unquote(act)
        end

        @doc """
             #{action.description || "Calls the #{action.name} action on #{inspect(resource)}."}

             Raises any errors instead of returning them

             #{Ash.CodeInterface.describe_action(resource, action, interface.args)}

             ## Options

             #{interface_options.docs()}
             """
             |> Ash.CodeInterface.trim_double_newlines()
        # sobelow_skip ["DOS.BinToAtom"]
        @dialyzer {:nowarn_function, {:"#{interface.name}!", length(common_args)}}
        @doc spark_opts: [
               {first_opts_location, interface_options.schema()},
               {first_opts_location + 1, interface_options.schema()}
             ]
        def unquote(:"#{interface.name}!")(unquote_splicing(common_args)) do
          unquote(resolve_bang_opts_params)
          unquote(resolve_subject)
          unquote(act!)
        end

        # sobelow_skip ["DOS.BinToAtom"]
        if subject_name in [:changeset, :query, :input] do
          subject_opts =
            Keyword.take(interface_options.schema(), [
              :actor,
              :tenant,
              :authorize?,
              :tracer,
              :changeset,
              :query,
              :input
            ])

          @dialyzer {:nowarn_function,
                     {:"#{subject_name}_to_#{interface.name}", length(common_args)}}

          @doc spark_opts: [
                 {first_opts_location, interface_options.schema()},
                 {first_opts_location + 1, interface_options.schema()}
               ]
          @doc """
               Returns the #{subject_name} corresponding to the action.

               ## Options

               #{Spark.Options.docs(subject_opts)}
               """
               |> Ash.CodeInterface.trim_double_newlines()
          @doc spark_opts: [
                 {first_opts_location, subject_opts},
                 {first_opts_location + 1, subject_opts}
               ]
          def unquote(:"#{subject_name}_to_#{interface.name}")(unquote_splicing(common_args)) do
            unquote(resolve_opts_params)
            unquote(resolve_subject)
            unquote(subject)
          end
        end

        # sobelow_skip ["DOS.BinToAtom"]
        @doc """
             Runs authorization checks for `#{inspect(resource)}.#{action.name}`

             See `Ash.can/3` for more information

             ## Options

             #{Ash.Resource.Interface.CanOpts.docs()}
             """
             |> Ash.CodeInterface.trim_double_newlines()
        @dialyzer {:nowarn_function, {:"can_#{interface.name}", length(common_args) + 1}}
        @doc spark_opts: [
               {first_opts_location + 1, Ash.Resource.Interface.CanOpts.schema()},
               {first_opts_location + 2, Ash.Resource.Interface.CanOpts.schema()}
             ]
        def unquote(:"can_#{interface.name}")(actor, unquote_splicing(common_args)) do
          {params, opts} =
            if opts == [] && Keyword.keyword?(params_or_opts),
              do:
                {%{},
                 Ash.Resource.Interface.CanOpts.validate!(params_or_opts)
                 |> unquote(interface_options).to_options()},
              else:
                {params_or_opts,
                 Ash.Resource.Interface.CanOpts.validate!(opts)
                 |> unquote(interface_options).to_options()}

          opts = Keyword.put(opts, :actor, actor)
          unquote(resolve_subject)

          case unquote(subject) do
            %struct{} when struct in [Ash.Changeset, Ash.Query, Ash.ActionInput] ->
              Ash.can(unquote(subject), actor, opts)

            {:atomic, _, input} ->
              raise "Ash.can_#{unquote(interface.name)} does not support #{inspect(input)} as input."

            {:bulk, input} ->
              raise "Ash.can_#{unquote(interface.name)} does not support #{inspect(input)} as input."

            other ->
              raise "Ash.can_#{unquote(interface.name)} does not support #{inspect(other)} as input."
          end
        end

        # sobelow_skip ["DOS.BinToAtom"]
        @dialyzer {:nowarn_function, {:"can_#{interface.name}?", length(common_args) + 1}}
        @doc spark_opts: [
               {first_opts_location + 1, Ash.Resource.Interface.CanQuestionMarkOpts.schema()},
               {first_opts_location + 2, Ash.Resource.Interface.CanQuestionMarkOpts.schema()}
             ]
        @doc """
             Runs authorization checks for `#{inspect(resource)}.#{action.name}`, returning a boolean.

             See `Ash.can?/3` for more information

             ## Options

             #{Ash.Resource.Interface.CanQuestionMarkOpts.docs()}
             """
             |> Ash.CodeInterface.trim_double_newlines()
        def unquote(:"can_#{interface.name}?")(actor, unquote_splicing(common_args)) do
          {params, opts} =
            if opts == [] && Keyword.keyword?(params_or_opts),
              do:
                {%{},
                 Ash.Resource.Interface.CanQuestionMarkOpts.validate!(params_or_opts)
                 |> unquote(interface_options).to_options()},
              else:
                {params_or_opts,
                 Ash.Resource.Interface.CanQuestionMarkOpts.validate!(opts)
                 |> unquote(interface_options).to_options()}

          opts = Keyword.put(opts, :actor, actor)
          unquote(resolve_subject)

          case unquote(subject) do
            %struct{} when struct in [Ash.Changeset, Ash.Query, Ash.ActionInput] ->
              Ash.can?(unquote(subject), actor, opts)

            {:atomic, _, input} ->
              raise "Ash.can_#{unquote(interface.name)}? does not support #{inspect(input)} as input."

            {:bulk, input} ->
              raise "Ash.can_#{unquote(interface.name)}? does not support #{inspect(input)} as input."

            other ->
              raise "Ash.can_#{unquote(interface.name)}? does not support #{inspect(other)} as input."
          end
        end
      end
    end
  end

  def describe_action(resource, action, args) do
    resource
    |> Ash.Resource.Info.action_inputs(action.name)
    |> Enum.filter(&is_atom/1)
    |> Enum.uniq()
    |> case do
      [] ->
        ""

      inputs ->
        {arguments, inputs} = Enum.split_with(inputs, &(&1 in (args || [])))

        arguments =
          Enum.map(arguments, &describe_input(resource, action, &1))

        inputs =
          Enum.map(inputs, &describe_input(resource, action, &1))

        case {arguments, inputs} do
          {[], []} ->
            ""

          {arguments, []} ->
            """
            # Arguments

            #{Enum.join(arguments, "\n")}
            """

          {[], inputs} ->
            """
            # Inputs

            #{Enum.join(inputs, "\n")}
            """

          {arguments, inputs} ->
            """
            # Arguments

            #{Enum.join(arguments, "\n")}

            # Inputs

            #{Enum.join(inputs, "\n")}
            """
        end
    end
  end

  def describe_calculation(resource, calculation, args) do
    calculation.arguments
    |> Enum.map(& &1.name)
    |> case do
      [] ->
        ""

      inputs ->
        {arguments, inputs} = Enum.split_with(inputs, &(&1 in args))

        arguments = Enum.sort_by(arguments, fn arg -> Enum.find_index(args, &(&1 == arg)) end)

        arguments =
          Enum.map(arguments, &describe_input(resource, calculation, &1))

        inputs =
          Enum.map(inputs, &describe_input(resource, calculation, &1))

        case {arguments, inputs} do
          {[], []} ->
            ""

          {arguments, []} ->
            """
            # Arguments

            #{Enum.join(arguments, "\n")}
            """

          {[], inputs} ->
            """
            # Inputs

            #{Enum.join(inputs, "\n")}
            """

          {arguments, inputs} ->
            """
            # Arguments

            #{Enum.join(arguments, "\n")}

            # Inputs

            #{Enum.join(inputs, "\n")}
            """
        end
    end
  end

  defp describe_input(resource, %{arguments: arguments}, name) do
    case Enum.find(arguments, &(&1.name == name)) do
      nil ->
        case Ash.Resource.Info.field(resource, name) do
          nil ->
            "* #{name}"

          field ->
            describe(field)
        end

      argument ->
        describe(argument)
    end
  end

  defp describe(%{name: name, description: description}) when not is_nil(description) do
    "* #{name} - #{description}"
  end

  defp describe(%{name: name}) do
    "* #{name}"
  end

  def trim_double_newlines(str) do
    str
    |> String.replace(~r/\n{2,}/, "\n")
    |> String.trim_trailing()
  end

  @doc false
  def bulk_query(resource, method, id) do
    case method do
      :query ->
        {:ok, id}

      :stream ->
        {:ok, id}

      :id ->
        case Ash.Filter.get_filter(resource, id) do
          {:ok, filter} ->
            {:ok, Ash.Query.do_filter(resource, filter)}

          {:error, error} ->
            {:error, error}
        end
    end
  end
end