lib/reactor/builder.ex

defmodule Reactor.Builder do
  @moduledoc """
  Build a new Reactor programmatically.

  You don't _have_ to use the Reactor DSL to create a Reactor.  The functions in
  this module allow you to define a Reactor programmatically.  This is
  especially useful if you need to create a reactor dynamically (maybe based on
  a UI such as [React Flow](https://reactflow.dev/)).

  ## Example

  ```elixir
  reactor = Builder.new()
  {:ok, reactor} = Builder.add_input(reactor, :name)
  argument = Argument.from_input(:name)
  {:ok, reactor} = Builder.add_step(reactor, :greet, [argument])
  {:ok, reactor} = Builder.return(reactor, :greet)
  ```
  """

  alias Reactor.{Argument, Builder, Step}
  import Reactor, only: :macros
  import Reactor.Utils

  @type step_options :: [async? | max_retries() | arguments_transform | context | ref]

  @typedoc "Should the step be run asynchronously?"
  @type async? :: {:async?, boolean | (keyword -> boolean)}

  @typedoc "How many times is the step allowed to retry?"
  @type max_retries :: {:max_retries, :infinity | non_neg_integer()}

  @typedoc "Optionally transform all the arguments into new arguments"
  @type arguments_transform ::
          {:transform,
           nil | (%{optional(atom) => any} -> %{optional(atom) => any}) | {module | keyword} | mfa}

  @type ref :: {:ref, :step_name | :make_ref}

  @typedoc "Optional context which will be merged with the reactor context when calling this step."
  @type context :: Reactor.context()

  @type step_argument :: Argument.t() | {atom, {:input | :result, any}}
  @type impl :: module | {module, keyword}

  @doc """
  Build a new, empty Reactor.

  Optionally an identifier for the Reactor. This is primarily used for recursive
  composition tracking.
  """
  @spec new(any) :: Reactor.t()
  def new(id \\ make_ref()),
    do: %Reactor{id: id, context: %{private: %{composed_reactors: MapSet.new([id])}}}

  @doc """
  Add a named input to the Reactor.

  This both places the input in the Reactor for later input validation and adds
  steps to the Reactor which will emit and (possibly) transform the input.
  """
  @spec add_input(Reactor.t(), any, nil | (any -> any)) :: {:ok, Reactor.t()} | {:error, any}
  def add_input(reactor, name, transform \\ nil)

  def add_input(reactor, _name, _transform) when not is_reactor(reactor),
    do: {:error, argument_error(:reactor, "not a Reactor", reactor)}

  def add_input(reactor, name, transform),
    do: Builder.Input.add_input(reactor, name, transform)

  @doc """
  Raising version of `add_input/2..3`.
  """
  @spec add_input!(Reactor.t(), any, nil | (any -> any)) :: Reactor.t() | no_return
  def add_input!(reactor, name, transform \\ nil)

  def add_input!(reactor, name, transform) do
    case add_input(reactor, name, transform) do
      {:ok, reactor} -> reactor
      {:error, reason} -> raise reason
    end
  end

  @doc """
  Add a step to the Reactor.

  Add a new step to the Reactor.  Rewrites input arguments to use the result of
  the input steps and injects transformation steps as required.
  """
  @spec add_step(
          Reactor.t(),
          name :: any,
          impl,
          [step_argument],
          step_options
        ) :: {:ok, Reactor.t()} | {:error, any}

  def add_step(reactor, name, impl, arguments \\ [], options \\ [])

  def add_step(reactor, _name, _impl, _arguments, _options) when not is_reactor(reactor),
    do: {:error, argument_error(:reactor, "not a Reactor", reactor)}

  def add_step(_reactor, _name, _impl, arguments, _options) when not is_list(arguments),
    do: {:error, argument_error(:arguments, "not a list", arguments)}

  def add_step(_reactor, _name, _impl, _arguments, options) when not is_list(options),
    do: {:error, argument_error(:options, "not a list", options)}

  def add_step(reactor, name, impl, arguments, options),
    do: Builder.Step.add_step(reactor, name, impl, arguments, options)

  @doc """
  Raising version of `add_step/3..5`.
  """
  @spec add_step!(Reactor.t(), name :: any, impl, [step_argument], step_options) ::
          Reactor.t() | no_return
  def add_step!(reactor, name, impl, arguments \\ [], options \\ [])

  def add_step!(reactor, name, impl, arguments, options) do
    case add_step(reactor, name, impl, arguments, options) do
      {:ok, reactor} -> reactor
      {:error, reason} -> raise reason
    end
  end

  @doc """
  Build a step which can be added to a reactor at runtime.

  Note that the built step doesn't support transformations - you should add an
  additional step to do the transformation needed (this is what `add_step/5`
  does anyway).
  """
  @spec new_step(any, impl, [step_argument], step_options) :: {:ok, Step.t()} | {:error, any}
  def new_step(name, impl, arguments \\ [], options \\ [])

  def new_step(_name, _impl, arguments, _options) when not is_list(arguments),
    do: {:error, argument_error(:arguments, "not a list", arguments)}

  def new_step(_name, _impl, _arguments, options) when not is_list(options),
    do: {:error, argument_error(:options, "not a list", options)}

  def new_step(name, impl, arguments, options),
    do: Builder.Step.new_step(name, impl, arguments, options)

  @doc """
  Raising version of `new_step/2..4`.
  """
  @spec new_step!(any, impl, [step_argument], step_options) :: Step.t() | no_return
  def new_step!(name, impl, arguments \\ [], options \\ [])

  def new_step!(name, impl, arguments, options) do
    case new_step(name, impl, arguments, options) do
      {:ok, step} -> step
      {:error, reason} -> raise reason
    end
  end

  @doc """
  Specify the return value of the Reactor.

  The return value must be the name of a step.
  """
  @spec return(Reactor.t(), any) :: {:ok, Reactor.t()} | {:error, any}
  def return(reactor, name) do
    step_names =
      reactor.steps
      |> Enum.map(& &1.name)

    if name in step_names do
      {:ok, %{reactor | return: name}}
    else
      {:error, argument_error(:name, "not an existing step name.", name)}
    end
  end

  @doc """
  Raising version of `return/2`.
  """
  @spec return!(Reactor.t(), any) :: Reactor.t() | no_return
  def return!(reactor, name) do
    case return(reactor, name) do
      {:ok, reactor} -> reactor
      {:error, reason} -> raise reason
    end
  end

  @doc """
  Compose another Reactor inside this one.

  Whenever possible this function will extract the steps from inner Reactor and
  place them inside the parent Reactor.  In order to achieve this the composer
  will rename the steps to ensure that there are no conflicts.

  If you're attempting to create a recursive Reactor (ie compose a Reactor
  within itself) then this will be detected and runtime composition will be used
  instead.  See `Reactor.Step.Compose` for more details.
  """
  @spec compose(Reactor.t(), atom, Reactor.t() | module, [step_argument]) ::
          {:ok, Reactor.t()} | {:error, any}
  def compose(reactor, _name, _inner_reactor, _arguments) when not is_reactor(reactor),
    do: {:error, argument_error(:reactor, "not a Reactor", reactor)}

  def compose(_reactor, name, _inner_reactor, _arguments) when not is_atom(name),
    do: {:error, argument_error(:name, "not an atom", name)}

  def compose(_reactor, _name, inner_reactor, _arguments)
      when not is_reactor(inner_reactor) and not is_atom(inner_reactor),
      do: {:error, argument_error(:inner_reactor, "not a Reactor", inner_reactor)}

  def compose(_reactor, _name, _inner_reactor, arguments) when not is_list(arguments),
    do: {:error, argument_error(:arguments, "not a list", arguments)}

  def compose(reactor, name, inner_reactor, arguments),
    do: Builder.Compose.compose(reactor, name, inner_reactor, arguments)

  @doc """
  Raising version of `compose/4`.
  """
  @spec compose!(Reactor.t(), atom, Reactor.t() | module, [step_argument]) ::
          Reactor.t() | no_return
  def compose!(reactor, name, inner_reactor, arguments) do
    case compose(reactor, name, inner_reactor, arguments) do
      {:ok, reactor} -> reactor
      {:error, reason} -> raise reason
    end
  end

  @doc """
  Add an initialiser hook to the Reactor.
  """
  @spec on_init(Reactor.t(), Reactor.init_hook()) :: {:ok, Reactor.t()} | {:error, any}
  def on_init(reactor, {m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
    do: add_hook(reactor, :init, {m, f, a})

  def on_init(reactor, hook) when is_function(hook, 1),
    do: add_hook(reactor, :init, hook)

  def on_init(_reactor, hook),
    do: {:error, argument_error(:hook, "Not a valid initialisation hook", hook)}

  @doc """
  Raising version of `on_init/2`.
  """
  @spec on_init!(Reactor.t(), Reactor.init_hook()) :: Reactor.t() | no_return
  def on_init!(reactor, hook) do
    case on_init(reactor, hook) do
      {:ok, reactor} -> reactor
      {:error, reason} -> raise reason
    end
  end

  @doc """
  Add an error hook to the Reactor.
  """
  @spec on_error(Reactor.t(), Reactor.init_hook()) :: {:ok, Reactor.t()} | {:error, any}
  def on_error(reactor, {m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
    do: add_hook(reactor, :error, {m, f, a})

  def on_error(reactor, hook) when is_function(hook, 2),
    do: add_hook(reactor, :error, hook)

  def on_error(_reactor, hook),
    do: {:error, argument_error(:hook, "Not a valid error hook", hook)}

  @doc """
  Raising version of `on_error/2`.
  """
  @spec on_error!(Reactor.t(), Reactor.init_hook()) :: Reactor.t() | no_return
  def on_error!(reactor, hook) do
    case on_error(reactor, hook) do
      {:ok, reactor} -> reactor
      {:error, reason} -> raise reason
    end
  end

  @doc """
  Add a completion hook to the Reactor.
  """
  @spec on_complete(Reactor.t(), Reactor.complete_hook()) :: {:ok, Reactor.t()} | {:error, any}
  def on_complete(reactor, {m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
    do: add_hook(reactor, :complete, {m, f, a})

  def on_complete(reactor, hook) when is_function(hook, 2),
    do: add_hook(reactor, :complete, hook)

  def on_complete(_reactor, hook),
    do: {:error, argument_error(:hook, "Not a valid completion hook", hook)}

  @doc """
  Raising version of `on_complete/2`.
  """
  @spec on_complete!(Reactor.t(), Reactor.init_hook()) :: Reactor.t() | no_return
  def on_complete!(reactor, hook) do
    case on_complete(reactor, hook) do
      {:ok, reactor} -> reactor
      {:error, reason} -> raise reason
    end
  end

  @doc """
  Add a halt hook to the Reactor.
  """
  @spec on_halt(Reactor.t(), Reactor.halt_hook()) :: {:ok, Reactor.t()} | {:error, any}
  def on_halt(reactor, {m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
    do: add_hook(reactor, :halt, {m, f, a})

  def on_halt(reactor, hook) when is_function(hook, 1),
    do: add_hook(reactor, :halt, hook)

  def on_halt(_reactor, hook),
    do: {:error, argument_error(:hook, "Not a valid completion hook", hook)}

  @doc """
  Raising version of `on_halt/2`.
  """
  @spec on_halt!(Reactor.t(), Reactor.init_hook()) :: Reactor.t() | no_return
  def on_halt!(reactor, hook) do
    case on_halt(reactor, hook) do
      {:ok, reactor} -> reactor
      {:error, reason} -> raise reason
    end
  end

  defp add_hook(reactor, type, hook) when is_reactor(reactor) do
    hooks =
      reactor.hooks
      |> Map.update(type, [hook], &Enum.concat(&1, [hook]))

    {:ok, %{reactor | hooks: hooks}}
  end

  defp add_hook(reactor, _, _), do: {:error, argument_error(:reactor, "not a Reactor", reactor)}
end