defmodule Repatch do
  @external_resource Path.join([__DIR__, "../README.md"])

  @moduledoc """
  #{@external_resource |> File.read!() |> String.split("<!-- MDOC -->") |> tl() |> hd()}
  """

  alias Repatch.Recompiler
  import :erlang, only: [atom_to_binary: 1]

  @forbidden_modules [
    :code,
    :erlang,
    :ets,
    :persistent_term,
    Agent,
    Enum,
    Keyword,
    Map,
    Repatch,
    Repatch.ExUnit,
    Repatch.Recompiler
  ]

  # Macro for debugging. It is designed to be thin, because we want to keep repatch
  # runtime overhead as thin as possible and Logger or anything with runtime check
  # is a bad fit here

  debugging? = false

  if debugging? do
    defmacrop debug(message) do
      quote do
        IO.puts("#{inspect(self())} | #{unquote(message)}")
        []
      end
    end
  else
    defmacrop debug(_), do: []
  end

  @typedoc """
  Options passed in the `setup/1` function.

  * `enable_global` (boolean) — Whether to allow global mocks in test suites. Defaults to `false`.
  * `enable_shared` (boolean) — Whether to allow shared mocks in test suites. Defaults to `true`.
  * `enable_history` (boolean) — Whether to enable calls history tracking. Defaults to `true`.
  * `recompile` (module or a list of modules) — What modules should be recompiled before test starts. Modules are recompiled lazily by default. Defaults to `[]`.
  * `ignore_forbidden_module` (boolean) — Whether to ignore the warning about forbidden module being recompiled. Works only when `recompile` is specified. Defaults to `false`.
  * `cover` (boolean) — Detected automatically by coverage tool, use with caution. Sets the `line_counters` native coverage mode.
  """
  @type setup_option ::
          recompile_option()
          | {:enable_global, boolean()}
          | {:enable_shared, boolean()}
          | {:enable_history, boolean()}
          | {:recompile, module() | [module()]}

  @typedoc """
  Mode of the patch, fake or application env isolation. These modes
  define the levels of isolation of the patches:

  * `:local` — Patches will work only in the process which set the patches
  * `:shared` — Patches will work only in the process which set the patches, spawned tasks or allowed processes. See `Repatch.allow/2`.
  * `:global` — Patches will work in all processes.

  Please check out "Isolation modes" doc for more information on details.
  """
  @type mode :: :local | :shared | :global

  @typedoc """
  Debug metadata tag of the function. Declares if the function is patched
  and what mode was used for the patch.
  """
  @type tag :: :patched | mode()

  @typedoc """
  Options passed in all functions which trigger module recompilation

  * `ignore_forbidden_module` (boolean) — Whether to ignore the warning about forbidden module being recompiled.
  * `recompile_only` (list of {module, function, arity} tuples) — Only these functions will be recompiled in this module or modules.
  * `recompile_except` (list of {module, function, arity} tuples) — All functions except specified will be recompiled in this module or modules.
  * `module_binary` (binary) — The BEAM binary of the module to recompile
  """
  @type recompile_option ::
          {:ignore_forbidden_module, boolean()}
          | {:module_binary, binary()}
          | {:recompile_only, [{module(), atom(), arity()}]}
          | {:recompile_except, [{module(), atom(), arity()}]}

  @doc """
  Setup function. Use it only once per test suite.
  See `t:setup_option/0` for available options.

  ## Example

      iex> Repatch.setup(enable_shared: false)

  It is suggested to be put in the `test_helper.exs` after the `ExUnit.start()` line
  """
  @spec setup([setup_option()]) :: :ok
  def setup(opts \\ []) do
    cover =
      Keyword.get_lazy(opts, :cover, fn -> :persistent_term.get(:repatch_line_counters, false) end)

    if cover && function_exported?(:code, :set_coverage_mode, 1) do
      apply(:code, :set_coverage_mode, [:line_counters])
    end

    global_hooks_enabled = Keyword.get(opts, :enable_global)
    shared_hooks_enabled = Keyword.get(opts, :enable_shared)
    history_enabled = Keyword.get(opts, :enable_history)

    setup_table(:repatch_module_states, [:set, :named_table, :public])
    setup_table(:repatch_state, [:set, :named_table, :public])

    case global_hooks_enabled do
      nil ->
        :ok

      true ->
        setup_table(:repatch_global_hooks, [:set, :named_table, :public])
        :persistent_term.put(:repatch_global_hooks_enabled, true)

      false ->
        :persistent_term.put(:repatch_global_hooks_enabled, false)
    end

    case shared_hooks_enabled do
      nil ->
        setup_table(:repatch_shared_hooks, [:set, :named_table, :public])
        setup_table(:repatch_shared_allowances, [:set, :named_table, :public])

      true ->
        setup_table(:repatch_shared_hooks, [:set, :named_table, :public])
        setup_table(:repatch_shared_allowances, [:set, :named_table, :public])
        :persistent_term.put(:repatch_shared_hooks_enabled, true)

      false ->
        :persistent_term.put(:repatch_shared_hooks_enabled, false)
    end

    case history_enabled do
      nil ->
        setup_table(:repatch_history, [:duplicate_bag, :named_table, :public])

      true ->
        setup_table(:repatch_history, [:duplicate_bag, :named_table, :public])
        :persistent_term.put(:repatch_history_enabled, true)

      false ->
        :persistent_term.put(:repatch_history_enabled, false)
    end

    # Doing this without Task, because we want to let users patch Task

    caller = self()

    to_await =
      for module <- List.wrap(Keyword.get(opts, :recompile, [])) do
        ref = make_ref()

        spawn_link(fn ->
          recompile(module, opts)
          send(caller, {module, ref})
        end)

        {module, ref}
      end

    for {module, ref} <- to_await do
      receive do
        {^module, ^ref} -> :ok
      after
        30_000 ->
          raise "Recompilation is taking more than 30 seconds"
      end
    end

    debug("setup successful")

    :ok
  end

  @doc false
  @spec setup_table(atom(), [any()]) :: :ok
  def setup_table(name, options) do
    case :ets.whereis(name) do
      :undefined ->
        :ets.new(name, options)
        :ok

      _ ->
        :ok
    end
  end

  @doc false
  @spec delete_all_objects(atom()) :: :ok
  def delete_all_objects(name) do
    case :ets.whereis(name) do
      :undefined ->
        :ok

      _ ->
        true = :ets.delete_all_objects(name)
        :ok
    end
  end

  @doc false
  @spec recompile(module(), [recompile_option() | any()]) :: :ok
  def recompile(module, opts \\ []) do
    if module in @forbidden_modules and not Keyword.get(opts, :ignore_forbidden_module, false) do
      raise ArgumentError,
            "Module #{inspect(module)} is a forbidden to patch module, because it may interfere with the Repatch logic"
    end

    debug("Recompiling #{inspect(module)}")

    case :ets.lookup(:repatch_module_states, module) do
      [{_, :recompiling}] ->
        debug("#{inspect(module)} awaiting recompilation")
        await_recompilation(module)
        :ok

      [{_, {:recompiled, _, _}}] ->
        debug("#{inspect(module)} found recompiled")
        :ok

      [] ->
        if :ets.insert_new(:repatch_module_states, {module, :recompiling}) do
          try do
            case Recompiler.recompile(module, opts) do
              {:ok, bin, filename} ->
                :ets.insert(:repatch_module_states, {module, {:recompiled, bin, filename}})
                debug("Recompiled #{inspect(module)}")
                :ok

              {:error, :nofile} ->
                raise ArgumentError, "Module #{inspect(module)} does not exist"

              {:error, :binary_unavailable} ->
                raise ArgumentError, "Binary for module #{inspect(module)} is unavailable"
            end
          rescue
            error ->
              :ets.delete(:repatch_module_states, module)
              reraise error, __STACKTRACE__
          end
        else
          await_recompilation(module)
          :ok
        end
    end
  end

  @doc """
  Cleans up current test process (or any other process) Repatch-state.

  ## Example

      iex> Repatch.patch(DateTime, :utc_now, fn -> :ok end)
      iex> DateTime.utc_now()
      :ok
      iex> Repatch.called?(DateTime, :utc_now, 0)
      true
      iex> Repatch.cleanup()
      iex> Repatch.called?(DateTime, :utc_now, 0)
      false
      iex> %DateTime{} = DateTime.utc_now()

  It is recommended to be called during the test exit.
  Check out `Repatch.ExUnit` module which set up this callback up.
  """
  @spec cleanup(pid()) :: :ok
  def cleanup(pid \\ self()) do
    if pid == self() do
      Enum.each(:erlang.get_keys(), fn
        {:repatch_hooks, _, _, _} = key -> :erlang.erase(key)
        _ -> :ok
      end)

      debug("cleanup local")
    end

    if :persistent_term.get(:repatch_shared_hooks_enabled, true) do
      :ets.match_delete(:repatch_shared_hooks, {{:_, :_, :_, pid}, :_})
      :ets.match_delete(:repatch_shared_allowances, {:_, pid})
      :ets.match_delete(:repatch_shared_allowances, {pid, :_})
    end

    if :persistent_term.get(:repatch_global_hooks_enabled, false) do
      :ets.match_delete(:repatch_global_hooks, {{:_, :_, :_}, pid, :_})
    end

    cleanup_history(pid)

    debug("cleanup done")

    :ok
  end

  defp cleanup_history(pid) do
    if :persistent_term.get(:repatch_history_enabled, true) do
      :ets.match_delete(:repatch_history, {{:_, :_, :_, pid}, :_, :_})
    end
  end

  @doc """
  Clears all state of the `Repatch` including all patches, fakes and history, and reloads all
  modules back to their original bytecode, disabling history collection on them.

  ## Example

      iex> Repatch.patch(DateTime, :utc_now, fn -> :ok end)
      iex> DateTime.utc_now()
      :ok
      iex> Repatch.restore_all()
      iex> %DateTime{} = DateTime.utc_now()

  It is not recommended to be called during testing and it is suggested to be used only
  when Repatch is used in iex session.
  """
  @spec restore_all() :: :ok
  def restore_all do
    :repatch_module_states
    |> :ets.tab2list()
    |> Enum.each(fn {module, state} ->
      case state do
        {:recompiled, original_binary, original_filename} ->
          :ok = Recompiler.load_binary(module, original_filename, original_binary)

        :recompiling ->
          {:recompiled, original_binary, original_filename} = await_recompilation(module)
          :ok = Recompiler.load_binary(module, original_filename, original_binary)

        _ ->
          :ok
      end
    end)

    delete_all_objects(:repatch_module_states)
    delete_all_objects(:repatch_state)
    delete_all_objects(:repatch_history)
    delete_all_objects(:repatch_global_hooks)
    delete_all_objects(:repatch_shared_hooks)
    delete_all_objects(:repatch_shared_allowances)

    debug("All restored")

    :ok
  end

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

  * `by` (pid) — What process history to clean. Defaults to `self()`.
  * `ignore_forbidden_module` (boolean) — Whether to ignore the warning about forbidden module is being spied. Defaults to `false`.
  """
  @type spy_option :: recompile_option() | {:by, pid()}

  @doc """
  Tracks calls to the specified module.

  Be aware that it recompiles the module if it was not patched or spied on before, which may take some time.

  ## Example

      iex> Repatch.spy(DateTime)
      iex> DateTime.utc_now()
      iex> Repatch.called?(DateTime, :utc_now, 0)
      true
      iex> Repatch.spy(DateTime)
      iex> Repatch.called?(DateTime, :utc_now, 0)
      false

  If spy is called on the same module for more than one time, it will clear the history of calls.
  """
  @spec spy(module(), [spy_option()]) :: :ok
  def spy(module, opts \\ []) do
    pid = Keyword.get(opts, :by, self())
    recompile(module, opts)
    cleanup_history(pid)
    :ok
  end

  @typedoc """
  Options passed in the `fake/3` function.

  * `ignore_forbidden_module` (boolean) — Whether to ignore the warning about forbidden module is being spied. Defaults to `false`.
  * `force` (boolean) — Whether to override existing patches and fakes. Defaults to `false`.
  * `mode` (`:local` | `:shared` | `:global`) — What mode to use for the patch. See `t:mode/0` for more info. Defaults to `:local`.
  """
  @type fake_option :: patch_option()

  @doc """
  Replaces functions implementation of the `real_module` with functions
  of the `fake_module`.

  See `t:fake_option/0` for available options.

  ## Example

      iex> ~U[2024-10-20 13:31:59.342240Z] != DateTime.utc_now()
      true
      iex> defmodule FakeDateTime do
      ...>   def utc_now do
      ...>     ~U[2024-10-20 13:31:59.342240Z]
      ...>   end
      ...> end
      iex> Repatch.fake(DateTime, FakeDateTime)
      iex> DateTime.utc_now()
      iex> ~U[2024-10-20 13:31:59.342240Z] == DateTime.utc_now()
      true
  """
  @spec fake(module(), module(), [fake_option()]) :: :ok
  def fake(real_module, fake_module, opts \\ [])

  def fake(m, m, _opts) do
    raise ArgumentError, "Can't fake module with itself #{inspect(m)}"
  end

  def fake(real_module, fake_module, opts) do
    exports = real_module.module_info(:exports)
    recompile(real_module, opts)

    for {function, arity} <- exports do
      hook = fn args -> {:ok, apply(fake_module, function, args)} end
      add_hook(real_module, function, arity, hook, opts)
    end

    :ok
  end

  # It's a macro because we don't want to add an extra entry in stacktrace when raising
  defmacrop raise_wrong_dotcall(ast) do
    quote do
      raise CompileError,
        description:
          "Expected call like Module.function(1, 2, 3). Got #{Code.format_string!(Macro.to_string(unquote(ast)))}",
        line: __CALLER__.line,
        file: __CALLER__.file
    end
  end

  defp maybe_raise_repatch_generated(function, env, name) do
    if Recompiler.generated?(function) do
      raise CompileError,
        description: "Can't call #{name}/1 on Repatch-generated functions. Got #{function}",
        line: env.line,
        file: env.file
    end
  end

  defp maybe_raise_repatch_generated(function, name) do
    if Recompiler.generated?(function) do
      raise ArgumentError, "Can't call #{name}/1 on Repatch-generated functions. Got #{function}"
    end
  end

  defp maybe_raise_non_existing(module, function, function_string, arity) do
    exists =
      Enum.any?(module.module_info(:exports), fn
        {f, ^arity} -> atom_to_binary(f) == function_string
        _ -> false
      end)

    unless exists do
      raise UndefinedFunctionError,
        module: module,
        function: function,
        arity: arity,
        message: "Can't find patch"
    end
  end

  @doc """
  Function version of the `real/1` macro. Please prefer to use macro in tests, since
  it is slightly more efficient

  ## Example

      iex> Repatch.patch(DateTime, :utc_now, fn _calendar -> :repatched end)
      iex> DateTime.utc_now()
      :repatched
      iex> %DateTime{} = Repatch.real(DateTime, :utc_now, [])
  """
  @spec real(module(), atom(), [term()]) :: any()
  def real(module, function, args) do
    maybe_raise_repatch_generated(function, :real)
    :erlang.put(:repatch_bypass_hooks, true)
    apply(module, function, args)
  after
    :erlang.erase(:repatch_bypass_hooks)
  end

  @doc """
  Use this on a call which would result only to unpatched versions of functions to be called on the whole stack of calls.
  Works only on calls in `Module.function(arg0, arg1, arg2)` format.

  ## Example

      iex> Repatch.patch(DateTime, :utc_now, fn _calendar -> :repatched end)
      iex> DateTime.utc_now()
      :repatched
      iex> %DateTime{} = Repatch.real(DateTime.utc_now())
  """
  defmacro real({{:., _dotmeta, [_module, function]}, _meta, _args} = call) do
    maybe_raise_repatch_generated(function, __CALLER__, :real)

    quote do
      :erlang.put(:repatch_bypass_hooks, true)

      try do
        unquote(call)
      after
        :erlang.erase(:repatch_bypass_hooks)
      end
    end
  end

  defmacro real(other) do
    raise_wrong_dotcall(other)
  end

  @doc """
  Function version of the `Repatch.super/1` macro. Please try to use the macro version,
  since it is slightly more efficient.

  ## Example

      iex> Repatch.patch(DateTime, :utc_now, fn _calendar -> :repatched end)
      iex> DateTime.utc_now()
      :repatched
      iex> Repatch.super(DateTime, :utc_now, [])
      :repatched
      iex> %DateTime{} = Repatch.super(DateTime, :utc_now, [Calendar.ISO])
  """
  @spec super(module(), atom(), [term()]) :: any()
  def super(module, function, args) do
    maybe_raise_repatch_generated(function, :super)

    maybe_raise_non_existing(
      module,
      function,
      Recompiler.super_name_string(function),
      length(args)
    )

    super_function = Recompiler.super_name(function)
    apply(module, super_function, args)
  end

  @doc """
  Use this on a call which would result only on one unpatched version of the function to be called.
  Works only on calls in `Module.function(arg0, arg1, arg2)` format.

  ## Example

      iex> Repatch.patch(DateTime, :utc_now, fn _calendar -> :repatched end)
      iex> DateTime.utc_now()
      :repatched
      iex> Repatch.super(DateTime.utc_now())
      :repatched
      iex> %DateTime{} = Repatch.super(DateTime.utc_now(Calendar.ISO))
  """
  defmacro super({{:., _, [module, function]}, meta, args}) when is_atom(function) do
    super_function = Recompiler.super_name(function)

    maybe_raise_repatch_generated(function, __CALLER__, :super)

    {:apply, meta, [module, super_function, args]}
  end

  defmacro super(other) do
    raise_wrong_dotcall(other)
  end

  @doc """
  Function version of the `private/1` macro. Please try to use the macro version
  when possible, because macro has slightly better performance
  """
  @spec private(module(), atom(), [term()]) :: any()
  def private(module, function, args) do
    maybe_raise_repatch_generated(function, :private)

    maybe_raise_non_existing(
      module,
      function,
      Recompiler.private_name_string(function),
      length(args)
    )

    private_function = Recompiler.private_name(function)
    apply(module, private_function, args)
  end

  @doc """
  Just a compiler-friendly wrapper to call private functions on the module.
  Works only on calls in `Module.function(arg0, arg1, arg2)` format.
  """
  defmacro private({{:., _, [module, function]}, meta, args}) when is_atom(function) do
    private_function = Recompiler.private_name(function)

    maybe_raise_repatch_generated(function, __CALLER__, :private)

    {:apply, meta, [module, private_function, args]}
  end

  defmacro private(other) do
    raise_wrong_dotcall(other)
  end

  @typedoc """
  Opaque value returned from `notify/4` or `notify/2`.
  Can be used to receive the notification about the function call.
  """
  @opaque notify_ref :: {module(), atom(), arity(), reference()}

  @doc """
  Patches a function to send the message to the calling process
  every time this function is successfully executed. Result
  of `notify/4` can be used to be receivied on.

  ## Example

      iex> notification = Repatch.notify(DateTime, :utc_now, 0)
      iex> receive do ^notification -> :got after 0 -> :none end
      :none
      iex> DateTime.utc_now()
      iex> receive do ^notification -> :got after 0 -> :none end
      :got

  If you want to stop receiving notifications, you can call `restore/4`.

  It is recommended to not use this function and instead use `m::trace` module
  """
  @doc since: "1.6.0"
  @spec notify(module(), atom(), arity() | [term()], [patch_option()]) :: notify_ref()
  def notify(module, function, args_or_arity, opts \\ []) do
    recompile(module, opts)
    owner = self()

    arity =
      case args_or_arity do
        arity when is_integer(arity) ->
          arity

        args when is_list(args) ->
          length(args)
      end

    opaque_ref = {module, function, arity, make_ref()}

    hook =
      case args_or_arity do
        arity when is_integer(arity) ->
          fn args ->
            result = __MODULE__.super(module, function, args)
            send(owner, opaque_ref)
            {:ok, result}
          end

        args when is_list(args) ->
          fn called_args ->
            case called_args do
              ^args ->
                result = __MODULE__.super(module, function, called_args)
                send(owner, opaque_ref)
                {:ok, result}

              _ ->
                {:ok, __MODULE__.super(module, function, called_args)}
            end
          end
      end

    add_hook(module, function, arity, hook, opts)
    opaque_ref
  end

  @doc """
  Patches a function to send the message to the calling process
  every time this function is successfully executed. Result
  of `notify/2` can be used to be receivied on.

  ## Example

      iex> notification = Repatch.notify DateTime.utc_now()
      iex> receive do ^notification -> :got after 0 -> :none end
      :none
      iex> DateTime.utc_now()
      iex> receive do ^notification -> :got after 0 -> :none end
      :got
  """
  @doc since: "1.6.0"
  defmacro notify(dotcall, opts \\ [])

  defmacro notify({{:., _, [module, function]}, _meta, args}, opts) do
    arity = length(args)

    quote do
      module = unquote(module)
      function = unquote(function)
      opts = unquote(opts)
      unquote(__MODULE__).recompile(module, opts)
      owner = self()
      opaque_ref = {module, function, unquote(arity), make_ref()}

      hook =
        fn called_args ->
          case called_args do
            unquote(args) ->
              result = unquote(__MODULE__).super(module, function, called_args)
              send(owner, opaque_ref)
              {:ok, result}

            _ ->
              {:ok, unquote(__MODULE__).super(module, function, called_args)}
          end
        end

      unquote(__MODULE__).add_hook(module, function, unquote(arity), hook, opts)
      opaque_ref
    end
  end

  defmacro notify(quoted, _opts) do
    raise_wrong_dotcall(quoted)
  end

  @typedoc """
  Options passed in the `patch/4` function.

  * `ignore_forbidden_module` (boolean) — Whether to ignore the warning about forbidden module is being spied. Defaults to `false`.
  * `force` (boolean) — Whether to override existing patches. Defaults to `false`.
  * `mode` (`:local` | `:shared` | `:global`) — What mode to use for the patch. See `t:mode/0` for more info. Defaults to `:local`.
  """
  @type patch_option ::
          recompile_option()
          | {:mode, :local | :shared | :global}
          | {:force, boolean()}

  @doc """
  Substitutes implementation of the function with a new one.
  Starts tracking history on all calls in the module too.
  See `t:patch_option/0` for available options.

  ## Example

      iex> ~U[2024-10-20 13:31:59.342240Z] != DateTime.utc_now()
      true
      iex> Repatch.patch(DateTime, :utc_now, fn -> ~U[2024-10-20 13:31:59.342240Z] end)
      iex> DateTime.utc_now()
      ~U[2024-10-20 13:31:59.342240Z]

  Be aware that it recompiles the module if it was not patched before, which may take some time.

  And it is also recommended to not patch functions which can be changed in the future, since
  every patch is an implicit dependency on the internals of implementation.
  """
  @spec patch(module(), atom(), [patch_option()], function()) :: :ok
  def patch(module, function, opts \\ [], func) do
    recompile(module, opts)

    arity =
      func
      |> :erlang.fun_info()
      |> Keyword.fetch!(:arity)

    case extract_export_type(module, function, arity) do
      :macro ->
        hook = prepare_hook(module, function, arity, func)
        add_hook(module, :"MACRO-#{function}", arity, hook, opts)

      :function ->
        hook = prepare_hook(module, function, arity, func)
        add_hook(module, function, arity, hook, opts)

      nil ->
        raise ArgumentError, "Function #{inspect(module)}.#{function}/#{arity} does not exist"
    end

    :ok
  end

  defp extract_export_type(module, function, arity) do
    macro_name = "MACRO-#{function}"
    super_name = Recompiler.super_name_string(function)

    :exports
    |> module.module_info()
    |> Enum.find_value(fn {function, function_arity} ->
      case atom_to_binary(function) do
        ^macro_name when function_arity == arity ->
          :macro

        ^super_name when function_arity == arity ->
          :function

        _ ->
          false
      end
    end)
  end

  @doc false
  def add_hook(module, function, arity, hook, opts) do
    mode = Keyword.get(opts, :mode, :local)

    case :ets.lookup(:repatch_state, {module, function, arity, self()}) do
      [] ->
        do_add_hook(module, function, arity, hook, mode)
        :ets.insert(:repatch_state, {{module, function, arity, self()}, [:patched, mode]})

      [{_, tags}] ->
        if :patched in tags and not Keyword.get(opts, :force, false) do
          raise ArgumentError,
                "Function #{inspect(module)}.#{function}/#{arity} is already patched"
        else
          do_add_hook(module, function, arity, hook, mode)
          tags = [mode | tags -- [mode]]
          :ets.insert(:repatch_state, {{module, function, arity, self()}, tags})
        end
    end

    debug("Added #{mode} function hook")
  end

  defp do_add_hook(module, function, arity, hook, mode) do
    case mode do
      :local ->
        add_local_hook(module, function, arity, hook)

      :shared ->
        add_shared_hook(module, function, arity, hook)

      :global ->
        add_global_hook(module, function, arity, hook)
    end
  end

  @typedoc """
  Options passed in the `restore/4` function.

  * `mode` (`:local` | `:shared` | `:global`) — What mode to remove the patch in. See `t:mode/0` for more info. Defaults to `:local`.
  """
  @type restore_option() :: {:mode, mode()}

  @doc """
  Removes any patch on the specified function.
  See `t:restore_option/0` for available options.

  ## Example

      iex> URI.encode_query(%{x: 123})
      "x=123"
      iex> Repatch.patch(URI, :encode_query, fn query -> inspect(query) end)
      iex> URI.encode_query(%{x: 123})
      "%{x: 123}"
      iex> Repatch.restore(URI, :encode_query, 1)
      iex> URI.encode_query(%{x: 123})
      "x=123"
  """
  @spec restore(module(), atom(), arity(), [restore_option()]) :: :ok
  def restore(module, function, arity, opts \\ []) do
    mode = Keyword.get(opts, :mode, :local)

    case mode do
      :local ->
        remove_local_hook(module, function, arity)

      :shared ->
        remove_shared_hook(module, function, arity)

      :global ->
        remove_global_hook(module, function, arity)
    end

    with [{_, tags}] <- :ets.lookup(:repatch_state, {module, function, arity, self()}) do
      tags = tags -- [:patched, mode]
      :ets.insert(:repatch_state, {{module, function, arity, self()}, tags})
    end

    :ok
  end

  @typedoc """
  Options passed in the `allow/3` function.

  * `force` (boolean) — Whether to override existing allowance on the allowed process or not. Defaults to `false`.
  """
  @type allow_option :: {:force, boolean()}

  @doc """
  Enables the `allowed` process to use the patch from `owner` process.
  Works only for patches in shared mode.
  See `t:allow_option/0` for available options.

  ## Example

      iex> alias Repatch.Looper
      iex> require Repatch
      iex> pid = Looper.start_link()
      iex> Range.new(1, 3)
      1..3
      iex> Repatch.patch(Range, :new, [mode: :shared], fn l, r -> Enum.to_list(Repatch.super(Range.new(l, r))) end)
      iex> Range.new(1, 3)
      [1, 2, 3]
      iex> Looper.call(pid, Range, :new, [1, 3])
      1..3
      iex> Repatch.allow(self(), pid)
      iex> Looper.call(pid, Range, :new, [1, 3])
      [1, 2, 3]
  """
  @spec allow(
          pid() | GenServer.name() | {atom(), node()},
          pid() | GenServer.name() | {atom(), node()},
          [allow_option()]
        ) :: :ok
  def allow(owner, allowed, opts \\ []) do
    unless :persistent_term.get(:repatch_shared_hooks_enabled, true) do
      raise ArgumentError, "Shared hooks are disabled!"
    end

    owner = resolve_pid(owner)
    allowed = resolve_pid(allowed)

    final_owner =
      case :ets.lookup(:repatch_shared_allowances, owner) do
        [] ->
          owner

        [{_, final_owner}] ->
          final_owner
      end

    if final_owner == allowed do
      if allowed == owner do
        raise ArgumentError, "Can't use allowance on the same process #{inspect(allowed)}"
      else
        raise ArgumentError,
              "Cyclic allowance detected! #{inspect([allowed, owner, final_owner])}"
      end
    end

    unless :ets.insert_new(:repatch_shared_allowances, {allowed, final_owner}) do
      if Keyword.get(opts, :force, false) do
        :ets.delete(:repatch_shared_allowances, allowed)
        :ets.insert(:repatch_shared_allowances, {allowed, final_owner})
      else
        raise ArgumentError,
              "Allowance is already present for the specified process #{inspect(allowed)}"
      end
    end

    debug("#{inspect(allowed)} Set owner to #{inspect(owner)}")

    :ok
  end

  @doc """
  Lists all allowances of specified process (or `self()` by default).
  Works only when shared mode is enabled.

  Please note that deep allowances are returned as the final allowed process.

  ## Example

      iex> alias Repatch.Looper
      iex> require Repatch
      iex> pid1 = Looper.start_link()
      iex> pid2 = Looper.start_link()
      iex> Repatch.allowances()
      []
      iex> Repatch.allow(self(), pid1)
      iex> Repatch.allowances()
      [pid1]
      iex> Repatch.allow(pid1, pid2)
      iex> pid1 in Repatch.allowances() and pid2 in Repatch.allowances()
      true
      iex> Repatch.allowances(pid1)
      []
      iex> Repatch.allowances(pid2)
      []
  """
  @spec allowances(pid()) :: [pid()]
  def allowances(pid \\ self()) do
    :repatch_shared_allowances
    |> :ets.match({:"$1", pid})
    |> Enum.concat()
  end

  @doc """
  Returns current owner of the allowed process (or `self()` by default) if any.
  Works only when shared mode is enabled.

  Please note that deep allowances are returned as the final owner of the process.

  ## Example

      iex> alias Repatch.Looper
      iex> require Repatch
      iex> pid1 = Looper.start_link()
      iex> pid2 = Looper.start_link()
      iex> Repatch.owner(pid1)
      nil
      iex> Repatch.allow(self(), pid1)
      iex> Repatch.owner(pid1)
      self()
      iex> Repatch.allow(pid1, pid2)
      iex> Repatch.owner(pid2)
      self()
  """
  @spec owner(pid()) :: pid() | nil
  def owner(pid \\ self()) do
    case :ets.lookup(:repatch_shared_allowances, pid) do
      [{_, owner}] -> owner
      _ -> nil
    end
  end

  @doc """
  For debugging purposes only. Returns list of tags which indicates patch state of the specified function.

  ## Example

      iex> Repatch.patch(MapSet, :new, fn -> :not_a_mapset end)
      iex> Repatch.info(MapSet, :new, 0)
      [:patched, :local]
  """
  @spec info(module(), atom(), arity(), pid()) :: [tag()]
  @spec info(module(), atom(), arity(), :any) :: %{pid() => [tag()]}
  def info(module, function, arity, pid \\ self())

  def info(module, function, arity, :any) do
    results = :ets.match_object(:repatch_state, {{module, function, arity, :_}, :_})
    Map.new(results, fn {{_, _, _, pid}, tags} -> {pid, tags} end)
  end

  def info(module, function, arity, name) do
    pid = resolve_pid(name)

    case :ets.lookup(:repatch_state, {module, function, arity, pid}) do
      [{_, tags}] -> tags
      _ -> []
    end
  end

  @typedoc """
  Options passed in the `repatched?/4` function.

  * `mode` (`:local` | `:shared` | `:global` | `:any`) — What mode to check the patch in. See `t:mode/0` for more info. Defaults to `:any`.
  """
  @type repatched_check_option :: {:mode, mode() | :any}

  @doc """
  Checks if function is patched in any (or some specific) mode.

  ## Example

      iex> Repatch.repatched?(MapSet, :new, 0)
      false
      iex> Repatch.patch(MapSet, :new, fn -> :not_a_mapset end)
      iex> Repatch.repatched?(MapSet, :new, 0)
      true
  """
  @spec repatched?(module(), atom(), arity(), [repatched_check_option()]) :: boolean()
  def repatched?(module, function, arity, opts \\ []) do
    case Keyword.get(opts, :mode, :any) do
      :any ->
        :patched in info(module, function, arity)

      mode ->
        tags = info(module, function, arity)
        :patched in tags and mode in tags
    end
  end

  @typedoc """
  Options passed in the `history/1` function. When multiple options are specified, they are combined in logical AND fashion.

  * `by` (`t:GenServer.name/0` | `:any` | pid) — what process called the function. Defaults to `self()`.
  * `before` (`:erlang.monotonic_time/0` timestamp) — upper boundary of when the function was called.
  * `after` (`:erlang.monotonic_time/0` timestamp) — lower boundary of when the function was called.
  """
  @type history_option ::
          {:by, GenServer.name() | pid() | :any}
          | {:after, monotonic_time_native :: integer()}
          | {:before, monotonic_time_native :: integer()}
          | {:module, module()}
          | {:function, atom()}
          | {:arity, arity()}
          | {:args, [term()]}

  @typedoc """
  Options passed in the `called?/2` and `called?/4`. When multiple options are specified, they are combined in logical AND fashion.

  * `by` (`t:GenServer.name/0` | `:any` | pid) — what process called the function. Defaults to `self()`.
  * `at_least` (`:once` | integer) — at least how many times the function was called. Defaults to `:once`.
  * `exactly` (`:once` | integer) — exactly how many times the function was called.
  * `before` (`:erlang.monotonic_time/0` timestamp) — upper boundary of when the function was called.
  * `after` (`:erlang.monotonic_time/0` timestamp) — lower boundary of when the function was called.
  """
  @type called_check_option ::
          {:by, GenServer.name() | pid() | :any}
          | {:after, monotonic_time_native :: integer()}
          | {:before, monotonic_time_native :: integer()}
          | {:at_least, :once | pos_integer()}
          | {:exactly, :once | pos_integer()}

  @doc """
  Checks if the function call is present in the history or not.
  See `t:called_check_option/0` for available options.

  ## Example

      iex> Repatch.spy(Path)
      iex> Repatch.called?(Path, :join, 2)
      false
      iex> Path.join("left", "right")
      "left/right"
      iex> Repatch.called?(Path, :join, 2)
      true
      iex> Repatch.called?(Path, :join, 2, exactly: :once)
      true
      iex> Path.join("left", "right")
      "left/right"
      iex> Repatch.called?(Path, :join, 2, exactly: :once)
      false

  Works only when history is enabled in setup.
  Please make sure that module is spied on or at least patched before querying history on it.
  """
  @spec called?(module(), atom(), arity() | [term()], [called_check_option()]) :: boolean()
  def called?(module, function, arity_or_args, opts \\ []) do
    {exactly, at_least, afterr, before, pid} = parse_called_opts(opts)

    {arity, args} =
      case arity_or_args do
        args when is_list(args) ->
          {length(args), args}

        arity when is_integer(arity) and arity >= 0 ->
          {arity, :_}
      end

    module
    |> called_pattern(function, arity, pid, args, 1, afterr, before)
    |> called_one(exactly, at_least)
  end

  @doc false
  def parse_called_opts(opts) do
    {afterr, before, pid} = parse_history_opts(opts)

    exactly = intify(Keyword.get(opts, :exactly))
    at_least = intify(Keyword.get(opts, :at_least, 1))

    cond do
      exactly && at_least && at_least > exactly ->
        raise ArgumentError,
              "When specifying exactly and at_least options, make sure that " <>
                "exactly is always less than at_least. Got #{at_least} > #{exactly}"

      !(exactly || at_least) ->
        raise ArgumentError, "At least one parameter of exactly and at_least is required"

      true ->
        :ok
    end

    {exactly, at_least, afterr, before, pid}
  end

  defp parse_history_opts(opts) do
    afterr = Keyword.get(opts, :after)
    before = Keyword.get(opts, :before)
    by = Keyword.get(opts, :by, self())

    cond do
      not :persistent_term.get(:repatch_history_enabled, true) ->
        raise ArgumentError, "History disabled"

      before && afterr && afterr > before ->
        raise ArgumentError, "Can't have after more than before. Got #{afterr} > #{before}"

      true ->
        :ok
    end

    pid =
      case by do
        :any ->
          :_

        other ->
          resolve_pid(other)
      end

    {afterr, before, pid}
  end

  @doc """
  Checks if the function call is present in the history or not.
  First argument of this macro must be in a `Module.function(arguments)` format
  and `arguments` can be a pattern. It is also possible to specify a guard like in example.

  See `t:called_check_option/0` for available options.

  ## Example

      iex> Repatch.spy(Path)
      iex> Repatch.called? Path.split("path/to")
      false
      iex> Path.split("path/to")
      ["path", "to"]
      iex> Repatch.called? Path.split("path/to")
      true
      iex> Repatch.called? Path.split(string) when is_binary(string)
      true
      iex> Repatch.called? Path.split("path/to"), exactly: :once
      true
      iex> Path.split("path/to")
      ["path", "to"]
      iex> Repatch.called? Path.split("path/to"), exactly: :once
      false

  Works only when history is enabled in setup.
  Please make sure that module is spied on or at least patched before querying history on it.
  """
  @doc since: "1.6.0"
  defmacro called?(dotcall, opts \\ []) do
    {module, function, args, guard} =
      case dotcall do
        {{:., _dotmeta, [module, function]}, _meta, args} ->
          {module, function, args, true}

        {:when, _, [{{:., _dotmeta, [module, function]}, _meta, args}, guard]} ->
          {module, function, args, guard}

        quoted ->
          raise_wrong_dotcall(quoted)
      end

    arity = length(args)
    {args, maxa, guards} = toms(args, guard, __CALLER__)

    quote do
      {exactly, at_least, afterr, before, pid} =
        unquote(__MODULE__).parse_called_opts(unquote(opts))

      {pattern, guard, selection} =
        unquote(__MODULE__).called_pattern(
          unquote(module),
          unquote(function),
          unquote(arity),
          pid,
          unquote(args),
          unquote(maxa + 1),
          afterr,
          before
        )

      ms = {pattern, guard ++ unquote(guards), selection}
      unquote(__MODULE__).called_one(ms, exactly, at_least)
    end
  end

  defp toms(args, guard, env) do
    require Ex2ms

    args = Macro.expand(args, %Macro.Env{env | context: :match})
    guard = Macro.expand(guard, %Macro.Env{env | context: :guard})

    [{:{}, _, [{:{}, _, [args]}, guards, _]}] =
      quote do
        Ex2ms.fun do
          {unquote(args)} when unquote(guard) -> :ok
        end
      end
      |> Macro.expand_once(__ENV__)

    {_args, maxi} =
      args
      |> Macro.postwalk(0, fn
        atom, acc when is_atom(atom) ->
          acc =
            with(
              "$" <> s <- Atom.to_string(atom),
              {i, ""} <- Integer.parse(s)
            ) do
              max(i, acc)
            else
              _ -> acc
            end

          {atom, acc}

        other, acc ->
          {other, acc}
      end)

    {args, maxi, guards}
  end

  @doc """
  Queries a history of all calls from current or passed process.
  It is also possible to filter by module, function name, arity, args
  and timestamps.

  See `t:history_option/0` for available options.

  ## Example

      iex> Repatch.spy(Path)
      iex> Repatch.spy(MapSet)
      iex> Repatch.history()
      []
      iex> Path.rootname("file.ex")
      "file"
      iex> MapSet.new()
      MapSet.new([])
      iex> Repatch.history(module: Path, function: :rootname)
      [
        {Path, :rootname, ["file.ex"], -576460731414614326}
      ]
      iex> Repatch.history()
      [
        {Path, :rootname, ["file.ex"], -576460731414614326},
        {MapSet, :new, [], -576460731414614300},
        ...
      ]

  Works only when history is enabled in setup.
  Please make sure that module is spied on or at least patched before querying history on it.
  """
  @doc since: "1.6.0"
  @spec history([history_option()]) :: [
          {module :: module(), function :: atom(), args :: [term()],
           monotonic_timestamp :: integer()}
        ]
  def history(opts \\ []) do
    {afterr, before, pid} = parse_history_opts(opts)

    module = Keyword.get(opts, :module, :"$1")
    function = Keyword.get(opts, :function, :"$2")
    arity = Keyword.get(opts, :arity, :_)
    args = Keyword.get(opts, :args, :"$3")

    guard =
      case {afterr, before} do
        {nil, nil} ->
          []

        {afterr, nil} ->
          [{:>=, :"$4", afterr}]

        {nil, before} ->
          [{:"=<", :"$4", before}]

        {afterr, before} ->
          [{:>=, :"$4", afterr}, {:"=<", :"$4", before}]
      end

    ms =
      {{{module, function, arity, pid}, :"$4", args}, guard, [{{module, function, args, :"$4"}}]}

    :ets.select(:repatch_history, [ms])
  end

  @doc false
  def called_pattern(module, function, arity, pid, args, _index, nil, nil) do
    {{{module, function, arity, pid}, :_, args}, [], [:"$$"]}
  end

  def called_pattern(module, function, arity, pid, args, index, nil, before) do
    {{{module, function, arity, pid}, :"$#{index}", args}, [{:"=<", :"$1", before}], [:"$$"]}
  end

  def called_pattern(module, function, arity, pid, args, index, afterr, nil) do
    {{{module, function, arity, pid}, :"$#{index}", args}, [{:>=, :"$#{index}", afterr}], [:"$$"]}
  end

  def called_pattern(module, function, arity, pid, args, index, afterr, before) do
    {{{module, function, arity, pid}, :"$#{index}", args},
     [{:>=, :"$#{index}", afterr}, {:"=<", :"$#{index}", before}], [:"$$"]}
  end

  @doc false
  def called_one(key_pattern, nil, at_least) do
    called_at_least(key_pattern, at_least)
  end

  def called_one(key_pattern, exactly, _at_least) do
    called_exactly(key_pattern, exactly)
  end

  defp intify(nil), do: nil
  defp intify(:once), do: 1
  defp intify(i) when is_integer(i) and i > 0, do: i

  defp intify(other) do
    raise ArgumentError, "Expected positive integer or `:once` atom. Got #{inspect(other)}"
  end

  defp called_exactly(pattern, count) do
    case :ets.select(:repatch_history, [pattern], count + 1) do
      {list, :"$end_of_table"} -> length(list) == count
      _ -> false
    end
  end

  defp called_at_least(pattern, count) do
    case :ets.select(:repatch_history, [pattern], count) do
      {list, _} -> length(list) == count
      _ -> false
    end
  end

  defp await_recompilation(module) do
    case :ets.lookup(:repatch_module_states, module) do
      [{_, :recompiling}] ->
        receive after: (10 -> [])
        await_recompilation(module)

      [{_, {:recompiled, _, _} = state}] ->
        state

      [] ->
        raise CompileError,
          description: "Compilation of #{module} failed in another process"
    end
  end

  defp add_local_hook(module, function, arity, hook) do
    :erlang.put({:repatch_hooks, module, function, arity}, hook)
  end

  defp add_shared_hook(module, function, arity, hook) do
    if :persistent_term.get(:repatch_shared_hooks_enabled, true) do
      :erlang.put({:repatch_shared_hooks, module, function, arity}, hook)
      :ets.insert(:repatch_shared_hooks, {{module, function, arity, self()}, hook})
    else
      raise ArgumentError, "Shared hooks disabled"
    end
  end

  defp add_global_hook(module, function, arity, hook) do
    if :persistent_term.get(:repatch_global_hooks_enabled, false) do
      :ets.insert(:repatch_global_hooks, {{module, function, arity}, self(), hook})
    else
      raise ArgumentError, "Global hooks disabled"
    end
  end

  defp remove_local_hook(module, function, arity) do
    :erlang.erase({:repatch_hooks, module, function, arity})
  end

  defp remove_shared_hook(module, function, arity) do
    if :persistent_term.get(:repatch_shared_hooks_enabled, true) do
      :erlang.erase({:repatch_shared_hooks, module, function, arity})
      :ets.delete(:repatch_shared_hooks, {module, function, arity, self()})
    else
      raise ArgumentError, "Shared hooks disabled"
    end
  end

  defp remove_global_hook(module, function, arity) do
    if :persistent_term.get(:repatch_global_hooks_enabled, false) do
      :ets.delete(:repatch_global_hooks, {module, function, arity})
    else
      raise ArgumentError, "Global hooks disabled"
    end
  end

  if debugging? do
    defp prepare_hook(module, function, _arity, hook) do
      fn args ->
        debug("Calling hook #{inspect(module)}.#{function}#{inspect(args)}")
        {:ok, apply(hook, args)}
      end
    end
  else
    defp prepare_hook(_module, _function, _arity, hook) do
      fn args -> {:ok, apply(hook, args)} end
    end
  end

  @doc false
  @spec dispatch(module(), atom(), arity(), [term()]) :: :pass | {:ok, term()}
  def dispatch(module, function, arity, args) do
    if :persistent_term.get(:repatch_history_enabled, true) do
      ts = :erlang.monotonic_time()
      :ets.insert(:repatch_history, {{module, function, arity, self()}, ts, args})
      # debug("Inserted history #{ts}")
    end

    case :erlang.get(:repatch_bypass_hooks) do
      no when no in ~w[undefined false]a ->
        case :erlang.get({:repatch_hooks, module, function, arity}) do
          :undefined ->
            dispatch_shared(module, function, arity, args)

          hook ->
            debug("Dispatched local")
            hook.(args)
        end

      true ->
        debug("Dispatched none")
        :pass
    end
  end

  defp dispatch_shared(module, function, arity, args) do
    if :persistent_term.get(:repatch_shared_hooks_enabled, true) do
      case :erlang.get({:repatch_shared_hooks, module, function, arity}) do
        :undefined ->
          case :ets.lookup(:repatch_shared_allowances, self()) do
            [] ->
              case :erlang.get(:"$callers") do
                [_ | _] = callers ->
                  dispatch_callers(callers, module, function, arity, args)

                _ ->
                  dispatch_global(module, function, arity, args)
              end

            [{_, pid}] when is_pid(pid) ->
              dispatch_allowance(module, function, arity, args, pid)
          end

        hook ->
          debug("Dispatched shared")
          hook.(args)
      end
    else
      dispatch_global(module, function, arity, args)
    end
  end

  defp dispatch_callers([], module, function, arity, args) do
    dispatch_global(module, function, arity, args)
  end

  defp dispatch_callers([caller | tail], module, function, arity, args) do
    case :ets.lookup(:repatch_shared_hooks, {module, function, arity, caller}) do
      [] ->
        dispatch_callers(tail, module, function, arity, args)

      [{_, hook}] when is_function(hook) ->
        debug("Dispatched allowance with caller owner")
        hook.(args)
    end
  end

  defp dispatch_allowance(module, function, arity, args, pid) do
    case :ets.lookup(:repatch_shared_hooks, {module, function, arity, pid}) do
      [] ->
        dispatch_global(module, function, arity, args)

      [{_, hook}] when is_function(hook) ->
        debug("Dispatched allowance with owner #{inspect(pid)}")
        hook.(args)
    end
  end

  defp dispatch_global(module, function, arity, args) do
    if :persistent_term.get(:repatch_global_hooks_enabled, false) do
      case :ets.lookup(:repatch_global_hooks, {module, function, arity}) do
        [] ->
          :pass

        [{_, _, hook}] ->
          debug("Dispatched global")
          hook.(args)
      end
    else
      :pass
    end
  end

  defmacrop raise_not_name(x) do
    quote do
      raise ArgumentError,
            "Expected pid, valid `t:GenServer.name` or `:any`. Got #{inspect(unquote(x))}"
    end
  end

  defp resolve_pid(pid) when is_pid(pid), do: pid

  defp resolve_pid(name) when is_atom(name) do
    with :undefined <- :erlang.whereis(name) do
      raise_not_name(name)
    end
  end

  defp resolve_pid({:global, name}) do
    case :global.whereis_name(name) do
      pid when is_pid(pid) ->
        pid

      :undefined ->
        raise_not_name({:global, name})
    end
  end

  defp resolve_pid({:via, module, name} = fullname) do
    case module.whereis_name(name) do
      pid when is_pid(pid) ->
        pid

      :undefined ->
        raise_not_name(fullname)
    end
  end

  defp resolve_pid({name, local}) when is_atom(name) and local == node() do
    with :undefined <- :erlang.whereis(name) do
      raise_not_name({name, local})
    end
  end

  defp resolve_pid({name, node}) when is_atom(name) and is_atom(node) do
    with :undefined <- :erpc.call(node, :erlang, :whereis, [name]) do
      raise_not_name({name, node})
    end
  end
end
