defmodule JSV.Debanger do
  @moduledoc false
  defmacro __using__(opts) do
    quote do
      import unquote(__MODULE__)
      @before_compile unquote(__MODULE__)
      @__debang_nowrap_records unquote(List.wrap(opts[:records]))
      Module.register_attribute(__MODULE__, :__debang_bang_funs, accumulate: true)
    end
  end

  defmacro debang(call) do
    {:def, meta, [{fun, _, args}]} = call

    quote do
      @__debang_bang_funs {unquote(fun), unquote(meta), unquote(Macro.escape(args))}
      unquote(call)
    end
  end

  defmacro __before_compile__(env) do
    bang_funs = Module.get_attribute(env.module, :__debang_bang_funs)
    specs = Module.get_attribute(env.module, :spec)

    quoted_unbanged =
      Enum.map(bang_funs, fn {bang_fun, bang_meta, args} ->
        {:spec, {:"::", _, [{^bang_fun, _, arg_types}, return_type]}, _} = find_spec!(specs, bang_fun)

        args_no_defaults = args_no_defaults(args)
        tuple_return_type = return_type_to_tuple_type(return_type)
        nobang_fun = debang_atom(bang_fun)

        quoted =
          quote do
            @doc """
            Same as `#{unquote(bang_fun)}/#{unquote(length(args))}` but rescues
            errors and returns a result tuple.
            """
            @spec unquote(nobang_fun)(unquote_splicing(arg_types)) :: unquote(tuple_return_type)
            def unquote(nobang_fun)(unquote_splicing(args)) do
              __debang_wrap__(unquote(bang_fun)(unquote_splicing(args_no_defaults)))
            rescue
              e -> {:error, e}
            end
          end

        Macro.postwalk(quoted, fn
          node -> Macro.update_meta(node, &Keyword.merge(bang_meta, &1))
        end)
      end)

    quote generated: true do
      unquote(quoted_unbanged)

      if @__debang_nowrap_records != [] do
        defp __debang_wrap__(value) when is_tuple(value) and elem(value, 0) in @__debang_nowrap_records do
          {:ok, value}
        end
      end

      defp __debang_wrap__(value) do
        unquote(__MODULE__).__wrap__(value)
      end
    end
  end

  defp find_spec!(specs, bang_fun) do
    spec =
      Enum.find_value(specs, fn
        {:spec, {:"::", _, [{^bang_fun, _, _}, _]}, _} = spec -> spec
        _ -> nil
      end)

    if spec == nil do
      raise "could not debang fun #{inspect(bang_fun)}, no spec found"
    end

    spec
  end

  defp return_type_to_tuple_type(return_type) do
    case return_type do
      {:{}, meta, tuple_vals} ->
        {:|, meta,
         [
           {:{}, meta, [:ok | tuple_vals]},
           {:error,
            quote do
              Exception.t()
            end}
         ]}

      #  2-tuples are not quoted

      {t1, t2} ->
        {:|, [],
         [
           {:{}, [], [:ok, t1, t2]},
           {:error, {{:., [], [:"Elixir.Exception", :t]}, [], []}}
         ]}

      raw_type ->
        {:|, [],
         [
           {:ok, raw_type},
           {:error, {{:., [], [:"Elixir.Exception", :t]}, [], []}}
         ]}
    end
  end

  defp debang_atom(atom) do
    atom
    |> Atom.to_string()
    |> tap(&(true = String.ends_with?(&1, "!")))
    |> String.trim_trailing("!")
    |> String.to_atom()
  end

  defp args_no_defaults(args) do
    Macro.prewalk(args, fn
      {:\\, _, [var, _default]} -> var
      ast -> ast
    end)
  end

  @spec __wrap__(term) :: tuple
  def __wrap__(value) do
    case value do
      {a, b} -> {:ok, a, b}
      {a, b, c} -> {:ok, a, b, c}
      {a, b, c, d} -> {:ok, a, b, c, d}
      {a, b, c, d, e} -> {:ok, a, b, c, d, e}
      v when not is_tuple(v) -> {:ok, v}
    end
  end
end
