lib/toolbox/workflow.ex

defmodule Toolbox.Workflow do
  @moduledoc """
  Module provides API to define state machine based workflow. Workflow is represented by struct
  containing all configuration needed to handle state of individual instances of given workflow.

  Workflow is defined by transitions, which contains all needed attributes (source node, target
  node, ..). Workflow defined by transitions needs to be built epxlicitly by calling `build/1`
  function. This function validates all workflow dependencies and state machine structure.

  Workflow definition produced by `build/1` function can be used to create new instance of given
  workflow. Workflow instance is struct of:
  - unique id
  - status
  - state (map containing all relevant data, manipulated by then callback during status transition)
  - history (record of all transitions which given instance went through in the past)
  - history in default is map of `%{"timestamp" => timestamp, "status" => binary}`
  - history entry can be updated via update_history_entry callback defined in transition
  - possible transitions (list of all transitions which could be executed in close future)
  - possible transition in default is map of `%{"timestamp" => -1 | timestamp, "status" => binary}`
  - timestamp -1 represents non-timeoutable transition
  - possible transition entry can be updated via update_possible_transition callback defined in
  transition

  Workflow transition can be defined by:
  - from (source status)
  - to (target status)
  - when (predicate used to select transition which will be executed)
    - there can be multiple when definitions in list, all definitions are connected with && relation
    - possible when definitions:
      - `{Module, function}`, where function accepts transition, instance and message as args
      - `{:timeout, timeout}`, where timeout is defined in milliseconds
      - `{:=, [path, to, state, key], value}`
      - `{:>, [path, to, state, key], value}`
      - `{:<, [path, to, state, key], value}`
      - `{:<=, [path, to, state, key], value}`
      - `{:>=, [path, to, state, key], value}`
      - `{:contains, [path, to, state, key], value}`
      - `{:is_in, [path, to, state, key], list_value}`
    - all callbacks should return boolean value
  - then (callback used to update workflow instance state during transition execution)
    - there can be multiple then definitions in list, all definitions are executed in given order
    - possible then definitions:
      - `{Module, function}`, where funciton accepts transition, instance and message as args
    - all calbacks should return `{:ok, %{} = wf_instance_state} | {:error, reason}`
  - side_effects (callback used to generate output actions during transition execution)
    - there can be multiple then definitions in list, all definitions are executed in given order
    - possible then definitions:
      - `{Module, function}`, where funciton accepts transition, instance and message as args
    - all calbacks should return `{:ok, [OutputAction]} | {:error, reason}`
  - update_history_entry (callback used to modify transition execution history entry)
    - there can be multiple then definitions in list, all definitions are executed in given order
    - possible then definitions:
      - `{Module, function}`, where funciton accepts history entry, transition, instance and message
  as args
    - all calbacks should return `{:ok, %{} = updated_history_entry} | {:error, reason}`
  - update_possible_transition (callback used in `handle_message/3` to modify possible transition)
    - there can be multiple then definitions in list, all definitions are executed in given order
    - possible then definitions:
      - `{Module, function}`, where funciton accepts possible transtion, transition, instance
        and message as args
    - all calbacks should return `{:ok, %{} = updated_possible_transition} | {:error, reason}`

  Workflow transition execution uses given workflow definition and message to update state of
  given instance. If no configured workflow transition matches, nothing will happend = instance
  state will remain the same.

  Order of callback execution:
  1. when definitions of transitions in definition order
  2. then definitions of matching transition
  3. update history entry definitions of matching transition
  3. update possible transition definitions of matching transition
  5. side effects definitions of matching transition
  """

  alias __MODULE__, as: WF
  alias __MODULE__.Instance
  alias __MODULE__.Transition
  alias Toolbox.Message, as: Msg
  alias Toolbox.Scenario.OutputAction, as: OA
  alias Toolbox.Utils.Enum, as: UtilsEnum
  alias Toolbox.Utils.Map, as: UtilsMap

  defmodule Transition do
    @moduledoc """
    Transitions forms workflow configuration. Struct is used internally - workflow client should not
    know internal details about transition struct.
    """

    alias Toolbox.Workflow, as: WF

    @type builtin_when_fn_def ::
            {:=, [String.t()], term}
            | {:<, [String.t()], term}
            | {:>, [String.t()], term}
            | {:<=, [String.t()], term}
            | {:>=, [String.t()], term}
            | {:contains, [String.t()], term}
            | {:is_in, [String.t()], term}
    @type when_fn_def :: {atom, atom} | {:timeout, integer} | builtin_when_fn_def
    @type then_fn_def :: {atom, atom}
    @type side_effects_fn_def :: {atom, atom}
    @type update_history_entry_fn_def :: {atom, atom}
    @type update_possible_transition_fn_def :: {atom, atom}

    @type t :: %Transition{
            from: WF.status(),
            to: WF.status(),
            when_fn: [when_fn_def],
            then_fn: [then_fn_def],
            side_effects_fn: [side_effects_fn_def],
            timeout?: boolean,
            timeout: integer,
            attributes: %{required(atom) => term},
            update_history_entry_fn: [update_history_entry_fn_def],
            update_possible_transition_fn: [update_possible_transition_fn_def]
          }

    defstruct from: nil,
              to: nil,
              when_fn: nil,
              then_fn: nil,
              side_effects_fn: nil,
              timeout?: false,
              timeout: -1,
              attributes: %{},
              update_history_entry_fn: nil,
              update_possible_transition_fn: nil
  end

  defmodule Instance do
    @moduledoc """
    Workflow instance is simple struct which keeps state if individual workflow instances. Workflow
    updates its status/state via defined callbacks during workflow transitions.
    """

    alias Toolbox.Workflow, as: WF

    @type t :: %Instance{
            id: String.t(),
            state: map,
            last_update: integer,
            status: :none | WF.status(),
            history: [map],
            possible_transitions: [map],
            next_possible_transition_timestamp: integer | nil,
            terminated?: boolean
          }

    defstruct id: nil,
              state: %{},
              last_update: 0,
              status: :none,
              history: [],
              possible_transitions: [],
              next_possible_transition_timestamp: nil,
              terminated?: false
  end

  defstruct statuses: [],
            transitions: %{},
            terminal_statuses: [],
            built?: false

  @type status :: String.t()

  @type transitions :: %{required(status) => [Transition.t()]}
  @type t :: %WF{
          statuses: [status],
          transitions: transitions,
          terminal_statuses: [status],
          built?: boolean
        }

  @spec new :: t
  @doc "Creates new blank workflow definition"
  def new do
    %WF{}
  end

  @spec new_instance(t, status, String.t(), map, Msg.t(), Keyword.t()) ::
          {:ok, [OA.t()], Instance.t()}
          | {:terminated, [OA.t()], Instance.t()}
          | {:error, :unknown_status}
  @doc "Creates new instance for given workflow"
  def new_instance(%WF{} = wf, status, id, state, %Msg{} = msg, options \\ []) do
    if status in wf.statuses do
      transition_params =
        options
        |> Keyword.merge(
          when: [],
          then: Keyword.get(options, :then, []),
          side_effects: Keyword.get(options, :side_effects, []),
          update_history_entry: Keyword.get(options, :update_history_entry, []),
          update_possible_transition: []
        )

      tran = construct_transition(:none, status, transition_params)

      execute_transition(wf, tran, %Instance{id: id, state: state}, msg)
    else
      {:error, :unknown_status}
    end
  end

  @spec build(t) ::
          {:ok, t}
          | {:error, :transition_from_required}
          | {:error, :transition_to_required}
          | {:error, {:bad_callback, {atom, atom}}}
          | {:error, :multiple_init_statuses}
  @doc "Finishes workflow definition, validates all configured dependencies and workflow structure"
  def build(%WF{} = wf) do
    with :ok <- validate_transition_defs(wf.transitions) do
      statuses =
        wf.transitions
        |> Enum.map(fn {from, trs} -> [from | Enum.map(trs, fn tr -> tr.to end)] end)
        |> List.flatten()
        |> Enum.uniq()

      non_term_stats = Map.keys(wf.transitions)
      term_stats = Enum.filter(statuses, fn status -> !Enum.member?(non_term_stats, status) end)

      wf = %WF{
        wf
        | built?: true,
          statuses: statuses,
          terminal_statuses: term_stats
      }

      {:ok, wf}
    end
  end

  @spec validate_transition_defs(transitions) ::
          :ok
          | {:error, :transition_from_required}
          | {:error, :transition_to_required}
          | {:error, {:bad_callback, {atom, atom}}}
  defp validate_transition_defs(transitions) do
    valid_when_fn? = valid_when_fn?()

    valid_then_fn? = function_exported_validator(3)
    valid_side_effects_fn? = function_exported_validator(3)
    valid_update_history_entry_fn? = function_exported_validator(4)
    valid_update_possible_transition_fn? = function_exported_validator(4)

    transitions
    |> Map.values()
    |> List.flatten()
    |> Enum.reduce_while(:ok, fn tran, acc ->
      cond do
        tran.from == :none ->
          {:halt, {:error, :transition_from_required}}

        tran.to == nil ->
          {:halt, {:error, :transition_to_required}}

        !Enum.all?(tran.when_fn, valid_when_fn?) ->
          bad_callback = Enum.find(tran.when_fn, fn callback -> !valid_when_fn?.(callback) end)
          {:halt, {:error, {:bad_callback, bad_callback}}}

        !Enum.all?(tran.then_fn, valid_then_fn?) ->
          bad_callback = Enum.find(tran.then_fn, fn callback -> !valid_then_fn?.(callback) end)
          {:halt, {:error, {:bad_callback, bad_callback}}}

        !Enum.all?(tran.side_effects_fn, valid_side_effects_fn?) ->
          bad_callback =
            Enum.find(tran.side_effects_fn, fn callback -> !valid_side_effects_fn?.(callback) end)

          {:halt, {:error, {:bad_callback, bad_callback}}}

        !Enum.all?(tran.update_history_entry_fn, valid_update_history_entry_fn?) ->
          bad_callback =
            Enum.find(tran.update_history_entry_fn, fn callback ->
              !valid_update_history_entry_fn?.(callback)
            end)

          {:halt, {:error, {:bad_callback, bad_callback}}}

        !Enum.all?(tran.update_possible_transition_fn, valid_update_possible_transition_fn?) ->
          bad_callback =
            Enum.find(tran.update_possible_transition_fn, fn callback ->
              !valid_update_possible_transition_fn?.(callback)
            end)

          {:halt, {:error, {:bad_callback, bad_callback}}}

        true ->
          {:cont, acc}
      end
    end)
  end

  defp valid_when_fn? do
    fn
      {:timeout, _} ->
        true

      {:=, path, _} when is_list(path) ->
        true

      {:>, path, _} when is_list(path) ->
        true

      {:<, path, _} when is_list(path) ->
        true

      {:<=, path, _} when is_list(path) ->
        true

      {:>=, path, _} when is_list(path) ->
        true

      {:contains, path, _} when is_list(path) ->
        true

      {:is_in, path, value} when is_list(path) and is_list(value) ->
        true

      {mod, fun} ->
        Code.ensure_loaded?(mod) && Kernel.function_exported?(mod, fun, 3)

      _ ->
        false
    end
  end

  @spec add_transition(t, Keyword.t()) :: t
  @doc """
  Workflow transition can be defined by keys in params:
  - from (source status)
    - required parameter
  - to (target status)
    - required parameter
  - when (predicate used to select transition which will be executed)
    - optional parameter
    - there can be multiple when definitions in list, all definitions are connected with && relation
    - possible when definitions:
      - `{Module, function}`, where function accepts transition, instance and message as args
      - `{:timeout, timeout}`, where timeout is defined in milliseconds
      - `{:=, [path, to, state, key], value}`
      - `{:>, [path, to, state, key], value}`
      - `{:<, [path, to, state, key], value}`
      - `{:<=, [path, to, state, key], value}`
      - `{:>=, [path, to, state, key], value}`
      - `{:contains, [path, to, state, key], value}`
      - `{:is_in, [path, to, state, key], list_value}`
    - all callbacks should return boolean value
  - then (callback used to update workflow instance state during transition execution)
    - optional parameter
    - there can be multiple then definitions in list, all definitions are executed in given order
    - possible then definitions:
      - `{Module, function}`, where funciton accepts transition, instance and message as args
    - all calbacks should return `{:ok, %{} = wf_instance_state} | {:error, reason}`
  - side_effects (callback used to generate output actions during transition execution)
    - optional parameter
    - there can be multiple then definitions in list, all definitions are executed in given order
    - possible then definitions:
      - `{Module, function}`, where funciton accepts transition, instance and message as args
    - all calbacks should return `{:ok, [OutputAction]} | {:error, reason}`
  - update_history_entry (callback used to modify transition execution history entry)
    - optional parameter
    - there can be multiple then definitions in list, all definitions are executed in given order
    - possible then definitions:
      - `{Module, function}`, where funciton accepts history entry, transition, instance and message
        as args
    - all calbacks should return `{:ok, %{} = updated_history_entry} | {:error, reason}`
  - update_possible_transition (callback used in `handle_message/3` to modify possible transition)
    - optional parameter
    - there can be multiple then definitions in list, all definitions are executed in given order
    - possible then definitions:
      - `{Module, function}`, where funciton accepts possible transtion, transition, instance
        and message as args
    - all calbacks should return `{:ok, %{} = updated_possible_transition} | {:error, reason}`
  """
  def add_transition(%WF{} = wf, params) do
    from = Keyword.get(params, :from, :none)
    to = Keyword.get(params, :to)
    tran = construct_transition(from, to, params)
    trans = Map.update(wf.transitions, from, [tran], fn trans -> trans ++ [tran] end)
    %WF{wf | transitions: trans}
  end

  @spec construct_transition(:none | status, status, Keyword.t()) :: Transition.t()
  defp construct_transition(from, to, params) do
    when_fn = construct_callback_list(params, :when)
    then_fn = construct_callback_list(params, :then)
    side_effects_fn = construct_callback_list(params, :side_effects)
    update_history_entry_fn = construct_callback_list(params, :update_history_entry)
    update_possible_transition_fn = construct_callback_list(params, :update_possible_transition)

    attrs =
      params
      |> Keyword.drop([
        :from,
        :to,
        :when,
        :then,
        :side_effects,
        :update_history_entry,
        :update_possible_transition
      ])
      |> Map.new()

    timeout? = timeout_when_fn?(when_fn)
    {:timeout, timeout} = Enum.find(when_fn, {:timeout, -1}, &timeout_when_fn?/1)

    %Transition{
      from: from,
      to: to,
      when_fn: when_fn,
      then_fn: then_fn,
      side_effects_fn: side_effects_fn,
      timeout?: timeout?,
      timeout: timeout,
      attributes: attrs,
      update_history_entry_fn: update_history_entry_fn,
      update_possible_transition_fn: update_possible_transition_fn
    }
  end

  @builtin_fns [:=, :>, :<, :>=, :<=, :contains, :is_in]
  @spec construct_callback_list(Keyword.t(), atom) :: [
          {atom, atom}
          | {:=, term, term}
          | {:>, term, term}
          | {:<, term, term}
          | {:>=, term, term}
          | {:<=, term, term}
          | {:contains, term, term}
          | {:is_in, term, term}
          | {:timeout, integer}
        ]
  defp construct_callback_list(params, key) do
    case Keyword.get(params, key, []) do
      {builtin_fn, _, _} = builtin_fn_def when builtin_fn in @builtin_fns ->
        [builtin_fn_def]

      {:timeout, _} = timeout_def ->
        [timeout_def]

      {mod, fun} = callback_def when is_atom(mod) and is_atom(fun) ->
        [callback_def]

      callback_defs when is_list(callback_defs) ->
        callback_defs
    end
  end

  @spec timeout_when_fn?([Transition.when_fn_def()] | Transition.when_fn_def()) :: boolean
  defp timeout_when_fn?(when_fns) when is_list(when_fns),
    do: Enum.any?(when_fns, &timeout_when_fn?/1)

  defp timeout_when_fn?({:timeout, _}), do: true
  defp timeout_when_fn?(_), do: false

  @spec terminal_status?(t, status) :: boolean
  defp terminal_status?(%WF{} = wf, status) do
    Enum.member?(wf.terminal_statuses, status)
  end

  @spec handle_message(t, Msg.t()) ::
          {:ok, [OA.t()], Instance.t()}
          | {:terminated, [OA.t()], Instance.t()}
          | {:error, :not_built_yet}
          | {:error, :status_mismatch}
  @doc """
  Uses given workflow definition and message to update state of given instance. If no configured
  workflow transition matches, nothing will happend = instance state will remain the same.

  Order of callback execution:
  1. when definitions of transitions in definition order
  2. then definitions of matching transition
  3. update history entry definitions of matching transition
  3. update possible transition definitions of matching transition
  5. side effects definitions of matching transition
  """
  def handle_message(%WF{built?: false}, %Msg{}) do
    {:error, :not_built_yet}
  end

  def handle_message(%WF{built?: true} = wf, %Instance{} = wf_inst, %Msg{} = msg) do
    if Enum.member?(wf.statuses, wf_inst.status) do
      case get_matching_transition(wf, wf_inst, msg) do
        {:ok, %Transition{} = tran} ->
          execute_transition(wf, tran, wf_inst, msg)

        {:error, :no_match} ->
          {:ok, [], wf_inst}
      end
    else
      {:error, :status_mismatch}
    end
  end

  @spec get_matching_transition(t, Instance.t(), Msg.t()) ::
          {:ok, Transition.t()} | {:error, :no_match}
  defp get_matching_transition(%WF{} = wf, %Instance{} = wf_inst, %Msg{} = msg) do
    wf.transitions
    |> Map.get(wf_inst.status, [])
    |> Enum.reduce_while({:error, :no_match}, fn %Transition{} = tran, acc ->
      if evaluate_when_fn(tran, wf_inst, msg) do
        {:halt, {:ok, tran}}
      else
        {:cont, acc}
      end
    end)
  end

  @spec execute_transition(t, Transition.t(), Instance.t(), Msg.t()) ::
          {:ok, [OA.t()], Instance.t()}
          | {:terminated, [OA.t()], Instance.t()}
          | {:error, :status_mismatch}
  defp execute_transition(%WF{} = wf, %Transition{} = tran, %Instance{} = wf_inst, %Msg{} = msg) do
    last_update =
      if tran.timeout? do
        wf_inst.last_update + tran.timeout
      else
        msg.timestamp
      end

    new_wf_inst = %Instance{
      wf_inst
      | last_update: last_update,
        state: update_wf_inst_state(wf_inst, tran, msg)
    }

    possible_transitions = format_possible_transitions(wf.transitions, tran, new_wf_inst, msg)

    new_wf_inst = %Instance{
      new_wf_inst
      | status: tran.to,
        terminated?: terminal_status?(wf, tran.to),
        history: append_history(new_wf_inst, tran, msg),
        possible_transitions: possible_transitions,
        next_possible_transition_timestamp: find_next_possible_transition_ts(possible_transitions)
    }

    {:ok, oas} = evaluate_side_effects_fn(tran, new_wf_inst, msg)

    cond do
      new_wf_inst.terminated? ->
        {:terminated, oas, new_wf_inst}

      tran.timeout? ->
        case handle_message(wf, new_wf_inst, msg) do
          {:ok, non_timeout_oas, wf_inst} ->
            {:ok, oas ++ non_timeout_oas, wf_inst}

          {:terminated, non_timeout_oas, wf_inst} ->
            {:terminated, oas ++ non_timeout_oas, wf_inst}

          {:error, :status_mismatch} ->
            {:error, :status_mismatch}
        end

      true ->
        {:ok, oas, new_wf_inst}
    end
  end

  @spec update_wf_inst_state(Instance.t(), Transition.t(), Msg.t()) :: map
  defp update_wf_inst_state(%Instance{} = wf_inst, %Transition{} = tran, %Msg{} = msg) do
    case evaluate_then_fn(tran, wf_inst, msg) do
      {:ok, new_state} ->
        new_state

      {:error, _reason} ->
        wf_inst.state
    end
  end

  @spec append_history(Instance.t(), Transition.t(), Msg.t()) :: [map]
  defp append_history(%Instance{} = wf_inst, %Transition{} = tran, %Msg{} = msg) do
    history_entry = %{
      "timestamp" => msg.timestamp,
      "status" => tran.to
    }

    new_history_entry =
      case evaluate_update_history_entry_fn(history_entry, tran, wf_inst, msg) do
        {:ok, new_history_entry} ->
          new_history_entry

        {:error, _reason} ->
          history_entry
      end

    wf_inst.history ++ [new_history_entry]
  end

  @spec format_possible_transitions(transitions, Transition.t(), Instance.t(), Msg.t()) :: [map()]
  defp format_possible_transitions(transitions, tran, wf_inst, msg) do
    transitions
    |> Map.get(tran.to, [])
    |> Enum.filter(fn pos_tran ->
      Enum.all?(pos_tran.when_fn, fn
        {:=, _, _} = builtin_fn_def ->
          evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

        {:<, _, _} = builtin_fn_def ->
          evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

        {:>, _, _} = builtin_fn_def ->
          evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

        {:<=, _, _} = builtin_fn_def ->
          evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

        {:>=, _, _} = builtin_fn_def ->
          evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

        {:contains, _, _} = builtin_fn_def ->
          evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

        {:is_in, _, _} = builtin_fn_def ->
          evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

        {:timeout, _} ->
          true

        {_, _} ->
          true
      end)
    end)
    |> Enum.map(fn pos_tran ->
      pos_tran_entry =
        if pos_tran.timeout? do
          %{
            "timestamp" => msg.timestamp + pos_tran.timeout,
            "status" => pos_tran.to
          }
        else
          %{
            "timestamp" => -1,
            "status" => pos_tran.to
          }
        end

      case evaluate_update_possible_transition_fn(pos_tran_entry, pos_tran, wf_inst, msg) do
        {:ok, new_pos_tran_entry} ->
          new_pos_tran_entry

        {:error, _reason} ->
          pos_tran_entry
      end
    end)
  end

  @spec find_next_possible_transition_ts([map()]) :: integer | nil
  # loop through possible_transitions and returns lowest transition timestamp
  defp find_next_possible_transition_ts(possible_transitions) do
    possible_transitions
    |> Enum.map(fn pos_tran -> pos_tran["timestamp"] end)
    # timestamp with -1 means unknown transition timestamp
    |> Enum.reject(fn timestamp -> timestamp == -1 end)
    # find lowest timestamp, return `nil` when there is no transition with known timestamp
    |> Enum.min(fn -> nil end)
  end

  @spec evaluate_when_fn(Transition.t(), Instance.t(), Msg.t()) :: boolean
  defp evaluate_when_fn(%Transition{} = tran, %Instance{} = wf_inst, %Msg{} = msg) do
    Enum.all?(tran.when_fn, fn
      {:timeout, timeout} ->
        timeout + wf_inst.last_update <= msg.timestamp

      {:=, _, _} = builtin_fn_def ->
        evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

      {:>, _, _} = builtin_fn_def ->
        evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

      {:<, _, _} = builtin_fn_def ->
        evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

      {:>=, _, _} = builtin_fn_def ->
        evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

      {:<=, _, _} = builtin_fn_def ->
        evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

      {:contains, _, _} = builtin_fn_def ->
        evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

      {:is_in, _, _} = builtin_fn_def ->
        evaluate_builtin_fn(builtin_fn_def, wf_inst.state)

      {mod, fun} ->
        apply(mod, fun, [tran, wf_inst, msg])
    end)
  end

  @spec evaluate_builtin_fn(Transition.builtin_when_fn_def(), map) :: boolean
  defp evaluate_builtin_fn({:=, path, val}, state) do
    UtilsMap.get_path(state, path) == val
  end

  defp evaluate_builtin_fn({:>, path, val}, state) do
    UtilsMap.get_path(state, path) > val
  end

  defp evaluate_builtin_fn({:<, path, val}, state) do
    UtilsMap.get_path(state, path) < val
  end

  defp evaluate_builtin_fn({:>=, path, val}, state) do
    UtilsMap.get_path(state, path) >= val
  end

  defp evaluate_builtin_fn({:<=, path, val}, state) do
    UtilsMap.get_path(state, path) <= val
  end

  defp evaluate_builtin_fn({:contains, path, val}, state) do
    case UtilsMap.get_path(state, path, []) do
      value when is_list(value) ->
        Enum.member?(value, val)

      _ ->
        false
    end
  end

  defp evaluate_builtin_fn({:is_in, path, list_val}, state) do
    Enum.member?(list_val, UtilsMap.get_path(state, path))
  end

  @spec evaluate_then_fn(Transition.t(), Instance.t(), Msg.t()) :: {:ok, map} | {:error, term}
  defp evaluate_then_fn(%Transition{} = tran, %Instance{} = wf_inst, %Msg{} = msg) do
    UtilsEnum.reduce_while_ok(tran.then_fn, wf_inst.state, fn
      {mod, fun}, state ->
        apply(mod, fun, [tran, %Instance{wf_inst | state: state}, msg])
    end)
  end

  @spec evaluate_side_effects_fn(Transition.t(), Instance.t(), Msg.t()) ::
          {:ok, []} | {:error, term}
  defp evaluate_side_effects_fn(tran, wf_inst, msg) do
    Enum.reduce_while(tran.side_effects_fn, {:ok, []}, fn {mod, fun}, {:ok, oas} ->
      case apply(mod, fun, [tran, wf_inst, msg]) do
        {:ok, new_oas} ->
          {:cont, {:ok, oas ++ new_oas}}

        {:error, reason} ->
          {:halt, {:error, reason}}
      end
    end)
  end

  @spec evaluate_update_history_entry_fn(map, Transition.t(), Instance.t(), Msg.t()) ::
          {:ok, map} | {:error, term}
  defp evaluate_update_history_entry_fn(history_entry, tran, wf_inst, msg) do
    UtilsEnum.reduce_while_ok(tran.update_history_entry_fn, history_entry, fn
      {mod, fun}, history_entry ->
        apply(mod, fun, [history_entry, tran, wf_inst, msg])
    end)
  end

  @spec evaluate_update_possible_transition_fn(map, Transition.t(), Instance.t(), Msg.t()) ::
          {:ok, map} | {:error, term}
  defp evaluate_update_possible_transition_fn(pos_tran_entry, tran, wf_inst, msg) do
    UtilsEnum.reduce_while_ok(tran.update_possible_transition_fn, pos_tran_entry, fn
      {mod, fun}, pos_tran_entry ->
        apply(mod, fun, [pos_tran_entry, tran, wf_inst, msg])
    end)
  end

  defp function_exported_validator(arity) do
    fn {mod, fun} ->
      Code.ensure_loaded?(mod) && Kernel.function_exported?(mod, fun, arity)
    end
  end
end