defmodule Xema.CastError do
  @moduledoc """
  Raised when a cast fails.
  """

  alias Xema.CastError
  alias Xema.ValidationError

  defexception [:message, :path, :required, :to, :value, :key, :error]

  @type t :: %CastError{
          error: struct | nil,
          key: String.t() | atom | nil,
          message: String.t() | nil,
          path: [atom | integer | String.t()] | nil,
          required: [atom] | nil,
          to: atom | list | nil,
          value: term | nil
        }

  @indent "  "

  @impl true
  def message(%{message: nil} = exception), do: format_error(exception)

  def message(%{message: message}), do: message

  @impl true
  def blame(exception, stacktrace) do
    message = message(exception)
    {%{exception | message: message}, stacktrace}
  end

  @doc """
  Formats the error map to an error message.
  """
  @spec format_error(CastError.t()) :: String.t()
  def format_error(error) do
    error
    |> traverse_error()
    |> List.flatten()
    |> Enum.join("\n")
  end

  defp traverse_error(%{path: path, to: to, error: error, value: value}) when not is_nil(error) do
    ["cannot cast #{inspect(value)} to #{inspect(to)}, " <> error.message <> at_path(path)]
  end

  defp traverse_error(%{path: path, to: :atom, value: value}) when is_binary(value) do
    ["cannot cast #{inspect(value)} to :atom, the atom is unknown" <> at_path(path)]
  end

  defp traverse_error(%{path: path, to: to, key: {:ambiguous, key}}) do
    [
      "cannot cast #{to_string(key)} to #{inspect(to)} key" <>
        at_path(path) <> ", the key is ambiguous"
    ]
  end

  defp traverse_error(%{path: path, to: to, key: key}) when is_binary(key) do
    [
      "cannot cast #{inspect(key)} to #{inspect(to)} key" <>
        at_path(path) <> ", the atom is unknown"
    ]
  end

  defp traverse_error(%{path: path, to: to, key: key, value: value}) when not is_nil(key) do
    [
      "cannot cast #{inspect(value)} to #{inspect(to)}, " <>
        "key #{inspect(key)} not found in #{inspect(to)}" <> at_path(path)
    ]
  end

  defp traverse_error(%{path: path, to: to, value: value}) when is_list(to) do
    if Enum.all?(to, &is_atom/1) do
      ["cannot cast #{inspect(value)} to any of #{inspect(to)}" <> at_path(path)]
    else
      errors = to |> Enum.map(fn error -> traverse_error(error) end) |> indent()
      ["cannot cast #{inspect(value)}" <> at_path(path) <> " to any of:" | errors]
    end
  end

  defp traverse_error(%{path: path, to: to, value: value, required: required})
       when not is_nil(required) do
    [
      "cannot cast #{inspect(value)} to #{inspect(to)}" <>
        "#{at_path(path)} missing required keys #{inspect(required)}"
    ]
  end

  defp traverse_error(%{path: path, to: to, value: value}) do
    ["cannot cast #{inspect(value)} to #{inspect(to)}" <> at_path(path)]
  end

  defp traverse_error(%{reason: %ValidationError{} = reason, value: value} = error) do
    to = if is_nil(error.module), do: error.to, else: error.module
    ["cannot cast #{inspect(value)} to #{inspect(to)} - #{Exception.message(reason)}"]
  end

  defp at_path([]), do: ""

  defp at_path(path), do: " at #{inspect(path)}"

  defp indent(list) when is_list(list), do: Enum.map(list, &indent/1)

  defp indent(str), do: @indent <> str
end
