defmodule Livebook.Utils.Graph do
  @typedoc """
  A bottom-up graph representation encoded as a map of child-to-parent
  entries.
  """
  @type t() :: %{node_id => node_id | nil}

  @type t(node_id) :: %{node_id => node_id | nil}

  @type node_id :: term()

  @doc """
  Finds a path between nodes `from_id` and `to_id`.

  If the path exists, a top-down list of nodes is returned including
  the extreme nodes. Otherwise, an empty list is returned.
  """
  @spec find_path(t(), node_id(), node_id()) :: list(node_id())
  def find_path(graph, from_id, to_id) do
    find_path(graph, from_id, to_id, [])
  end

  defp find_path(_graph, to_id, to_id, path), do: [to_id | path]
  defp find_path(_graph, nil, _to_id, _path), do: []

  defp find_path(graph, from_id, to_id, path),
    do: find_path(graph, graph[from_id], to_id, [from_id | path])

  @doc """
  Finds graph leave nodes, that is, nodes with no children.
  """
  @spec leaves(t()) :: list(node_id())
  def leaves(graph) do
    children = MapSet.new(graph, fn {key, _} -> key end)
    parents = MapSet.new(graph, fn {_, value} -> value end)
    MapSet.difference(children, parents) |> MapSet.to_list()
  end

  @doc """
  Reduces each top-down path in the graph.

  Returns a list of accumulators, one for each leaf in the graph, in
  no specific order.
  """
  @spec reduce_paths(t(), acc, (node_id(), acc -> acc)) :: acc when acc: term()
  def reduce_paths(graph, acc, fun) do
    leaves = Livebook.Utils.Graph.leaves(graph)
    cache = do_reduce(graph, leaves, acc, fun, %{})
    Enum.map(leaves, &cache[&1])
  end

  defp do_reduce(_graph, [], _initial_acc, _fun, cache), do: cache

  defp do_reduce(graph, [cell_id | cell_ids], initial_acc, fun, cache) do
    if parent_id = graph[cell_id] do
      case cache do
        %{^parent_id => acc} ->
          acc = fun.(cell_id, acc)
          cache = put_in(cache[cell_id], acc)
          do_reduce(graph, cell_ids, initial_acc, fun, cache)

        _ ->
          do_reduce(graph, [parent_id, cell_id | cell_ids], initial_acc, fun, cache)
      end
    else
      acc = fun.(cell_id, initial_acc)
      cache = put_in(cache[cell_id], acc)
      do_reduce(graph, cell_ids, initial_acc, fun, cache)
    end
  end
end
