defmodule Dune.Parser.AtomEncoder do
  @moduledoc false

  alias Dune.AtomMapping

  @type atom_category :: :alias | :private_var | :public_var | :other

  @atom_categories 4

  # TODO Remove when dropping support for Elixir 1.16
  extra_modules =
    if System.version() |> Version.compare("1.17.0-rc.0") != :lt, do: [Duration], else: []

  @elixir_modules [
                    Kernel,
                    Kernel.SpecialForms,
                    Atom,
                    Base,
                    Bitwise,
                    Date,
                    DateTime,
                    Duration,
                    Exception,
                    Float,
                    Function,
                    Integer,
                    Module,
                    NaiveDateTime,
                    Record,
                    Regex,
                    String,
                    Time,
                    Tuple,
                    URI,
                    Version,
                    Version.Requirement,
                    Access,
                    Date.Range,
                    Enum,
                    Keyword,
                    List,
                    Map,
                    MapSet,
                    Range,
                    Stream,
                    File,
                    File.Stat,
                    File.Stream,
                    IO,
                    IO.ANSI,
                    IO.Stream,
                    OptionParser,
                    Path,
                    Port,
                    StringIO,
                    System,
                    Calendar,
                    Calendar.ISO,
                    Calendar.TimeZoneDatabase,
                    Calendar.UTCOnlyTimeZoneDatabase,
                    Agent,
                    Application,
                    Config,
                    Config.Provider,
                    Config.Reader,
                    DynamicSupervisor,
                    GenServer,
                    Node,
                    Process,
                    Registry,
                    Supervisor,
                    Task,
                    Task.Supervisor,
                    Collectable,
                    Enumerable,
                    Inspect,
                    Inspect.Algebra,
                    Inspect.Opts,
                    List.Chars,
                    Protocol,
                    String.Chars,
                    Code,
                    Kernel.ParallelCompiler,
                    Macro,
                    Macro.Env,
                    Behaviour,
                    Dict,
                    GenEvent,
                    HashDict,
                    HashSet,
                    Set,
                    Supervisor.Spec,
                    ArgumentError,
                    ArithmeticError,
                    BadArityError,
                    BadBooleanError,
                    BadFunctionError,
                    BadMapError,
                    BadStructError,
                    CaseClauseError,
                    Code.LoadError,
                    CompileError,
                    CondClauseError,
                    Enum.EmptyError,
                    Enum.OutOfBoundsError,
                    ErlangError,
                    File.CopyError,
                    File.Error,
                    File.LinkError,
                    File.RenameError,
                    FunctionClauseError,
                    IO.StreamError,
                    Inspect.Error,
                    KeyError,
                    MatchError,
                    Module.Types.Error,
                    OptionParser.ParseError,
                    Protocol.UndefinedError,
                    Regex.CompileError,
                    RuntimeError,
                    SyntaxError,
                    SystemLimitError,
                    TokenMissingError,
                    TryClauseError,
                    DuneRestrictedError,
                    UnicodeConversionError,
                    Version.InvalidRequirementError,
                    Version.InvalidVersionError,
                    WithClauseError
                  ] ++ extra_modules

  @module_reprs @elixir_modules
                |> Enum.flat_map(&Module.split/1)
                |> Map.new(&{&1, String.to_existing_atom(&1)})

  @spec load_atom_mapping(AtomMapping.t() | nil) :: :ok
  def load_atom_mapping(nil), do: :ok

  def load_atom_mapping(%AtomMapping{atoms: atoms}) do
    count = Enum.count(atoms)
    Process.put(:__Dune_atom_count__, count)

    Enum.each(atoms, fn {atom, binary} ->
      Process.put({:__Dune_atom__, binary}, atom)
    end)
  end

  @spec static_atoms_encoder(String.t(), non_neg_integer()) :: {:ok, atom} | {:error, String.t()}
  def static_atoms_encoder(binary, pool_size)
      when is_binary(binary) and is_integer(pool_size) do
    case @module_reprs do
      %{^binary => atom} ->
        {:ok, atom}

      _ ->
        if binary =~ "Dune" do
          {:error, "Atoms containing `Dune` are restricted for safety"}
        else
          atom_category = categorize_atom_binary(binary)
          do_static_atoms_encoder(binary, atom_category, pool_size)
        end
    end
  end

  @spec categorize_atom_binary(binary) :: atom_category
  def categorize_atom_binary(atom_binary) do
    charlist = String.to_charlist(atom_binary)

    case {Code.Fragment.cursor_context(charlist), atom_binary} do
      {{:alias, ^charlist}, _} -> :alias
      {{:local_or_var, ^charlist}, "_" <> _} -> :private_var
      {{:local_or_var, ^charlist}, _} -> :public_var
      _ -> :other
    end
  end

  defp do_static_atoms_encoder("Elixir." <> rest, :alias, pool_size) do
    rest
    |> String.split(".")
    |> encode_many_atoms(pool_size, [])
  end

  defp do_static_atoms_encoder(binary, atom_category, pool_size) do
    process_key = {:__Dune_atom__, binary}

    case Process.get(process_key, nil) do
      nil -> do_static_atoms_encoder(binary, atom_category, process_key, pool_size)
      atom when is_atom(atom) -> {:ok, atom}
    end
  end

  defp do_static_atoms_encoder(binary, atom_category, process_key, pool_size) do
    {:ok, String.to_existing_atom(binary)}
  rescue
    ArgumentError ->
      case new_atom(atom_category, pool_size) do
        {:ok, atom} ->
          Process.put(process_key, atom)

          if atom_category == :other do
            Process.put({:__Dune_atom_extra_info__, atom}, :wrapped)
          end

          {:ok, atom}

        {:error, error} ->
          {:error, error}
      end
  end

  defp encode_many_atoms([], _pool_size, acc) do
    {:ok, {:__aliases__, [], [Elixir | Enum.reverse(acc)]}}
  end

  defp encode_many_atoms([head | tail], pool_size, acc) do
    case do_static_atoms_encoder(head, :alias, pool_size) do
      {:ok, atom} -> encode_many_atoms(tail, pool_size, [atom | acc])
      {:error, error} -> {:error, error}
    end
  end

  @spec plain_atom_mapping :: AtomMapping.t()
  def plain_atom_mapping() do
    atoms =
      for {{:__Dune_atom__, binary}, atom} <- Process.get() do
        {atom, binary}
      end

    extra_info =
      for {{:__Dune_atom_extra_info__, atom}, info} <- Process.get() do
        {atom, info}
      end

    AtomMapping.from_atoms(atoms, extra_info)
  end

  defp new_atom(atom_category, pool_size) do
    count = Process.get(:__Dune_atom_count__, 0) + 1

    if count * @atom_categories > pool_size do
      {:error, "atom_pool_size exceeded, failed to parse atom"}
    else
      Process.put(:__Dune_atom_count__, count)
      atom = do_new_atom(atom_category, count)
      {:ok, atom}
    end
  end

  defp do_new_atom(:alias, count) do
    :"Dune_Atom_#{count}__"
  end

  defp do_new_atom(:public_var, count) do
    :"a__Dune_atom_#{count}__"
  end

  defp do_new_atom(category, count) when category in [:private_var, :other] do
    :"__Dune_atom_#{count}__"
  end

  @spec encode_modules(Macro.t(), AtomMapping.t(), AtomMapping.t() | nil) ::
          {Macro.t(), AtomMapping.t()}
  def encode_modules(ast, plain_atom_mapping, existing_mapping) do
    initial_acc = get_module_acc(existing_mapping)

    {new_ast, acc} =
      Macro.postwalk(ast, initial_acc, fn
        {:__aliases__, ctx, atoms}, acc ->
          {modules, new_acc} = remove_elixir_prefix(atoms) |> map_modules_ast(acc)
          {{:__aliases__, ctx, modules}, new_acc}

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

    atom_mapping = build_module_mapping(acc, plain_atom_mapping)

    {new_ast, atom_mapping}
  end

  defp get_module_acc(nil), do: %{}

  defp get_module_acc(%AtomMapping{atoms: atoms, modules: modules}) do
    reverse_atoms = Map.new(atoms, fn {atom, string} -> {string, atom} end)

    Map.new(modules, fn {atom, string} ->
      atoms = String.split(string, ".") |> Enum.map(&Map.fetch!(reverse_atoms, &1))
      {atoms, atom}
    end)
  end

  defp remove_elixir_prefix(atoms = [Elixir, Elixir | _]), do: atoms
  defp remove_elixir_prefix([Elixir | atoms]) when atoms != [], do: atoms
  defp remove_elixir_prefix(atoms), do: atoms

  defp map_modules_ast(atoms, acc) do
    case acc do
      %{^atoms => module_name} ->
        {[module_name], acc}

      _ ->
        try do
          atoms |> Enum.join(".") |> then(&"Elixir.#{&1}") |> String.to_existing_atom()
        rescue
          ArgumentError ->
            module_name = :"Dune_Module_#{map_size(acc) + 1}__"
            {[module_name], Map.put(acc, atoms, module_name)}
        else
          _ ->
            {atoms, acc}
        end
    end
  end

  defp build_module_mapping(acc, plain_atom_mapping) do
    modules =
      Enum.map(acc, fn {atoms, module_name} ->
        string = Enum.map_join(atoms, ".", &AtomMapping.to_string(plain_atom_mapping, &1))
        module = Module.concat([module_name])
        {module, string}
      end)

    AtomMapping.add_modules(plain_atom_mapping, modules)
  end
end
