lib/foundry/context/scenarios/adapters/trigger.ex

defmodule Foundry.Context.Scenarios.Adapters.Trigger do
  @moduledoc false

  @behaviour ExTracer.Adapter

  alias ExTracer.CallTracer
  alias ExTracer.FlowExpander
  alias ExTracer.FlowSummary
  alias ExTracer.TestBlock
  alias Foundry.Context.Scenarios.Adapters.Oban
  alias Foundry.Context.Scenarios.ModuleIndex
  alias Foundry.Context.Scenarios.Utils

  @impl true
  def expand_step(step, lookup) do
    case Map.get(lookup.by_id, step.node_id || "") do
      %{type: "trigger"} = node ->
        if String.ends_with?(step.module_function || "", ".handle_webhook") do
          expand_with_chain_step(step, node, lookup)
        else
          []
        end

      _ ->
        []
    end
  end

  @impl true
  def classify_call(module_ast, fun, args, alias_map, lookup, opts) do
    Foundry.Context.Scenarios.CallClassifier.classify_ast_call(
      module_ast,
      fun,
      args,
      alias_map,
      lookup,
      opts
    )
  end

  @impl true
  def focus_for_helper(module_name, helper_name, lookup) do
    with {:ok, module_ast, alias_map} <- ModuleIndex.fetch_module_ast(module_name, lookup),
         {:ok, body} <- ModuleIndex.find_function_body(module_ast, helper_name) do
      CallTracer.collect_executed_trace(
        %TestBlock{name: Atom.to_string(helper_name), kind: :test, line: nil, block: body},
        alias_map,
        lookup,
        [__MODULE__, Oban]
      )
      |> Enum.find_value(&(&1.focus_node_id || &1.node_id))
      |> Kernel.||(Oban.focus_for_helper(module_name, helper_name, lookup))
      |> Utils.base_node_id()
    end
  end

  def expand_with_chain_step(step, node, lookup) do
    with {:ok, module_ast, alias_map} <- ModuleIndex.fetch_module_ast(node.module, lookup),
         {:ok, body} <- ModuleIndex.find_function_body(module_ast, :handle_webhook),
         {:with, _meta, args} <- body do
      {clauses, [options]} = Enum.split(args, -1)
      do_block = Keyword.get(options, :do)
      else_block = Keyword.get(options, :else)
      reached_count = with_reached_count(step, clauses)

      clause_steps =
        clauses
        |> Enum.take(reached_count)
        |> Enum.with_index()
        |> Enum.map(fn {clause, index} ->
          expand_with_clause(
            step,
            clause,
            index,
            alias_map,
            node.module,
            lookup,
            index == reached_count - 1
          )
        end)

      clause_steps ++ with_result_steps(step, do_block, else_block)
    else
      _ -> FlowExpander.maybe_assert_result_step(step)
    end
  end

  defp expand_with_clause(
         step,
         {:<-, _, [_pattern, expr]},
         index,
         alias_map,
         current_module,
         lookup,
         last_reached?
       ) do
    expand_with_expr(step, expr, index, alias_map, current_module, lookup, last_reached?)
  end

  defp expand_with_clause(step, expr, index, alias_map, current_module, lookup, last_reached?) do
    expand_with_expr(step, expr, index, alias_map, current_module, lookup, last_reached?)
  end

  defp expand_with_expr(step, expr, _index, alias_map, current_module, lookup, last_reached?) do
    %{label: label, node_id: node_id, focus_node_id: focus_node_id, kind: kind, details: details} =
      summarize_with_expr(expr, alias_map, current_module, lookup)

    FlowSummary.expanded_step(step, %{
      type: :reaction,
      kind: kind,
      status:
        if(last_reached? and step.status in [:failed, :short_circuit],
          do: :short_circuit,
          else: :passed
        ),
      label: label,
      node_id: node_id || step.node_id,
      focus_node_id: focus_node_id || node_id || step.focus_node_id || step.node_id,
      details: details,
      source_snippet: Utils.ast_to_text(expr)
    })
  end

  defp with_result_steps(step, do_block, else_block) do
    cond do
      step.status in [:passed, :matched] ->
        [
          FlowSummary.expanded_step(step, %{
            type: :reaction,
            kind: :assert_result,
            provenance: :branch,
            status: step.status,
            label: "Return success result",
            details: Utils.ast_to_text(Utils.last_expression(do_block)),
            source_snippet: Utils.ast_to_text(Utils.last_expression(do_block))
          })
        ]

      step.status in [:failed, :short_circuit] ->
        [
          FlowSummary.expanded_step(step, %{
            type: :reaction,
            kind: :assert_result,
            provenance: :branch,
            status: :failed,
            label: "Return failure result",
            details: Utils.ast_to_text(Utils.last_expression(else_block)),
            source_snippet: Utils.ast_to_text(Utils.last_expression(else_block))
          })
        ]

      true ->
        FlowExpander.maybe_assert_result_step(step)
    end
  end

  defp with_reached_count(step, clauses) do
    if step.status in [:passed, :matched], do: Enum.count(clauses), else: 1
  end

  defp summarize_with_expr(expr, alias_map, current_module, lookup) do
    case expr do
      {fun, _, args} when is_atom(fun) and is_list(args) ->
        summarize_local_with_expr(fun, current_module, lookup, expr)

      {{:., _, [module_ast, fun]}, _, _args} ->
        module_name = ModuleIndex.resolve_module_name(module_ast, alias_map)
        fun_name = to_string(fun)

        cond do
          fun_name == "verify_signature" ->
            named_step("Verify provider signature", :trigger_receive, module_name, lookup, expr)

          fun_name == "parse_event" ->
            named_step("Parse provider event", :read, module_name, lookup, expr)

          fun_name == "persist_event" ->
            target = focus_for_helper(module_name, :persist_event, lookup)
            named_step("Persist event", :write, module_name, lookup, expr, target)

          fun_name == "dispatch_async_job" ->
            target = focus_for_helper(module_name, :dispatch_async_job, lookup)
            named_step("Enqueue async job", :job_enqueue, module_name, lookup, expr, target)

          true ->
            named_step("Execute #{fun_name}", :action_execute, module_name, lookup, expr)
        end

      _ ->
        %{
          label: Utils.ast_to_text(expr),
          node_id: nil,
          focus_node_id: nil,
          kind: :action_execute,
          details: nil
        }
    end
  end

  defp summarize_local_with_expr(fun, current_module, lookup, expr) do
    fun_name = to_string(fun)

    cond do
      fun_name == "verify_signature" ->
        %{
          label: "Verify provider signature",
          node_id: nil,
          focus_node_id: nil,
          kind: :trigger_receive,
          details: Utils.ast_to_text(expr)
        }

      fun_name == "parse_event" ->
        %{
          label: "Parse provider event",
          node_id: nil,
          focus_node_id: nil,
          kind: :read,
          details: Utils.ast_to_text(expr)
        }

      fun_name == "persist_event" ->
        target = focus_for_helper(current_module, :persist_event, lookup)

        %{
          label: "Persist event",
          node_id: target,
          focus_node_id: target,
          kind: :write,
          details: Utils.ast_to_text(expr)
        }

      fun_name == "dispatch_async_job" ->
        target = focus_for_helper(current_module, :dispatch_async_job, lookup)

        %{
          label: "Enqueue async job",
          node_id: target,
          focus_node_id: target,
          kind: :job_enqueue,
          details: Utils.ast_to_text(expr)
        }

      true ->
        %{
          label: Utils.ast_to_text(expr),
          node_id: nil,
          focus_node_id: nil,
          kind: :action_execute,
          details: nil
        }
    end
  end

  defp named_step(label, kind, module_name, lookup, expr, target \\ nil) do
    node_id = target || ModuleIndex.resolve_node_id(module_name, lookup)

    %{
      label: label,
      node_id: node_id,
      focus_node_id: node_id,
      kind: kind,
      details: Utils.ast_to_text(expr)
    }
  end
end