defmodule Repatch.Mock do
  @moduledoc """
  Helper to generate modules based on protocols or behaviours. See `Repatch.Mock.define/2`.
  """

  @typedoc "A tuple defining a function name and arity"
  @type signature :: {atom(), arity()}

  @typedoc """
  Options passed in the `define/2` function.

  * `behaviour` (module or list of modules) — What modules to generate callback implementations for.
  * `protocol` (module or list of modules) — What protocols to implement. Passing this option will automatically generate a defstruct statement.
  * `only` (list of signatures) — What functions to generate.
  * `except` (`:optional` or list of signatures) — What functions to not generate.
  * `reconsolidate` (boolean) — Whether to recompile consolidated protocols or not. Default is `false`.
  * `defstruct_fields` (list) — Just a list of fields for `defstruct` statement. Default is `[]`.
  """
  @type define_option ::
          {:behaviour, [module()] | module()}
          | {:protocol, [module()] | module()}
          | {:defstruct_fields, [atom() | {atom(), any()}]}
          | {:reconsolidate, boolean()}
          | {:only, [signature()]}
          | {:except, [signature()] | :optional}

  defmodule NotImplemented do
    @moduledoc """
    This exception is raised when not yet patched
    function from the module generated by `Repatch.Mock.define/2`
    is called
    """

    defexception message: "Not implemented"
  end

  @doc """
  Creates a module implementing all functions from passed behaviours.
  Useful for creating dummy implementations for behaviours or protocols which can be patched later.

  ## Example

      iex> define(Something, behaviour: Access)
      iex> Something.fetch([], :x)
      ** (Repatch.Mock.NotImplemented) Not implemented
      iex> Repatch.patch(Something, :fetch, fn kwd, key -> Keyword.get(kwd, key) end)
      iex> Something.fetch([], :x)
      nil

  It is also possible to pass multiple behaviours or protocols at the same time:

      iex> define(SomeModule,
      ...>   behaviour: GenServer,
      ...>   behaviour: :gen_statem,
      ...>   protocol: Enumerable,
      ...>   protocol: Collectable,
      ...>   reconsolidate: true
      ...> )

  It is also advised to disable protocol consolidation when defining protocol implementations. However, if
  protocol consolidation is required during the test, you can pass the `reconsolidate: true` option.

  It is recommended to call `define/2` during test code compilation or at least in `test_support.exs`.
  """
  @spec define(atom(), [define_option()]) :: {:module, module(), binary(), term()}
  def define(name, opts) do
    behaviours =
      opts
      |> Keyword.get_values(:behaviour)
      |> List.flatten()

    protocols =
      opts
      |> Keyword.get_values(:protocol)
      |> List.flatten()

    with {[], []} <- {behaviours, protocols} do
      raise ArgumentError, message: "Expected to have at least one `:behaviour` or `:protocol`"
    end

    protocol_callbacks = protocol_callbacks(protocols)
    all_callbacks = Enum.uniq(behaviour_callbacks(behaviours, :callbacks))

    callbacks =
      case Keyword.get(opts, :only) do
        nil ->
          all_callbacks

        list when is_list(list) ->
          for callback <- list, callback not in all_callbacks do
            raise ArgumentError,
              message: "Function #{inspect(callback)} is not present in behaviours or protocols"
          end

          list
      end

    callbacks =
      case Keyword.get(opts, :except) do
        nil ->
          callbacks

        :optional ->
          callbacks -- behaviour_callbacks(behaviours, :optional_callbacks)

        list when is_list(list) ->
          callbacks -- list
      end

    body =
      case protocol_callbacks do
        [] ->
          defs(callbacks)

        [_ | _] ->
          defs = defs(callbacks)

          defstruct_line =
            quote do: defstruct(unquote(Keyword.get(opts, :defstruct_fields, [])))

          [defstruct_line | defs]
      end

    body = [quote(do: @compile(debug_info: true)) | body]

    reconsolidate? = protocols != [] && opts[:reconsolidate]
    result = Module.create(name, body, Macro.Env.location(__ENV__))
    {:module, _, binary, _} = result

    impls =
      if reconsolidate? && not Code.get_compiler_option(:ignore_already_consolidated) do
        Code.put_compiler_option(:ignore_already_consolidated, true)

        try do
          compile_impls(name, protocol_callbacks)
        after
          Code.put_compiler_option(:ignore_already_consolidated, false)
        end
      else
        compile_impls(name, protocol_callbacks)
      end

    for {name, binary} <- [{name, binary} | impls] do
      Repatch.recompile(name, module_binary: binary)
    end

    if reconsolidate? do
      reconsolidate(protocols, name)
    end

    result
  end

  defp compile_impls(name, protocol_callbacks) do
    Enum.flat_map(protocol_callbacks, fn {protocol, signatures} ->
      quote do
        defimpl unquote(protocol), for: unquote(name) do
          @compile debug_info: true
          (unquote_splicing(defs(signatures)))
        end
      end
      |> Code.compile_quoted()
    end)
  end

  defp reconsolidate(protocols, new_impl) do
    for protocol <- protocols do
      with {:consolidated, old_impls} <- protocol.__protocol__(:impls) do
        path = :code.which(protocol)
        {:ok, binary} = Protocol.consolidate(protocol, [new_impl | old_impls])
        :code.load_binary(protocol, path, binary)
      end

      []
    end

    :ok
  end

  defp defs(signatures) do
    for {func, arity} <- signatures do
      args = List.duplicate({:_, [], nil}, arity)

      quote do
        def unquote(func)(unquote_splicing(args)) do
          raise Repatch.Mock.NotImplemented
        end
      end
    end
  end

  defp protocol_callbacks([]) do
    []
  end

  defp protocol_callbacks([head | tail]) do
    [{head, head.__protocol__(:functions)} | protocol_callbacks(tail)]
  end

  defp behaviour_callbacks([], _) do
    []
  end

  defp behaviour_callbacks([head | tail], key) do
    head.behaviour_info(key) ++ behaviour_callbacks(tail, key)
  end
end
