lib/ash/error/error.ex

defmodule Ash.Error do
  @moduledoc """
  Tools and utilities used by Ash to manage and conform errors
  """

  alias Ash.Error.{Forbidden, Framework, Invalid, Stacktrace, Unknown}
  alias Ash.Error.Unknown.UnknownError

  @type error_class() :: :forbidden | :invalid | :framework | :unknown

  @type t :: %{
          required(:__struct__) => module,
          required(:__exception__) => true,
          required(:class) => error_class(),
          required(:path) => [atom | integer],
          required(:changeset) => Ash.Changeset.t() | nil,
          required(:query) => Ash.Query.t() | nil,
          required(:error_context) => list(String.t()),
          required(:vars) => Keyword.t(),
          required(:stacktrace) => Ash.Error.Stacktrace.t() | nil,
          optional(atom) => any
        }

  # We use these error classes also to choose a single error
  # to raise when multiple errors have occurred. We raise them
  # sorted by their error classes
  @error_classes [
    :forbidden,
    :invalid,
    :framework,
    :unknown
  ]

  @error_modules [
    forbidden: Forbidden,
    invalid: Invalid,
    framework: Framework,
    unknown: Unknown
  ]

  @error_class_indices @error_classes |> Enum.with_index() |> Enum.into(%{})

  @doc false
  def error_modules, do: Keyword.values(@error_modules)

  @doc false
  def set_path(errors, path) when is_list(errors) do
    Enum.map(errors, &set_path(&1, path))
  end

  def set_path(error, path) when is_map(error) do
    path = List.wrap(path)

    error =
      if Map.has_key?(error, :path) && is_list(error.path) do
        %{error | path: path ++ error.path}
      else
        error
      end

    error =
      if Map.has_key?(error, :changeset) && error.changeset do
        %{error | changeset: %{error.changeset | errors: set_path(error.changeset.errors, path)}}
      else
        error
      end

    if Map.has_key?(error, :errors) && is_list(error.errors) do
      %{error | errors: Enum.map(error.errors, &set_path(&1, path))}
    else
      error
    end
  end

  def set_path(error, _), do: error

  def ash_error?(value) do
    !!Ash.ErrorKind.impl_for(value)
  end

  @doc """
  Conforms a term into one of the built-in Ash [Error classes](handle-errors.html#error-classes).

  The provided term would usually be an Ash Error or a list of Ash Errors.

  If the term is:
  - a map/struct/Ash Error with a key `:class` having a value `:special`,
  - a list with a single map/struct/Ash Error element as above, or
  - an `Ash.Error.Invalid` containing such a list in its `:errors` field

  then the term is returned unchanged.

  Example:
  ```elixir

  iex(1)> Ash.Error.to_error_class("oops", changeset: Ash.Changeset.new(%Post{}), error_context: "some context")
    %Ash.Error.Unknown{
      changeset: #Ash.Changeset<
        errors: [
          %Ash.Error.Unknown.UnknownError{
            changeset: nil,
            class: :unknown,
            error: "oops",
            error_context: ["some context"],
            field: nil,
            path: [],
            query: nil,
            stacktrace: #Stacktrace<>,
            vars: []
          }
        ],
        ...
      >,
      class: :unknown,
      error_context: ["some context"],
      errors: [
        %Ash.Error.Unknown.UnknownError{
          changeset: nil,
          class: :unknown,
          error: "oops",
          error_context: ["some context"],
          field: nil,
          path: [],
          query: nil,
          stacktrace: #Stacktrace<>,
          vars: []
        }
      ],
      stacktrace: #Stacktrace<>,
      stacktraces?: true,
      vars: []
    }

  ```

  Example of nested errors:
  ```elixir
    iex(1)> error1 = Ash.Error.to_ash_error("whoops!", nil, error_context: "some context")
    iex(2)> error2 = Ash.Error.to_ash_error("whoops, again!!", nil, error_context: "some other context")
    iex(3)> Ash.Error.to_error_class([error1, error2], error_context: "some higher context")
    %Ash.Error.Unknown{
      changeset: nil,
      class: :unknown,
      error_context: ["some higher context"],
      errors: [
        %Ash.Error.Unknown.UnknownError{
          changeset: nil,
          class: :unknown,
          error: "whoops!",
          error_context: ["some higher context", "some context"],
          field: nil,
          path: [],
          query: nil,
          stacktrace: #Stacktrace<>,
          vars: []
        },
        %Ash.Error.Unknown.UnknownError{
          changeset: nil,
          class: :unknown,
          error: "whoops, again!!",
          error_context: ["some higher context", "some other context"],
          field: nil,
          path: [],
          query: nil,
          stacktrace: #Stacktrace<>,
          vars: []
        }
      ],
      path: [],
      query: nil,
      stacktrace: #Stacktrace<>,
      stacktraces?: true,
      vars: []
    }

  ```

  Options:
  - `changeset`: a changeset related to the error
  - `query`: a query related to the error
  - `error_context`: a sting message providing extra context around the error
  """
  def to_error_class(values, opts \\ [])

  def to_error_class(%{class: :special} = special, _opts) do
    special
  end

  def to_error_class([%{class: :special} = special], _opts) do
    special
  end

  def to_error_class(%Ash.Error.Invalid{errors: [%{class: :special} = special]}, _opts) do
    special
  end

  def to_error_class(values, opts) when is_list(values) do
    values =
      if Keyword.keyword?(opts) do
        [to_ash_error(values, nil, Keyword.delete(opts, :error_context))]
      else
        Enum.map(values, &to_ash_error(&1, nil, Keyword.delete(opts, :error_context)))
      end

    case values do
      [%{class: :special} = exception] ->
        exception

      values ->
        values =
          values
          |> flatten_preserving_keywords()
          |> Enum.uniq_by(&clear_stacktraces/1)
          |> Enum.map(fn value ->
            if ash_error?(value) do
              value
            else
              UnknownError.exception(error: value)
            end
          end)
          |> Enum.uniq()

        values
        |> accumulate_error_context(opts[:error_context])
        |> choose_error(opts[:changeset] || opts[:query])
        |> add_error_context(opts[:error_context])
    end
  end

  def to_error_class(value, opts) do
    value = to_ash_error(value, nil, Keyword.delete(opts, :error_context))

    if value.__struct__ in Keyword.values(@error_modules) do
      value
      |> add_changeset_or_query([value], opts[:changeset] || opts[:query])
      |> Map.put(:error_context, [opts[:error_context] | value.error_context])
    else
      to_error_class([value], opts)
    end
  end

  @doc """
  Converts a term into an Ash Error.

  The term could be a simple string, the second element in an `{:error, error}` tuple, an Ash Error, or a list of any of these.
  In most cases the returned error is an Ash.Error.Unknown.UnknownError.

  A stacktrace is added to the error, and any existing stacktrace (i.e. when the term is an Ash Error) is preserved.

  `to_ash_error` converts string(s) into UnknownError(s):
  ```elixir
    iex(1)> Ash.Error.to_ash_error("whoops!", nil, error_context: "some context")
    %Ash.Error.Unknown.UnknownError{
      changeset: nil,
      class: :unknown,
      error: "whoops!",
      error_context: ["some context"],
      field: nil,
      path: [],
      query: nil,
      stacktrace: #Stacktrace<>,
      vars: []
    }

    iex(2)> Ash.Error.to_ash_error(["whoops!", "whoops, again!!"], nil, error_context: "some context")
    [
      %Ash.Error.Unknown.UnknownError{
        changeset: nil,
        class: :unknown,
        error: "whoops!",
        error_context: ["some context"],
        field: nil,
        path: [],
        query: nil,
        stacktrace: #Stacktrace<>,
        vars: []
      },
      %Ash.Error.Unknown.UnknownError{
        changeset: nil,
        class: :unknown,
        error: "whoops, again!!",
        error_context: ["some context"],
        field: nil,
        path: [],
        query: nil,
        stacktrace: #Stacktrace<>,
        vars: []
      }
    ]
  ```

  `to_ash_error` can preserve error-like data from a keyword-list and accumulate context if called against an Ash Error:
  ```elixir
    iex(1)> err = Ash.Error.to_ash_error([vars: [:some_var], message: "whoops!"], nil, error_context: " some context")
    %Ash.Error.Unknown.UnknownError{
      changeset: nil,
      class: :unknown,
      error: "whoops!",
      error_context: ["some context"],
      field: nil,
      path: [],
      query: nil,
      stacktrace: #Stacktrace<>,
      vars: [:some_var]
    }
    iex(2)> Ash.Error.to_ash_error(err, nil, error_context: "some higher context")
    %Ash.Error.Unknown.UnknownError{
      changeset: nil,
      class: :unknown,
      error: "whoops!",
      error_context: ["some higher context", "some context"],
      field: nil,
      path: [],
      query: nil,
      stacktrace: #Stacktrace<>,
      vars: [:some_var]
    }
  ```

  Options:
  - `error_context`: a sting message providing extra context around the error
  """
  def to_ash_error(list, stacktrace \\ nil, opts \\ [])

  def to_ash_error(list, stacktrace, opts) when is_list(list) do
    if Keyword.keyword?(list) do
      list
      |> Keyword.take([:error, :vars])
      |> Keyword.put_new(:error, list[:message])
      |> Keyword.put_new(:value, list)
      |> UnknownError.exception()
      |> add_stacktrace(stacktrace)
      |> add_error_context(opts[:error_context])
    else
      Enum.map(list, &to_ash_error(&1, stacktrace, opts))
    end
  end

  def to_ash_error(%NimbleOptions.ValidationError{message: message}, stacktrace, opts) do
    to_ash_error(Ash.Error.Action.InvalidOptions.exception(message: message), stacktrace, opts)
  end

  def to_ash_error(%Ash.Changeset{errors: errors}, stacktrace, opts) do
    to_ash_error(errors, stacktrace, opts)
  end

  def to_ash_error(%Ash.Query{errors: errors}, stacktrace, opts) do
    to_ash_error(errors, stacktrace, opts)
  end

  def to_ash_error(error, stacktrace, opts) when is_binary(error) do
    [error: error]
    |> UnknownError.exception()
    |> add_stacktrace(stacktrace)
    |> add_error_context(opts[:error_context])
  end

  def to_ash_error(other, stacktrace, opts) do
    cond do
      ash_error?(other) ->
        other
        |> add_stacktrace(stacktrace)
        |> add_error_context(opts[:error_context])

      is_exception(other) ->
        [error: Exception.format(:error, other)]
        |> UnknownError.exception()
        |> add_stacktrace(stacktrace)
        |> add_error_context(opts[:error_context])

      true ->
        [error: "unknown error: #{inspect(other)}"]
        |> UnknownError.exception()
        |> add_stacktrace(stacktrace)
        |> add_error_context(opts[:error_context])
    end
  end

  defp add_stacktrace(%{stacktrace: _} = error, stacktrace) do
    stacktrace =
      case stacktrace do
        %Stacktrace{stacktrace: nil} ->
          nil

        nil ->
          nil

        stacktrace ->
          %Stacktrace{stacktrace: stacktrace}
      end

    %{error | stacktrace: stacktrace || error.stacktrace || fake_stacktrace()}
  end

  defp add_stacktrace(e, _), do: e

  defp fake_stacktrace do
    {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
    %Stacktrace{stacktrace: Enum.drop(stacktrace, 2)}
  end

  defp add_error_context(error, error_context) when is_binary(error_context) do
    %{error | error_context: [error_context | error.error_context]}
  end

  defp add_error_context(error, _) do
    error
  end

  defp accumulate_error_context(%{errors: [_ | _] = errors} = error, error_context)
       when is_binary(error_context) do
    updated_errors = accumulate_error_context(errors, error_context)

    %{error | errors: updated_errors}
  end

  defp accumulate_error_context(errors, error_context)
       when is_list(errors) and is_binary(error_context) do
    errors
    |> Enum.map(fn err ->
      err
      |> add_error_context(error_context)
      |> accumulate_error_context(error_context)
    end)
  end

  defp accumulate_error_context(error, _) do
    error
  end

  @doc "A utility to flatten a list, but preserve keyword list elements"
  def flatten_preserving_keywords(list) do
    if Keyword.keyword?(list) do
      [list]
    else
      Enum.flat_map(list, fn item ->
        cond do
          Keyword.keyword?(item) ->
            [item]

          is_list(item) ->
            item

          true ->
            [item]
        end
      end)
    end
  end

  def clear_stacktraces(%{stacktrace: stacktrace} = error) when not is_nil(stacktrace) do
    clear_stacktraces(%{error | stacktrace: nil})
  end

  def clear_stacktraces(%{errors: errors} = exception) when is_list(errors) do
    %{exception | errors: Enum.map(errors, &clear_stacktraces/1)}
  end

  def clear_stacktraces(error), do: error

  def choose_error(errors, changeset_or_query \\ nil)

  def choose_error([], changeset_or_query) do
    error = Ash.Error.Unknown.exception([])

    add_changeset_or_query(error, [], changeset_or_query)
  end

  def choose_error(errors, changeset_or_query) do
    errors = Enum.map(errors, &to_ash_error/1)

    [error | other_errors] =
      Enum.sort_by(errors, fn error ->
        # the second element here sorts errors that are already parent errors
        {Map.get(@error_class_indices, error.class),
         @error_modules[error.class] != error.__struct__}
      end)

    parent_error_module = @error_modules[error.class]

    top_level_error =
      if parent_error_module == error.__struct__ do
        %{error | errors: (error.errors || []) ++ other_errors}
      else
        parent_error_module.exception(errors: errors)
      end

    add_changeset_or_query(top_level_error, errors, changeset_or_query)
  end

  defp add_changeset_or_query(error, errors, changeset_or_query) do
    changeset = error.changeset || error.query || changeset_or_query

    if changeset_or_query do
      changeset_or_query = %{
        changeset_or_query
        | action_failed?: true,
          errors: List.wrap(errors) ++ changeset.errors
      }

      case changeset_or_query do
        %Ash.Changeset{} = changeset ->
          %{error | changeset: %{changeset | errors: Enum.uniq(changeset.errors)}}

        %Ash.Query{} = query ->
          %{error | query: %{query | errors: Enum.uniq(query.errors)}}
      end
    else
      error
    end
  end

  def error_messages(errors, custom_message, stacktraces?) do
    errors = Enum.map(errors, &to_ash_error/1)

    generic_message =
      errors
      |> List.wrap()
      |> Enum.group_by(& &1.class)
      |> Enum.sort_by(fn {group, _} -> Map.get(@error_class_indices, group) end)
      |> Enum.map_join("\n\n", fn {class, class_errors} ->
        header = header(class) <> "\n\n"

        if stacktraces? do
          header <>
            Enum.map_join(class_errors, "\n", fn
              error when is_binary(error) ->
                "* #{error}"

              %{stacktrace: %Stacktrace{stacktrace: stacktrace}} = class_error ->
                breadcrumb(class_error.error_context) <>
                  "* #{Exception.message(class_error)}\n" <>
                  path(class_error) <>
                  Enum.map_join(stacktrace, "\n", fn stack_item ->
                    "  " <> Exception.format_stacktrace_entry(stack_item)
                  end)
            end)
        else
          header <>
            Enum.map_join(class_errors, "\n", fn
              class_error when is_binary(class_error) ->
                "* #{class_error}"

              class_error ->
                breadcrumb(class_error.error_context) <>
                  "* #{Exception.message(class_error)}" <> path(class_error)
            end)
        end
      end)

    if custom_message do
      custom =
        custom_message
        |> List.wrap()
        |> Enum.map_join("\n", &"* #{&1}")

      "\n\n" <> custom <> generic_message
    else
      generic_message
    end
  end

  def error_descriptions(errors) do
    errors
    |> Kernel.||([])
    |> Enum.group_by(& &1.class)
    |> Enum.sort_by(fn {group, _} -> Map.get(@error_class_indices, group) end)
    |> Enum.map_join("\n\n", fn {class, class_errors} ->
      header = header(class) <> "\n\n"

      header <> Enum.map_join(class_errors, "\n", &"* #{Exception.message(&1)}")
    end)
  end

  defp path(%{path: path}) when path not in [[], nil] do
    "    at " <> to_path(path) <> "\n"
  end

  defp path(_), do: ""

  defp to_path(path) do
    Enum.map_join(path, ", ", fn item ->
      if is_list(item) do
        "[#{to_path(item)}]"
      else
        if is_binary(item) || is_atom(item) || is_number(item) do
          item
        else
          inspect(item)
        end
      end
    end)
  end

  defp header(:forbidden), do: "Forbidden"
  defp header(:invalid), do: "Input Invalid"
  defp header(:framework), do: "Framework Error"
  defp header(:unknown), do: "Unknown Error"

  @doc false
  def breadcrumb(nil), do: ""
  def breadcrumb([]), do: ""

  def breadcrumb(error_context) do
    case Enum.filter(error_context, & &1) do
      [] ->
        ""

      bread_crumbs ->
        "Context: " <> Enum.join(bread_crumbs, " > ") <> "\n"
    end
  end
end