defmodule Crudry.Middlewares.TranslateErrors do
  @moduledoc """
  Absinthe Middleware to translate errors and changeset errors into human readable messages. It support nested changeset errors and internationalization, using [Gettext](https://github.com/elixir-lang/gettext).

  ## Usage

  `Crudry.Translator` is used by default to translate error messages to the default locale `en`. You can also use your own Gettext module by adding it to your Absinthe's schema `context/1` function:

      def context(context) do
        Map.put(context, :translator, MyAppWeb.Gettext)
      end

  Or just override the default locale in your [Context Plug](https://hexdocs.pm/absinthe/context-and-authentication.html#context-and-plugs):

      def call(conn, _) do
        Absinthe.Plug.put_options(conn, context: %{locale: "pt_BR"})
      end

  Then, to handle errors for a field, add it after the resolve, using [`middleware/2`](https://hexdocs.pm/absinthe/Absinthe.Middleware.html#module-the-middleware-2-macro):

      alias Crudry.Middlewares.TranslateErrors

      field :create_user, :user do
        arg :params, non_null(:user_params)

        resolve &UsersResolver.create_user/2
        middleware TranslateErrors
      end

  To handle errors for all fields, use [middleware/3](https://hexdocs.pm/absinthe/Absinthe.Middleware.html#module-object-wide-authentication):

      alias Crudry.Middlewares.TranslateErrors

      def middleware(middleware, _field, _object) do
        middleware ++ [TranslateErrors]
      end

  ## Examples

  For a simple changeset error:

      #Ecto.Changeset<
        action: nil,
        changes: %{},
        errors: [username: {"can't be blank", [validation: :required]}],
        data: #Crudry.User<>,
        valid?: false
      >

  The resulting error will be `["username can't be blank"]`

  For a changeset with nested errors:

      #Ecto.Changeset<
        action: nil,
        changes: %{
          posts: [
            #Ecto.Changeset<
              action: :insert,
              changes: %{},
              errors: [
                title: {"can't be blank", [validation: :required]},
                user_id: {"can't be blank", [validation: :required]}
              ],
              data: #Crudry.Post<>,
              valid?: false
            >
          ]
        },
        errors: [
          username: {"should be at least %{count} character(s)",
          [count: 2, validation: :length, kind: :min]}
        ],
        data: #Crudry.User<>,
        valid?: false
      >

  The resulting error will be `["posts: title can't be blank", "posts: user_id can't be blank", "username should be at least 2 character(s)"]`
  """

  @behaviour Absinthe.Middleware

  alias Crudry.Translator

  alias Absinthe.Resolution
  alias Ecto.Changeset

  def call(%{errors: errors} = resolution, _config) do
    translator = get_translator(resolution)
    locale = get_locale(resolution, translator)

    Resolution.put_result(resolution, {:error, Enum.flat_map(errors, & handle_error(&1, translator, locale))})
  end

  defp get_translator(%{context: %{translator: translator}}), do: translator
  defp get_translator(_res), do: Translator

  defp get_locale(%{context: %{locale: locale}}, _translator), do: locale
  defp get_locale(_res, translator), do: Gettext.get_locale(translator)

  def handle_error(%Ecto.Changeset{} = changeset, translator, locale) do
    changeset
    |> Changeset.traverse_errors(& translate_error(&1, translator, locale))
    |> Enum.map(fn {key, value} -> message_to_string(key, value, translator, locale) end)
    |> List.flatten()
  end

  def handle_error(%{message: message, schema: schema}, translator, locale) do
    translated_message = translate_with_domain(translator, locale, :errors_domain, message)

    schema
    |> message_to_string([translated_message], translator, locale)
    |> List.wrap()
  end

  def handle_error(error, translator, locale) when is_binary(error) do
    translator
    |> translate_with_domain(locale, :errors_domain, error)
    |> List.wrap()
  end

  # If it's a map, a number or a keyword list, we don't try to translate
  def handle_error(error, _translator, _locale) do
    List.wrap(error)
  end

  defp translate_with_domain(translator, locale, domain, msgid, bindings \\ %{}) do
    Gettext.with_locale(locale, fn ->
      Gettext.dgettext(translator, get_domain(translator, domain), msgid, bindings)
    end)
  end

  # The error message is a tuple like this:
  # {"should be at least %{count} characters", [count: 3, validation: :length, min: 3]}
  # So here we translate it to become like this:
  # "should be at least 3 characters"
  defp translate_error({error, opts}, translator, locale) do
    translate_with_domain(translator, locale, :errors_domain, error, opts)
  end

  defp get_domain(translator, domain) do
    try do
      apply(translator, domain, [])
    rescue
      _error -> apply(Translator, domain, [])
    end
  end

  # Simple case (e.g. key: `label`, value: `"does not exist"`)
  # Just concatenate strings
  defp message_to_string(key, [value], translator, locale) when is_binary(value) do
    translated_field = translate_with_domain(translator, locale, :schemas_domain, to_string(key))

    "#{translated_field} #{value}"
  end

  # Nested case, like this:
  # %{project_workflow_steps: [%{map: %{definition: ["cant be blank"]}}, %{}]}
  # key: `project_workflow_steps`, value: `[%{map: %{definition: ["cant be blank"]}}, %{}]`
  # Remove empty maps and recursively convert nested messages to string
  defp message_to_string(key, value, translator, locale) when is_list(value) do
    value
    |> Enum.reject(fn x -> x == %{} end)
    |> Enum.map(fn
      string when is_binary(string) ->
        message_to_string(key, [string], translator, locale)

      enumerable when is_list(enumerable) or is_map(enumerable) ->
        Enum.map(enumerable, fn {inner_key, inner_value} ->
          message_to_string(key, Map.new([{inner_key, inner_value}]), translator, locale)
        end)
    end)
  end

  # When there are no more nested errors, the input is
  # key: `map`, value: `%{definition: ["cant be blank"]}`
  # Only add `key: ` to the start of the string if this is the last level of nesting.
  defp message_to_string(key, %{} = value, translator, locale) do
    translated_key = translate_with_domain(translator, locale, :schemas_domain, to_string(key))

    Enum.map(value, fn {inner_key, inner_value} ->
      translated_inner_value = message_to_string(inner_key, inner_value, translator, locale)

      case get_value(inner_value) do
        %{} -> translated_inner_value
        _ -> "#{translated_key}: #{translated_inner_value}"
      end
    end)
  end

  defp get_value(%{} = value), do: value
  defp get_value(value), do: List.first(value)
end
