lib/mobus/stepwise/components/stepwise_advance.ex

defmodule Mobus.Stepwise.Components.StepwiseAdvance do
  @moduledoc """
  Advances (or reverses) the current step for stepwise workflows.

  Step ordering is derived from `Mobus.Stepwise.SpecHelpers.ordered_steps/1`:
  - `spec.steps` (list of step IDs), otherwise
  - `states` with `step_number` (ascending)

  Supported events:
  - `:next` / `"next"`
  - `:back` / `"back"`
  """

  alias Mobus.Stepwise.SpecHelpers

  @doc """
  Advances or reverses the current step based on the event.

  This is an ALF pipeline stage. For `:next`/`"next"` events, moves to the next
  step in order. For `:back`/`"back"`, moves to the previous step. For custom
  events, looks up explicit transitions in `spec.transitions`. Updates history
  and trace on successful transitions.

  Skipped when `skip_transition: true` is set (projection-only mode) or on error status.

  ## Parameters

    * `event` — pipeline event map with `:spec`, `:runtime`, `:event`, `:payload`
    * `opts` — ALF stage options (unused)

  ## Returns

    * Updated event map with `:runtime` (potentially new `current_state`),
      `:previous_state`, and `:state_changed?` flag.

  """
  @spec call(map(), map()) :: map()
  def call(%{skip_transition: true} = event, _opts), do: event
  def call(%{status: :error} = event, _opts), do: event
  # Capability yielded wait — do not advance. The step that returned wait
  # stays current until `Engine.restore/3` + another `handle_event/3` moves
  # the runtime forward on resume.
  def call(%{wait: wait} = event, _opts) when not is_nil(wait), do: event

  def call(%{spec: spec, runtime: runtime, event: event_name, payload: payload} = event, _opts)
      when is_map(payload) do
    current = Map.get(runtime, :current_state)
    ordered = SpecHelpers.ordered_steps(spec)

    runtime =
      case normalize_event_key(event_name) do
        :next ->
          maybe_move(runtime, ordered, current, :next, spec)

        "next" ->
          maybe_move(runtime, ordered, current, :next, spec)

        :back ->
          maybe_move(runtime, ordered, current, :back, spec)

        "back" ->
          maybe_move(runtime, ordered, current, :back, spec)

        _ ->
          maybe_apply_explicit_transition(runtime, spec, event_name, current)
      end

    event
    |> Map.put(:runtime, runtime)
    |> Map.put(:previous_state, current)
    |> Map.put(:state_changed?, not SpecHelpers.equivalent_state?(current, Map.get(runtime, :current_state)))
  end

  def call(event, _opts),
    do: Map.put(event, :status, :error) |> Map.put(:error, :invalid_stepwise_shape)

  defp maybe_move(runtime, ordered, current, dir, spec) when is_list(ordered) do
    idx = Enum.find_index(ordered, &SpecHelpers.equivalent_state?(&1, current))

    next_state =
      cond do
        is_nil(idx) ->
          nil

        dir == :next and idx + 1 < length(ordered) ->
          Enum.at(ordered, idx + 1)

        dir == :back and idx - 1 >= 0 ->
          Enum.at(ordered, idx - 1)

        true ->
          nil
      end

    if is_nil(next_state) do
      runtime
    else
      case apply_transition_policy(spec, runtime, current, next_state, dir) do
        :allow ->
          commit_move(runtime, current, next_state, dir)

        {:redirect, target} ->
          commit_move(runtime, current, target, dir)

        {:deny, reason} ->
          Map.update(runtime, :blocked_reasons, %{}, &Map.put(&1, dir, reason))
      end
    end
  end

  defp normalize_event_key(event) when is_atom(event), do: event
  defp normalize_event_key(event) when is_binary(event), do: event
  defp normalize_event_key(_), do: nil

  defp maybe_apply_explicit_transition(runtime, spec, event_name, current) do
    case resolve_transition_to(spec, event_name) do
      nil ->
        runtime

      to_state ->
        case apply_transition_policy(spec, runtime, current, to_state, event_name) do
          :allow ->
            commit_move(runtime, current, to_state, event_name)

          {:redirect, target} ->
            commit_move(runtime, current, target, event_name)

          {:deny, reason} ->
            Map.update(runtime, :blocked_reasons, %{}, &Map.put(&1, event_name, reason))
        end
    end
  end

  defp commit_move(runtime, from, to, event) do
    trace_entry = %{kind: :step, from: from, to: to}

    trace_entry =
      if event in [:next, :back, "next", "back"],
        do: Map.put(trace_entry, :direction, event),
        else: Map.put(trace_entry, :event, event)

    runtime
    |> Map.put(:current_state, to)
    |> Map.update(:history, [], fn hist ->
      hist ++ [%{event: event, from: from, to: to, at: DateTime.utc_now()}]
    end)
    |> Map.update(:trace, [], fn trace ->
      trace ++ [trace_entry]
    end)
  end

  # Optional transition policy hook. When `spec.transition_policy` names a
  # module that exports `allow_transition?/5`, it is called before every
  # state move. Fail-open: if the module is absent, misconfigured, or raises,
  # the transition proceeds as normal.
  #
  # Expected callback:
  #   allow_transition?(spec, runtime, from_state, to_state, event) ::
  #     :allow | {:redirect, state} | {:deny, reason}
  defp apply_transition_policy(spec, runtime, from, to, event) do
    case Map.get(spec, :transition_policy) do
      nil ->
        :allow

      module when is_atom(module) ->
        if function_exported?(module, :allow_transition?, 5) do
          try do
            module.allow_transition?(spec, runtime, from, to, event)
          rescue
            _ -> :allow
          end
        else
          :allow
        end

      _ ->
        :allow
    end
  end

  defp resolve_transition_to(spec, event_name) do
    transitions = Map.get(spec, :transitions) || Map.get(spec, "transitions") || %{}

    transition =
      Map.get(transitions, event_name) ||
        Map.get(transitions, to_string(event_name)) ||
        Map.get(transitions, normalize_event_key(event_name))

    case transition do
      %{to: to} -> to
      %{"to" => to} -> to
      _ -> nil
    end
  end

  end