defmodule BroadwayKafka.Allocator do
  @moduledoc false

  # The allocator is responsible to allocate Kafka partitions
  # for each layer of stages in Broadway. We have an allocator
  # for each processor (vertical) and an allocator for each
  # batcher (horizontal).
  #
  # The allocator is stateful, as it must avoid rellocations,
  # as that would imply lost of ordering.

  use GenServer

  @doc """
  Starts a new allocator.
  """
  def start_link({name, producers, processors})
      when is_atom(name) and producers > 0 and processors > 0 do
    GenServer.start_link(__MODULE__, {name, producers, processors}, name: name)
  end

  @doc """
  Fetches the partition for a given `key`.
  """
  def fetch!(name, key) when is_atom(name), do: :ets.lookup_element(name, key, 2)

  @doc """
  Allocates the given `new_entries` for `producer`.
  """
  def allocate(name, producer, new_entries)
      when is_atom(name) and producer >= 0 and is_list(new_entries),
      do: GenServer.call(name, {:allocate, producer, new_entries}, :infinity)

  @doc """
  Returns the allocation map with partitions as keys and
  a list of allocated keys as values. Used for testing.
  """
  def to_map(name), do: GenServer.call(name, :to_map, :infinity)

  @impl true
  def init({name, producers, processors}) when producers > 0 and processors > 0 do
    partitions = for i <- 0..(processors - 1), into: %{}, do: {i, %{}}
    producers = for i <- 0..(producers - 1), into: %{}, do: {i, %{}}
    old_producers = producers
    keys = :ets.new(name, [:named_table, :set, :protected, read_concurrency: true])
    old_keys = %{}

    # `producers` - a map from the producer index to all partitions it holds
    # `old_producers` - a map from the producer index to all partitions it previously held
    # `partitions` - a map with the processor index pointing with a map as set of partitions
    # `keys` - an ets set from the partition pointing to the processor index
    # `old_keys` - a map from the partition pointing to its previous processor index
    {:ok, {producers, old_producers, partitions, keys, old_keys}}
  end

  @impl true
  def handle_call(:to_map, _from, {_, _, partitions, _, _} = state) do
    map =
      for {partition, map} <- partitions, into: %{} do
        {partition, Map.keys(map)}
      end

    {:reply, map, state}
  end

  @impl true
  def handle_call(
        {:allocate, producer, new_entries},
        _from,
        {producers, old_producers, partitions, keys, old_keys}
      ) do
    # Update the producer allocation
    old_entries = Map.fetch!(old_producers, producer)
    current_entries = Map.fetch!(producers, producer)
    old_producers = Map.put(producers, producer, current_entries)
    producers = Map.put(producers, producer, new_entries)

    # We remove the current entries that are no longer used
    to_remove_entries = Enum.reject(current_entries, &in_a_producer?(producers, &1))
    partitions = remove_unused(to_remove_entries, partitions, keys)

    # We remove the old entries that are no longer used
    fun = &(in_a_producer?(producers, &1) or in_a_producer?(old_producers, &1))
    old_keys = Map.drop(old_keys, Enum.reject(old_entries, fun))

    # Now we reject anything that is currently assigned and
    # split into unseen and seen entries.
    {seen_entries, unseen_entries} =
      new_entries
      |> Enum.reject(&:ets.member(keys, &1))
      |> Enum.split_with(&Map.has_key?(old_keys, &1))

    # We first assign the ones that we know the location
    # so we can do a better distribution for the unseen ones
    partitions = assign_seen_entries(seen_entries, partitions, old_keys, keys)

    # Now we assign unseen ones
    {_, partitions, old_keys} = assign_unseen_entries(unseen_entries, partitions, old_keys, keys)

    {:reply, :ok, {producers, old_producers, partitions, keys, old_keys}}
  end

  defp in_a_producer?(producers, entry) do
    Enum.any?(producers, fn {_, entries} -> entry in entries end)
  end

  defp remove_unused(to_remove, partitions, keys) do
    Enum.reduce(to_remove, partitions, fn entry, partitions ->
      [{^entry, partition}] = :ets.take(keys, entry)
      {true, partitions} = pop_in(partitions[partition][entry])
      partitions
    end)
  end

  defp assign_seen_entries(seen_entries, partitions, old_keys, keys) do
    Enum.reduce(seen_entries, partitions, fn entry, partitions ->
      partition = Map.fetch!(old_keys, entry)
      :ets.insert(keys, {entry, partition})
      put_in(partitions[partition][entry], true)
    end)
  end

  defp assign_unseen_entries(unseen_entries, partitions, old_keys, keys) do
    # Get the partitions with fewer allocations first and assign from there
    sorted = sort_partitions_by_allocation(partitions)

    Enum.reduce(unseen_entries, {sorted, partitions, old_keys}, fn
      entry, {[{size, partition} | sorted], partitions, old_keys} ->
        sorted = add_to_sorted({size + 1, partition}, sorted)
        partitions = put_in(partitions[partition][entry], true)
        old_keys = put_in(old_keys[entry], partition)
        :ets.insert(keys, {entry, partition})
        {sorted, partitions, old_keys}
    end)
  end

  defp sort_partitions_by_allocation(partitions) do
    partitions
    |> Enum.map(fn {partition, map} -> {map_size(map), partition} end)
    |> Enum.sort()
  end

  defp add_to_sorted({size, partition}, [{next_size, _} | _] = rest) when size <= next_size,
    do: [{size, partition} | rest]

  defp add_to_sorted(tuple, [pair | rest]),
    do: [pair | add_to_sorted(tuple, rest)]

  defp add_to_sorted(tuple, []),
    do: [tuple]
end
