lib/vnext_genai/graph/root.ex

defmodule GenAI.Graph.Root do
  @vsn 1.0
  @moduledoc """
  The root data structure that contains nested graphs, nodes and other structures.
  """

  import GenAI.Records.Link

  require GenAI.Records.Link
  require GenAI.Types.Graph

  defstruct [
    # Entry Point
    graph: nil,
    # uuid => element_lookup, handle => {:resolver, %{paths => element_entry}} | element_entry
    lookup_table: %{by_element: %{}, by_handle: %{}},
    # id of last inserted node
    last_node: nil,
    # id of last inserted link
    last_link: nil
  ]

  @doc """
  Retrieve a element_lookup entry by element id.
  """
  def element_entry(this, element)
  def element_entry(_, error = {:error, _}), do: error
  def element_entry(this, element_lookup(element: element)), do: element_entry(this, element)

  def element_entry(%__MODULE__{} = this, id) do
    if entry = this.lookup_table.by_element[id] do
      {:ok, entry}
    else
      {:error, {:element, :not_found}}
    end
  end

  @doc """
  Retrieve an element_lookup entry by handle and base node
  """
  def handle_entry(this, handle, base \\ nil)

  def handle_entry(%__MODULE__{} = this, graph_handle(name: handle), base),
    do: handle_entry(this, handle, base)

  def handle_entry(%__MODULE__{} = this, handle, base) do
    if global_entry = this.lookup_table.by_handle[graph_handle(scope: :global, name: handle)] do
      element_entry(this, global_entry)
    else
      standard_entry = this.lookup_table.by_handle[graph_handle(scope: :standard, name: handle)]
      local_entries = this.lookup_table.by_handle[graph_handle(scope: :local, name: handle)] || []

      with {:ok, element_lookup(path: path)} <- element_entry(this, base) do
        {_, entry} =
          local_entries
          |> Enum.reduce(
            {0, standard_entry},
            fn
              {entry_path, entry_element}, b = {b_depth, _} ->
                e_depth = length(entry_path)

                cond do
                  e_depth > b_depth && List.starts_with?(path, entry_path) ->
                    {e_depth, entry_element}

                  :else ->
                    b
                end
            end
          )

        if entry,
          do: element_entry(this, entry),
          else: {:error, {:element, :not_found}}
      else
        _ ->
          if standard_entry,
            do: element_entry(this, standard_entry),
            else: {:error, {:element, :not_found}}
      end
    end
  end

  @max_depth 1_000_000

  defp if_element_under_path(this, element, under)
  defp if_element_under_path(_, nil, _), do: nil

  defp if_element_under_path(this, element, element_lookup(path: under_path)) do
    case element_entry(this, element) do
      entry = {:ok, element_lookup(path: entry_path)} ->
        if List.starts_with?(entry_path, under_path), do: entry

      _ ->
        nil
    end
  end

  @doc """
  Retrieve closest nested handle nested under base.
  If a global entry is found and under base it will be used.
  If a standard entry is found and under base it will be used.
  Only if no standard or global entry under base exists will locals be checked.
  """
  def nearest_handle_entry(this, handle, base)

  def nearest_handle_entry(%__MODULE__{} = this, graph_handle(name: handle), base),
    do: nearest_handle_entry(this, handle, base)

  def nearest_handle_entry(%__MODULE__{} = this, handle, base = element_lookup(path: under_path)) do
    global_entry = this.lookup_table.by_handle[graph_handle(scope: :global, name: handle)]
    standard_entry = this.lookup_table.by_handle[graph_handle(scope: :standard, name: handle)]
    local_entries = this.lookup_table.by_handle[graph_handle(scope: :local, name: handle)] || []

    cond do
      x = if_element_under_path(this, global_entry, base) ->
        x

      x = if_element_under_path(this, standard_entry, base) ->
        x

      :else ->
        {_, entry} =
          local_entries
          |> Enum.reduce(
            {@max_depth, nil},
            fn
              {entry_path, entry_element}, b = {b_depth, _} ->
                e_depth = length(entry_path)

                cond do
                  e_depth < b_depth && List.starts_with?(entry_path, under_path) ->
                    {e_depth, entry_element}

                  :else ->
                    b
                end
            end
          )

        if entry,
          do: element_entry(this, entry),
          else: {:error, {:element, :not_found}}
    end
  end

  @doc """
  Retrieve a nested element by id from graph
  """
  def element(%__MODULE__{} = this, id) do
    with {:ok, element_lookup(path: [_ | path])} <- element_entry(this, id) do
      get_nested_element(this.graph, path)
    end
  end

  @doc """
  Retrieve a nested element by handle from graph
  """
  def element_by_handle(%__MODULE__{} = this, handle, base \\ nil) do
    with {:ok, element_lookup(path: [_ | path])} <- handle_entry(this, handle, base) do
      get_nested_element(this.graph, path)
    end
  end

  @doc """
  extract nested entry by path from source element.
  """
  def get_nested_element(source, path)

  def get_nested_element(nil, _) do
    {:error, {:element, :not_found}}
  end

  def get_nested_element(element, []) do
    {:ok, element}
  end

  def get_nested_element(element, [h | t]) do
    with {:ok, nested} <- element.__struct__.node(element, h) do
      get_nested_element(nested, t)
    end
  end

  @doc """
  Merge element lookup entries
  """
  def merge_lookup_table_entries(%__MODULE__{} = this, entries) do
    update_in(
      this,
      [Access.key(:lookup_table), Access.key(:by_element)],
      &Enum.into(entries, &1 || %{})
    )
  end

  @doc """
  Merge list of {handle, element or path to element} entries into lookup table.
  """
  def merge_handles(%__MODULE__{} = this, handles) when is_list(handles) do
    Enum.reduce(handles, this, &merge_handle(&2, &1))
  end

  @doc """
  Merge a single graph handle entry into lookup table.
  """
  def merge_handle(this, {handle = graph_handle(scope: :global), element}) do
    put_in(this, [Access.key(:lookup_table), Access.key(:by_handle), handle], element)
  end

  def merge_handle(this, {handle = graph_handle(scope: :standard), element}) do
    put_in(this, [Access.key(:lookup_table), Access.key(:by_handle), handle], element)
  end

  def merge_handle(this, {handle = graph_handle(scope: :local), elements}) do
    elements = (is_list(elements) && elements) || [elements]

    update_in(
      this,
      [Access.key(:lookup_table), Access.key(:by_handle), handle],
      &(elements ++ (&1 || []))
    )
  end

  @doc """
  Retrieve graph context by link
  """
  def graph_context_by_link(this, link, from_element)

  def graph_context_by_link(
        this,
        %GenAI.Graph.Link{target: connector(node: target)} = link,
        from_element
      ) do
    {:ok, base} = GenAI.Graph.NodeProtocol.id(from_element || this.graph)
    base = element_entry(this, base)

    case target do
      anchor_link(anchor_handle: anchor, local_handle: path) ->
        anchor_base = handle_entry(this, anchor, base)
        graph_context_by_link__to_anchor_path(this, link, path, anchor_base)

      handle = graph_handle() ->
        graph_context_by_link__to_handle(this, link, handle, base)

      element ->
        graph_context_by_link__to_element(this, link, element)
    end
  end

  # --------------
  # graph_context_by_link__to_anchor_path/5
  # --------------
  defp graph_context_by_link__to_anchor_path(this, link, path, base)

  defp graph_context_by_link__to_anchor_path(this, link, path, base) when is_list(path) do
    entry = reduce_anchor_path(this, path, base)
    graph_context_by_link__entry(this, link, entry)
  end

  defp graph_context_by_link__to_anchor_path(this, link, nil, base),
    do: graph_context_by_link__to_anchor_path(this, link, [], base)

  defp graph_context_by_link__to_anchor_path(this, link, path, base) when not is_list(path),
    do: graph_context_by_link__to_anchor_path(this, link, [path], base)

  # --------------
  # graph_context_by_link__to_handle/4
  # --------------
  defp graph_context_by_link__to_handle(this, link, handle, base) do
    # return element context for a given element handle under base
    graph_context_by_link__entry(this, link, handle_entry(this, handle, base))
  end

  # --------------
  # graph_context_by_link__to_element/3
  # --------------
  defp graph_context_by_link__to_element(this, link, element) do
    # return element context for a given element identifier
    graph_context_by_link__entry(this, link, element_entry(this, element))
  end

  # --------------
  # graph_context_by_link__entry/3
  # --------------
  defp graph_context_by_link__entry(this, link, entry)

  defp graph_context_by_link__entry(this, link, {:ok, entry}),
    do: graph_context_by_link__entry(this, link, entry)

  defp graph_context_by_link__entry(this, link, element_lookup(path: [_ | path])) do
    # return element context for a given element lookup entry
    with {container_path, target_path} <- Enum.split(path, -1),
         {:ok, target_container} <- get_nested_element(this.graph, container_path),
         {:ok, target_element} <- get_nested_element(target_container, target_path) do
      {:ok, element_context(element: target_element, link: link, container: target_container)}
    end
  end

  defp graph_context_by_link__entry(_, _, entry = {:error, _}),
    do: entry

  # --------------
  # nearest_handle_entry/3
  # --------------
  defp reduce_anchor_path(this, path, base)

  defp reduce_anchor_path(this, path, {:ok, base}),
    do: reduce_anchor_path(this, path, base)

  defp reduce_anchor_path(_, _, error = {:error, _}),
    do: error

  defp reduce_anchor_path(this, path, base) do
    Enum.reduce(path, {:ok, base}, fn
      handle, {:ok, base} ->
        nearest_handle_entry(this, handle, base)

      _, error ->
        error
    end)
  end

  def process_node(subject, _, _, session, context, options) do
    subject =
      subject
      |> GenAI.Graph.Root.merge_lookup_table_entries(
        GenAI.Graph.NodeProtocol.build_node_lookup(subject.graph, [])
      )
      |> GenAI.Graph.Root.merge_handles(
        GenAI.Graph.NodeProtocol.build_handle_lookup(subject.graph, [])
      )

    session = %{session | root: subject}
    GenAI.Graph.NodeProtocol.process_node(subject.graph, nil, subject, session, context, options)
  end
end

#
# defimpl GenAI.Thread.SessionProtocol, for: GenAI.Graph.Root do
#  require GenAI.Records.Session
#  alias GenAI.Records.Session, as: Node
#
#  def process_node(subject, scope, context, options) do
#    subject = subject
#              |> GenAI.Graph.Root.merge_lookup_table_entries(GenAI.Graph.NodeProtocol.build_node_lookup(subject.graph, []))
#              |> GenAI.Graph.Root.merge_handles(GenAI.Graph.NodeProtocol.build_handle_lookup(subject.graph, []))
#
#    updated_scope = Node.scope(
#      scope,
#      graph_node: subject.graph,
#      graph_link: nil,
#      graph_container: subject,
#      session_root: subject
#    )
#    GenAI.Thread.SessionProtocol.process_node(subject.graph, updated_scope, context, options)
#  end
# end