lib/protean/context.ex

defmodule Protean.Context do
  @moduledoc """
  Snapshot of active states, assigns, and the latest event seen by the machine.

  Functions in this module should rarely be used directly. Instead, rely on the API exposed by
  `Protean` and `Protean.Action` to query and modify machine context.
  """

  alias __MODULE__
  alias Protean.Action
  alias Protean.Node

  @derive {Inspect, only: [:value, :event, :assigns]}
  defstruct [
    :value,
    :event,
    final: MapSet.new(),
    assigns: %{},
    private: %{
      actions: [],
      replies: []
    }
  ]

  @type t :: %Context{
          value: value,
          final: value,
          event: Protean.event() | nil,
          assigns: assigns,
          private: private_state
        }

  @type value :: MapSet.t(Node.id())

  @type assigns :: %{any => any}

  @opaque private_state :: %{
            actions: [Action.t()],
            replies: [term()]
          }

  @doc false
  @spec new(Enumerable.t()) :: t
  def new(value), do: %Context{value: MapSet.new(value)}

  @doc """
  See `Protean.matches?/2`.
  """
  @spec matches?(t, Node.id() | String.t() | atom()) :: boolean()
  def matches?(context, descriptor)

  def matches?(%Context{value: value}, query) when is_list(query) do
    Enum.any?(value, fn id -> id == query || Node.descendant?(id, query) end)
  end

  def matches?(context, query) when is_binary(query) do
    matches?(context, parse_match_query(query))
  end

  def matches?(context, query) when is_atom(query) do
    matches?(context, to_string(query))
  end

  defp parse_match_query(""), do: ["#"]
  defp parse_match_query("#"), do: ["#"]
  defp parse_match_query("#." <> query), do: parse_match_query(query)
  defp parse_match_query("#" <> query), do: parse_match_query(query)

  defp parse_match_query(query) do
    query
    |> String.split(".")
    |> List.insert_at(0, "#")
    |> Enum.reverse()
  end

  @doc false
  @spec assign_active(t, Enumerable.t()) :: t
  def assign_active(context, ids) do
    %{context | value: MapSet.new(ids)}
  end

  @doc false
  @spec assign_final(t, MapSet.t(Node.id())) :: t
  def assign_final(context, ids) do
    %{context | final: ids}
  end

  # Assign data to a context's assigns.
  #
  # Usage:
  #
  #   * `assign(context, key, value)` - Assigns value to key in context's assigns.
  #   * `assign(context, %{})` - Merges the update map into a context's assigns.
  #   * `assign(context, enumerable)` - Collects the key/values of `enumerable` into a map, then
  #     merges that map into the context's assigns.
  @doc false
  @spec assign(t, any, any) :: t
  def assign(%Context{assigns: assigns} = context, key, value),
    do: %{context | assigns: Map.put(assigns, key, value)}

  @doc false
  @spec assign(t, Enumerable.t()) :: t
  def assign(%Context{assigns: assigns} = context, updates) when is_map(updates),
    do: %{context | assigns: Map.merge(assigns, updates)}

  def assign(context, enum),
    do: assign(context, Enum.into(enum, %{}))

  @doc false
  def actions(context), do: context.private.actions

  @doc false
  def assign_actions(context, actions \\ []),
    do: put_in(context.private.actions, actions)

  @doc false
  def update_actions(context, fun),
    do: update_in(context.private.actions, fun)

  @doc false
  def put_actions(context, actions),
    do: update_actions(context, &(&1 ++ actions))

  @doc false
  def pop_actions(context),
    do: {actions(context), assign_actions(context)}

  @doc false
  def put_reply(context, reply),
    do: update_in(context.private.replies, &[reply | &1])

  @doc false
  def get_replies(context),
    do: context.private.replies |> Enum.reverse()

  @doc false
  def pop_replies(context),
    do: {get_replies(context), put_in(context.private.replies, [])}
end