defmodule Livebook.Runtime.NodePool do
  use GenServer

  @moduledoc false

  # A pool for reusing child node names.
  #
  # `free_name` refers to the list of unused names.
  # `generated_names` refers to the list of names ever generated.
  #
  # `buffer_time` refers to time the pool waits before
  # adding a name to `pool`,  which by default is 1 minute.

  @default_time 60_000

  # Client interface

  @doc """
  Starts the GenServer from a Supervision tree

  ## Options

    - `:name` - The name the NodePool is locally registered as. By default, it is `Livebook.Runtime.NodePool`
    - `:buffer_time` - The time that is spent before a disconnected node's name is  added to pool. The default is 1 minute.
  """
  def start_link(opts) do
    name = opts[:name] || __MODULE__
    buffer_time = opts[:buffer_time] || @default_time

    GenServer.start_link(
      __MODULE__,
      %{buffer_time: buffer_time},
      name: name
    )
  end

  @doc """
  Returns a node name.

  Generates a new name if pool is empty, or takes one from pool.
  """
  def get_name(server \\ __MODULE__, basename) do
    GenServer.call(server, {:get_name, basename})
  end

  # Server side code

  @impl GenServer
  def init(opts) do
    :net_kernel.monitor_nodes(true, node_type: :all)
    {:ok, %{buffer_time: opts.buffer_time, generated_names: MapSet.new(), free_names: []}}
  end

  @impl GenServer
  def handle_call({:get_name, basename}, _, state) do
    {name, new_state} = name(state, basename)
    {:reply, name, new_state}
  end

  @impl GenServer
  def handle_info({:nodedown, node, _info}, state) do
    case state.buffer_time do
      0 -> send(self(), {:add_node, node})
      t -> Process.send_after(self(), {:add_node, node}, t)
    end

    {:noreply, state}
  end

  @impl GenServer
  def handle_info({:nodeup, _node, _info}, state) do
    {:noreply, state}
  end

  @impl GenServer
  def handle_info({:add_node, node}, state) do
    {:noreply, add_node(state, node)}
  end

  # Helper functions

  defp name(state, basename) do
    if Enum.empty?(state.free_names) do
      generate_name(state, basename)
    else
      get_existing_name(state)
    end
  end

  defp generate_name(state, basename) do
    new_name = :"#{Livebook.Utils.random_short_id()}-#{basename}"
    generated_names = MapSet.put(state.generated_names, new_name)
    {new_name, %{state | generated_names: generated_names}}
  end

  defp get_existing_name(state) do
    [name | free_names] = state.free_names
    {name, %{state | free_names: free_names}}
  end

  defp add_node(state, node) do
    if MapSet.member?(state.generated_names, node) do
      free_names = [node | state.free_names]
      %{state | free_names: free_names}
    else
      state
    end
  end
end
