defmodule ESpec.Mock do
  @moduledoc """
  Defines 'expect` function to mock function using 'meck'.
  :meck.new is called with options :non_strict and :passthrough.
  The :non_strict allows create mocks for modules and functions that do not exist.
  The :passthrough options allow call other functions in the module.
  Information about mock is stored in the ':espec_mock_agent' set.
  Mock are being unloaded after each example.
  """
  @agent_name :espec_mock_agent
  @default_options [:non_strict, :passthrough]
  @doc """
  Creates new mock using :meck. The default :meck options are [:non_strict, :passthrough]
  but they can be overriden.
  Stores mock in agent to remove it after spec.
  """
  def expect(module, name, function, meck_options) do
    opts = if Enum.empty?(meck_options), do: @default_options, else: meck_options

    try do
      :meck.new(module, opts)
    rescue
      error in [ErlangError] ->
        case error do
          %ErlangError{original: {:already_started, _pid}} -> :ok
          _ -> reraise error, __STACKTRACE__
        end
    end

    :meck.expect(module, name, function)
    agent_put({module, self()})
  end

  @doc "Unloads modules at the end of example"
  def unload do
    modules = agent_get(self()) |> Enum.map(fn {m, _p} -> m end)
    :meck.unload(modules)
    agent_del(self())
  end

  @doc "Starts Agent to save mocked modules."
  def start_agent, do: Agent.start_link(fn -> MapSet.new() end, name: @agent_name)

  @doc "Stops Agent"
  def stop_agent, do: Agent.stop(@agent_name)

  defp agent_get(pid) do
    Agent.get(@agent_name, & &1)
    |> Enum.filter(fn {_m, p} -> p == pid end)
  end

  defp agent_del(pid) do
    new_set =
      Agent.get(@agent_name, & &1)
      |> Enum.reduce(MapSet.new(), fn {m, p}, acc ->
        if p != pid, do: MapSet.put(acc, {m, p}), else: acc
      end)

    Agent.update(@agent_name, fn _state -> new_set end)
  end

  defp agent_put(key), do: Agent.update(@agent_name, &MapSet.put(&1, key))
end
