defmodule Ash.Resource.Calculation do
  @moduledoc """
  The behaviour for defining a module calculation, and the struct for storing a defined calculation.
  """

  require Ash.BehaviourHelpers

  defstruct allow_nil?: true,
            arguments: [],
            calculation: nil,
            constraints: [],
            description: nil,
            filterable?: true,
            sortable?: true,
            load: [],
            name: nil,
            public?: false,
            async?: false,
            sensitive?: false,
            type: nil,
            __spark_metadata__: nil

  @schema [
    name: [
      type: :atom,
      required: true,
      doc: "The field name to use for the calculation value"
    ],
    type: [
      type: :any,
      doc: "The type of the calculation. See `Ash.Type` for more.",
      required: true
    ],
    async?: [
      type: :boolean,
      default: false
    ],
    constraints: [
      type: :keyword_list,
      default: [],
      doc: "Constraints to provide to the type. See `Ash.Type` for more."
    ],
    calculation: [
      type: Ash.OptionsHelpers.calculation_type(),
      required: true,
      doc: """
      The `module`, `{module, opts}` or `expr(...)` to use for the calculation. Also accepts a function that takes *a list of records* and the context, and produces a result for each record.
      """
    ],
    description: [
      type: :string,
      doc: "An optional description for the calculation"
    ],
    public?: [
      type: :boolean,
      default: false,
      doc: """
      Whether or not the calculation will appear in public interfaces.
      """
    ],
    sensitive?: [
      type: :boolean,
      default: false,
      doc: """
      Whether or not references to the calculation will be considered sensitive.
      """
    ],
    load: [
      type: :any,
      default: [],
      doc: "A load statement to be applied if the calculation is used."
    ],
    allow_nil?: [
      type: :boolean,
      default: true,
      doc: "Whether or not the calculation can return nil."
    ],
    filterable?: [
      type: {:or, [:boolean, {:in, [:simple_equality]}]},
      default: true,
      doc: "Whether or not the calculation should be usable in filters."
    ],
    sortable?: [
      type: :boolean,
      default: true,
      doc: """
      Whether or not the calculation can be referenced in sorts.
      """
    ]
  ]

  defmodule Context do
    @moduledoc """
    The context and arguments of a calculation
    """

    defstruct [
      :actor,
      :tenant,
      :authorize?,
      :tracer,
      :domain,
      :resource,
      :type,
      :constraints,
      :arguments,
      source_context: %{}
    ]

    @type t :: %__MODULE__{
            actor: term | nil,
            tenant: term(),
            authorize?: boolean,
            tracer: module | nil,
            source_context: map(),
            resource: module(),
            type: Ash.Type.t(),
            constraints: Keyword.t(),
            domain: module(),
            arguments: map()
          }
  end

  @type t :: %__MODULE__{
          allow_nil?: boolean,
          arguments: [__MODULE__.Argument.t()],
          calculation: module | {module, keyword},
          constraints: keyword,
          async?: boolean,
          description: nil | String.t(),
          filterable?: boolean,
          load: keyword,
          sortable?: boolean,
          name: atom(),
          public?: boolean,
          type: nil | Ash.Type.t(),
          __spark_metadata__: Spark.Dsl.Entity.spark_meta()
        }

  @type ref :: {module(), Keyword.t()} | module()
  defmacro __using__(_) do
    quote do
      @behaviour Ash.Resource.Calculation

      @before_compile Ash.Resource.Calculation

      import Ash.Expr

      def init(opts), do: {:ok, opts}

      def describe(opts), do: "##{inspect(__MODULE__)}<#{inspect(opts)}>"

      def load(_query, _opts, _context), do: []

      defoverridable init: 1, describe: 1, load: 3
    end
  end

  defmacro __before_compile__(_) do
    quote do
      if Module.defines?(__MODULE__, {:expression, 2}) do
        def has_expression?, do: true
      else
        def has_expression?, do: false
      end

      if Module.defines?(__MODULE__, {:calculate, 3}) do
        def has_calculate?, do: true
      else
        def has_calculate?, do: false
      end

      def strict_loads?, do: true

      defoverridable strict_loads?: 0
    end
  end

  @type opts :: Keyword.t()

  @callback init(opts :: opts) :: {:ok, opts} | {:error, term}
  @callback describe(opts :: opts) :: String.t()

  @callback calculate(records :: [Ash.Resource.record()], opts :: opts, context :: Context.t()) ::
              {:ok, [term]} | [term] | {:error, term} | :unknown
  @callback expression(opts :: opts, context :: Context.t()) :: any
  @callback load(query :: Ash.Query.t(), opts :: opts, context :: Context.t()) ::
              atom | [atom] | Keyword.t()
  @callback strict_loads?() :: boolean()
  @callback has_expression?() :: boolean()

  @optional_callbacks expression: 2, calculate: 3

  def schema, do: @schema

  @spec init(module(), opts) :: {:ok, opts} | {:error, term}
  def init(module, opts) do
    Ash.BehaviourHelpers.check_type!(module, module.init(opts), [
      {:ok, opts},
      {:error, error}
    ])
  end

  @doc false
  def expr_calc(expr) when is_function(expr) do
    {:error,
     "Inline function calculations expect a function with arity 2. Got #{Function.info(expr)[:arity]}"}
  end

  def expr_calc(expr) do
    {:ok, {Ash.Resource.Calculation.Expression, expr: expr}}
  end
end
