defmodule Promox do
  @moduledoc """
  Documentation for `Promox`.
  """

  defmodule UnexpectedCallError do
    defexception [:message]
  end

  defmodule VerificationError do
    defexception [:message]
  end

  @doc """
  Enables mock `:for` the given protocol.

  ## Caveats

  Since `defmock/1` is a macro, so remember to `require Promox` before calling `Promox.defmock/1`.
  ```
  require Promox

  Promox.defmock(for: MyProtocol)
  ```
  """
  defmacro defmock(for: protocol) do
    quote bind_quoted: [protocol: protocol] do
      defimpl protocol, for: Promox do
        for {fun, arity} <- protocol.__protocol__(:functions) do
          args = Macro.generate_arguments(arity - 1, __MODULE__)

          def unquote(fun)(mock, unquote_splicing(args)) do
            Promox.call(
              mock,
              {unquote(protocol), unquote(fun), unquote(arity)},
              [mock | unquote(args)]
            )
          end
        end
      end
    end
  end

  @enforce_keys [:agent]
  defstruct [:agent]

  @doc """
  Initialize a new mock struct.
  ```
  my_mock = Promox.new()

  # Promox.expect(my_mock, MyProtocol, :callback, fn _mock, ... -> ... end)
  ```

  Since mocks are just isolated data structures, you can use them in concurrent processes.
  ```
  mock1 =
    Promox.new()
    |> Promox.expect(MyProtocol, :callback, fn _mock, ... -> :ok end)

  mock2 =
    Promox.new()
    |> Promox.expect(MyProtocol, :callback, fn _mock, ... -> :error end)

  assert :ok = MyProtocol.callback(mock1, ...)
  assert :error =
          fn -> MyProtocol.callback(mock2, ...) end
          |> Task.async()
          |> Task.await()
  ```
  """
  def new() do
    {:ok, agent} = Agent.start_link(Promox.State, :new, [])

    %__MODULE__{agent: agent}
  end

  @doc """
  Allows the `protocol.name` callback with arity given by `code` to be invoked with `mock` any times.
  The call would be delegated to `code` and returns whatever `code` returns.

  ## Caveat
  1. The first argument passed to `code` is always the `mock` being stubbed.
  2. `stub/4` will overwrite any previous calls to `stub/4`
  3. If expectations and stubs are defined for the same function and arity, the stub is invoked only after all expectations are fulfilled.

  ## Examples

  To allow `MyProtocol.callback/1` to be called any times:

  ```
  my_mock =
    Promox.new()
    |> Promox.stub(MyProtocol, :callback, fn _mock -> :ok end)
  ```
  """
  def stub(mock, protocol, name, code) do
    verify_protocol!(protocol, mock)
    verify_callback!(protocol, name, code)

    :ok = Agent.update(mock.agent, &Promox.State.stub(&1, protocol, name, code))

    mock
  end

  @doc """
  Expects the `protocol.name` callback with arity given by `code` to be invoked with `mock` `n` times.

  ## Examples

  To expect `MyProtocol.callback/1` to be called once:

  ```
  my_mock =
    Promox.new()
    |> Promox.expect(MyProtocol, :callback, fn _mock -> :ok end)
  ```

  To expect `MyProtocol.callback/1` to be called five times:

  ```
  my_mock =
    Promox.new()
    |> Promox.expect(MyProtocol, :callback, 5, fn _mock -> :ok end)
  ```
  """
  def expect(mock, protocol, name, n \\ 1, code) do
    verify_protocol!(protocol, mock)
    verify_callback!(protocol, name, code)

    :ok = Agent.update(mock.agent, &Promox.State.expect(&1, protocol, name, n, code))

    mock
  end

  defp verify_protocol!(protocol, mock) do
    case protocol.impl_for(mock) do
      nil ->
        raise ArgumentError,
              "unmocked Protocol #{inspect(protocol)}. Call Promox.defmock(for: #{inspect(protocol)}) first."

      _ ->
        :ok
    end
  end

  defp verify_callback!(protocol, name, code) do
    {:arity, arity} = Function.info(code, :arity)

    if Enum.find(protocol.__protocol__(:functions), &(&1 == {name, arity})),
      do: :ok,
      else:
        raise(
          ArgumentError,
          "unknown callback function #{Exception.format_mfa(protocol, name, arity)}"
        )
  end

  @doc false
  def call(mock, pfa, args) do
    mock.agent
    |> Agent.get_and_update(&Promox.State.retrieve(&1, pfa))
    |> case do
      nil ->
        {protocol, fun, arity} = pfa

        raise UnexpectedCallError,
              "no expectation defined for #{Exception.format_mfa(protocol, fun, arity)}"

      fun when is_function(fun) ->
        apply(fun, args)
    end
  end

  @doc """
  Verifys that all the expectations set for the `mock` have been called.
  Returns `:ok` if so;
  Otherwise, raises `Promox.VerificationError`.
  """
  def verify!(mock) do
    mock.agent
    |> Agent.get(&Promox.State.get_expects/1)
    |> Enum.filter(fn {_pfa, {expects, _used_expects}} -> length(expects) > 0 end)
    |> case do
      [] ->
        :ok

      unmet_expects ->
        messages =
          unmet_expects
          |> Enum.map(fn {{protocol, fun, arity}, {expects, used_expects}} ->
            total = length(expects) + length(used_expects)
            called = length(used_expects)

            "  * expect #{Exception.format_mfa(protocol, fun, arity)} to be called #{times(total)}, but it was called #{times(called)}"
          end)

        raise VerificationError,
              "error while verifying mocks for these protocols:\n\n" <> Enum.join(messages, "\n")
    end
  end

  defp times(1), do: "once"
  defp times(n), do: "#{n} times"
end
