defmodule Livebook.Runtime.Evaluator.Tracer do
  # Compilation tracer used by the evaluator.
  #
  # Events are pre-processed and sent to the group leader, where the
  # tracer state is accumulated. After evaluation the evaluator reads
  # the accumulated state.

  alias Livebook.Runtime.Evaluator

  defstruct modules_used: MapSet.new(),
            modules_defined: %{},
            aliases_used: MapSet.new(),
            aliases_defined: %{},
            requires_used: MapSet.new(),
            requires_defined: MapSet.new(),
            imports_used?: false,
            imports_defined?: false

  @type t :: %__MODULE__{
          modules_used: MapSet.t(),
          modules_defined: map(),
          aliases_used: MapSet.t(),
          aliases_defined: map(),
          requires_used: MapSet.t(),
          requires_defined: MapSet.t(),
          imports_used?: boolean(),
          imports_defined?: boolean()
        }

  @doc false
  def trace(event, env) do
    case event_to_updates(event, env) do
      [] ->
        :ok

      updates ->
        io_proxy = Process.group_leader()
        Evaluator.IOProxy.tracer_updates(io_proxy, updates)
    end

    :ok
  end

  defp event_to_updates(event, env) do
    # Note that import/require/alias/defmodule don't trigger `:alias_reference`
    # for the used alias, so we add it explicitly

    case event do
      :start ->
        if Code.ensure_loaded?(env.module) do
          raise CompileError,
            line: env.line,
            file: env.file,
            description: "module #{inspect(env.module)} is already defined"
        end

        []

      {:import, _meta, module, _opts} ->
        if(env.module, do: [], else: [:import_defined]) ++
          [{:module_used, module}, {:alias_used, module}]

      {:imported_function, _meta, module, _name, _arity} ->
        [{:module_used, module}, :import_used]

      {:imported_macro, _meta, module, _name, _arity} ->
        [{:module_used, module}, :import_used]

      {:alias, _meta, alias, as, _opts} ->
        if(env.module, do: [], else: [{:alias_defined, as, alias}]) ++
          [{:alias_used, alias}]

      {:alias_expansion, _meta, as, _alias} ->
        [{:alias_used, as}]

      {:alias_reference, _meta, alias} ->
        [{:alias_used, alias}]

      {:require, _meta, module, _opts} ->
        if(env.module, do: [], else: [{:require_defined, module}]) ++
          [{:module_used, module}, {:alias_used, module}]

      {:struct_expansion, _meta, module, _keys} ->
        [{:module_used, module}]

      {:remote_function, _meta, module, _name, _arity} ->
        [{:module_used, module}]

      {:remote_macro, _meta, module, _name, _arity} ->
        [{:module_used, module}, {:require_used, module}]

      {:on_module, bytecode, _ignore} ->
        module = env.module
        vars = Map.keys(env.versioned_vars)
        Evaluator.write_module!(module, bytecode)
        [{:module_defined, module, vars, env.line}, {:alias_used, module}]

      _ ->
        []
    end
  end

  @doc """
  Applies updates to the tracer state.
  """
  @spec apply_updates(%__MODULE__{}, list()) :: %__MODULE__{}
  def apply_updates(info, updates) do
    Enum.reduce(updates, info, &apply_update(&2, &1))
  end

  defp apply_update(info, {:module_used, module}) do
    update_in(info.modules_used, &MapSet.put(&1, module))
  end

  defp apply_update(info, {:module_defined, module, vars, line}) do
    put_in(info.modules_defined[module], {line, vars})
  end

  defp apply_update(info, {:alias_used, alias}) do
    update_in(info.aliases_used, &MapSet.put(&1, alias))
  end

  defp apply_update(info, {:alias_defined, as, alias}) do
    put_in(info.aliases_defined[as], alias)
  end

  defp apply_update(info, {:require_used, module}) do
    update_in(info.requires_used, &MapSet.put(&1, module))
  end

  defp apply_update(info, {:require_defined, module}) do
    update_in(info.requires_defined, &MapSet.put(&1, module))
  end

  defp apply_update(info, :import_used) do
    put_in(info.imports_used?, true)
  end

  defp apply_update(info, :import_defined) do
    put_in(info.imports_defined?, true)
  end
end
