defmodule Protoss do
  @moduledoc """
  Protoss is a utility library that allows you to define protocols implementations
  which automatically delegate back to the module that is the caller of the protocol.

  ### Automatic delegation of protocol functions

  The primary purpose of `Protoss` is to automatically delegate protocol functions.
  As an example, here is standard elixir code for implementing a protocol:

  ```elixir
  defprotocol Proto do
    def unwrap(term)
  end

  defmodule MyStruct do
    defstruct [:value]
    defimpl Proto do
      def unwrap(term), do: term.value
    end
  end
  ```

  Protoss makes this easier to understand by hiding the `defimpl` boilerplate module.
  This is a natural fit for Elixir's convention of having module functions whose
  first parameter is the module's datatype.

  ```elixir
  use Protoss
  defprotocol Proto do
    def unwrap(term)
  end

  defmodule MyStruct do
    use Proto
    defstruct [:value]
    def unwrap(term), do: term.value
  end
  ```

  ### Directly delegated functions.

  Protoss also allows you to define functions that get directly delegated to the
  implementation module, that are then logically associated with the protocol, and
  whose existence is checked by the compiler using the protocol behaviour.

  A common use case would be a `from_json` function which reifies json data into a
  struct.

  #### Example:

  ```elixir
  use Protoss
  defprotocol Proto do
    def unwrap(term)
    defdelegate from_json(module, json)
  end

  defmodule MyStruct do
    use Proto
    defstruct [:value]
    def unwrap(term), do: term.value

    # NOTE the arity of the function
    def from_json(%{"value" => v}), do: %__MODULE__{value: v}
  end
  ```

  ### Protocol body functions

  Protoss allows you to write arbitrary functions in the protocol body
  that aren't necessarily part of the protocol itself, using the `after` keyword.
  This could be useful to reduce code duplication if you have a common processing
  step that must occur in conjunction with a pseudo-private protocol function,
  or even if you simply have a thematically relevant function that you would like
  to incorporate  into the same namespace.

  ```elixir
  use Protoss
  defprotocol Proto do
    def _unwrap_impl(term)
  after
    def unwrap(struct) do
      struct
      |> _unwrap_impl()
      |> SomeOtherModule.process()
    end
  end

  defmodule MyStruct do
    use Proto
    defstruct [:value]
    def _unwrap_impl(term), do: term.value
  end
  ```

  ### Inheriting `using`

  You may inherit the `__using__` macro in the `after` block of the protocol, simply
  by including any `defmacro __using__(...)` header in the `after` block.
  All code generated by this `__using__` macro will be executed prior to defining
  the implementation submodule.

  One way in which this is useful is to create a series of default implementations.

  ```elixir
  defmacro __using__(opts) do
    if opts[:default] do
      quote do
        def do_something_or_nil(struct), do: nil
      end
    end
  end
  ```
  """

  defmacro __using__(_) do
    quote do
      import Kernel, except: [defprotocol: 2]
      import Protoss, only: [defprotocol: 2, defdelegate: 1]
    end
  end

  defmacro defprotocol(proto_module, [{:do, body} | rest]) do
    delegations = Macro.escape(scan_delegation(body))

    after_content = Keyword.get(rest, :after, [])
    after_callbacks = scan_callbacks(after_content)
    after_code = Macro.escape(after_content)

    extended =
      quote do
        @before_compile Protoss
        Module.put_attribute(__MODULE__, :__protoss_delegations__, unquote(delegations))
        Module.put_attribute(__MODULE__, :__protoss_after_content__, unquote(after_code))
        Module.put_attribute(__MODULE__, :__protoss_after_callbacks__, unquote(after_callbacks))
        unquote(body)
      end

    quote do
      Kernel.defprotocol(unquote(proto_module), do: unquote(extended))
    end
  end

  defmacro defdelegate(_), do: []

  defmacro __before_compile__(%{module: module}) do
    # this before_compile is called in the protocol body, so we should
    # be generating at a minimum, the `using` content.

    delegations =
      module
      |> Module.get_attribute(:__protoss_delegations__)
      |> Enum.group_by(&elem(elem(&1, 0), 0), &{elem(elem(&1, 0), 1), elem(&1, 1)})
      |> Map.put_new(:module, [])

    protocol_delegations = Macro.escape(delegations.protocol)

    module_delegations =
      Enum.map(delegations.module, fn {name, [module | rest] = params} ->
        quote do
          def unquote(name)(unquote_splicing(params)) do
            unquote(module).unquote(name)(unquote_splicing(rest))
          end
        end
      end)

    specs = Module.get_attribute(module, :spec)

    module_delegations_callbacks =
      Enum.map(delegations.module, fn {name, delegation_params} ->
        spec =
          Enum.flat_map(specs, fn
            {:spec, {:"::", _, [{^name, _, spec_params} | _]} = spec_ast, _}
            when length(spec_params) == length(delegation_params) ->
              [spec_ast]

            _ ->
              []
          end)

        case spec do
          [] ->
            params_types =
              List.duplicate(
                quote do
                  term()
                end,
                length(delegation_params) - 1
              )

            quote do
              @callback unquote(name)(unquote_splicing(params_types)) :: term()
            end

          specs ->
            Enum.map(specs, fn {:"::", meta1, [{^name, meta2, spec_params} | rest]} ->
              new_callback = {:"::", meta1, [{name, meta2, tl(spec_params)} | rest]}

              quote do
                @callback unquote(new_callback)
              end
            end)
        end
      end)

    after_content = Module.get_attribute(module, :__protoss_after_content__)
    after_callbacks = Module.get_attribute(module, :__protoss_after_callbacks__)
    has_using? = has_using?(after_content)

    using_override = if has_using? do
      quote do
        defoverridable __using__: 1
      end
    end

    using_code = if has_using? do
      quote do
        super(opts)
      end
    end

    quote do
      import Protocol, only: []
      import Kernel

      unquote_splicing(module_delegations_callbacks)
      unquote_splicing(module_delegations)
      unquote(after_content)

      unquote(using_override)

      defmacro __using__(opts) do
        protocol = __MODULE__
        caller = __CALLER__.module

        using_code = unquote(using_code)

        delegates =
          Enum.map(unquote(protocol_delegations), fn
            {fun, params} ->
              quote do
                defdelegate unquote(fun)(unquote_splicing(params)), to: unquote(caller)
              end
          end)

        suppress_delegations =
          Enum.map(unquote(Macro.escape(delegations.module)), fn
            {fun, params} ->
              Protoss._empty_function(fun, length(params) - 1)
          end)

        suppress_callbacks =
          Enum.map(unquote(after_callbacks), fn
            {fun, param_count} ->
              Protoss._empty_function(fun, param_count)
          end)

        quote do
          unquote(using_code)
          @behaviour unquote(protocol)
          defimpl unquote(protocol) do
            unquote(delegates)
            # here we need to suppress callbacks that were declared as a part of basic protocols
            unquote(suppress_delegations)
            unquote(suppress_callbacks)
          end
        end
      end
    end
  end

  defp scan_delegation(ast) do
    {_, defs} =
      Macro.prewalk(ast, [], fn
        {:def, _, [{name, _, args}]} = ast, so_far ->
          {ast, [{{:protocol, name}, args} | so_far]}

        {:defdelegate, _, [{name, _, args}]} = ast, so_far ->
          {ast, [{{:module, name}, args} | so_far]}

        ast, so_far ->
          {ast, so_far}
      end)

    defs
  end

  defp scan_callbacks(ast) do
    {_, cbs} =
      Macro.prewalk(ast, [], fn
        {:@, _, [{:callback, _, [{:"::", _, [{name, _, count} | _]}]}]} = ast, so_far ->
          {ast, [{name, length(count)} | so_far]}

        ast, so_far ->
          {ast, so_far}
      end)

    cbs
  end

  @doc false
  def _empty_function(name, args_count) do
    empty_args =
      List.duplicate(
        quote do
          _
        end,
        args_count
      )

    quote do
      def unquote(name)(unquote_splicing(empty_args)) do
        raise "unreachable"
      end
    end
  end

  defp has_using?(ast) do
    ast
    |> Macro.prewalk(false, fn 
      {:defmacro, _, [{:__using__, _, [_]}, _]}, false -> {[], true}
      other, false -> {other, false}
      _, true -> {[], true}
    end)
    |> elem(1)
  end
end
