lib/foundry/context/scenarios/call_classifier.ex

defmodule Foundry.Context.Scenarios.CallClassifier do
  @moduledoc false

  alias ExTracer.FlowSummary
  alias Foundry.Context.Scenarios.ModuleIndex
  alias Foundry.Context.Scenarios.Utils

  @ash_funs ~w(get read read_one create update destroy)a
  @ash_changeset_funs ~w(for_create for_update for_read for_destroy)a

  def classify_ast_call(module_ast, fun, args, alias_map, lookup, opts) do
    module_name = ModuleIndex.resolve_module_name(module_ast, alias_map)
    fun_name = to_string(fun)

    cond do
      module_name == "Reactor" and fun == :run ->
        infer_reactor_run_step(args, lookup, opts)

      module_name == "Ash" and fun in @ash_funs ->
        infer_ash_step(fun_name, args, alias_map, lookup, opts)

      module_name == "Ash.Changeset" and fun in @ash_changeset_funs ->
        infer_changeset_step(fun_name, args, alias_map, lookup, opts)

      is_nil(module_name) ->
        nil

      resolved_node = ModuleIndex.resolve_node_id(module_name, lookup) ->
        infer_module_step(module_name, resolved_node, fun_name, lookup, opts)

      true ->
        nil
    end
  end

  def runtime_trace_attrs(module_ast, fun, args, call_ast, caller) do
    module_name = resolve_runtime_module_name(module_ast, caller)
    fun_name = to_string(fun)

    cond do
      module_name == "Ash" and fun in @ash_funs ->
        infer_ash_trace(args, fun_name, call_ast, caller)

      module_name == "Ash.Changeset" and fun in @ash_changeset_funs ->
        infer_changeset_trace(args, fun_name, call_ast, caller)

      module_name == "Reactor" and fun == :run ->
        infer_reactor_trace(args, call_ast, caller)

      true ->
        nil
    end
  end

  def infer_assertion_context(pattern_ast) do
    result = Utils.ast_to_text(pattern_ast)

    %{
      result: result,
      status: infer_status_from_pattern(pattern_ast)
    }
  end

  def extract_ash_action(args) when is_list(args) do
    Enum.reduce_while(args, {nil, []}, fn arg, {action, seen} ->
      cond do
        action_name = ModuleIndex.extract_action_name(arg) ->
          {:halt, {action_name, seen ++ [arg]}}

        action_name = extract_keyword_action(arg) ->
          {:halt, {action_name, seen ++ [arg]}}

        true ->
          {:cont, {action, seen ++ [arg]}}
      end
    end)
  end

  def extract_ash_action(_args), do: {nil, []}

  def ash_kind(fun_name) when fun_name in ["get", "read", "read_one"], do: :read
  def ash_kind(_fun_name), do: :action_execute

  def ash_step_label(fun_name, node_id, action) do
    short = List.last(String.split(node_id, "."))

    case {fun_name, action} do
      {"get", _} -> "Load #{short}"
      {"read", _} -> "Read #{short}"
      {"read_one", _} -> "Read #{short}"
      {"create", nil} -> "Create #{short}"
      {"create", act} -> "Create #{short} via #{act}"
      {"update", nil} -> "Update #{short}"
      {"update", act} -> "Update #{short} via #{act}"
      {"destroy", nil} -> "Destroy #{short}"
      {"destroy", act} -> "Destroy #{short} via #{act}"
      _ -> "#{String.capitalize(fun_name)} #{short}"
    end
  end

  def changeset_step_label(fun_name, node_id, action) do
    short = List.last(String.split(node_id, "."))

    case {fun_name, action} do
      {"for_create", nil} -> "Prepare create #{short}"
      {"for_create", act} -> "Prepare #{short}.#{act}"
      {"for_update", nil} -> "Prepare update #{short}"
      {"for_update", act} -> "Prepare #{short}.#{act}"
      {"for_read", nil} -> "Prepare read #{short}"
      {"for_read", act} -> "Prepare #{short}.#{act}"
      {"for_destroy", nil} -> "Prepare destroy #{short}"
      {"for_destroy", act} -> "Prepare #{short}.#{act}"
      _ -> "Prepare #{short}"
    end
  end

  def infer_module_label(:assertion, short_name, _fun_name), do: "Evaluate #{short_name}"
  def infer_module_label(:job, short_name, _fun_name), do: "Run #{short_name}"
  def infer_module_label(:entry, short_name, "handle_webhook"), do: "Handle #{short_name} webhook"

  def infer_module_label(:entry, short_name, fun_name),
    do: "#{String.capitalize(fun_name)} #{short_name}"

  def infer_module_label(_type, short_name, fun_name),
    do: "#{String.capitalize(fun_name)} #{short_name}"

  defp infer_ash_step(fun_name, [resource_ast | rest], alias_map, lookup, opts) do
    {action, arg_payload} = extract_ash_action(rest)

    with {node_id, resolved_action} when is_binary(node_id) <-
           resolve_action_target(resource_ast, action, alias_map, lookup) do
      action = resolved_action || action

      focus_node_id =
        if action, do: ModuleIndex.build_action_focus(node_id, action, lookup), else: node_id

      FlowSummary.build_step(%{
        type: if(fun_name in ["get", "read", "read_one"], do: :observation, else: :entry),
        kind: ash_kind(fun_name),
        label: ash_step_label(fun_name, node_id, action),
        node_id: node_id,
        focus_node_id: focus_node_id,
        focus_targets: [],
        emits: [],
        reacts_to: nil,
        action: action,
        actor: nil,
        module_function: "Ash.#{fun_name}",
        source_snippet: Utils.short_call_snippet("Ash", fun_name, arg_payload),
        details: nil,
        line: opts.line,
        test_name: opts.test_name,
        test_kind: opts.test_kind,
        assertion_context: opts.assertion_context
      })
    end
  end

  defp infer_ash_step(_, _, _, _, _), do: nil

  defp infer_reactor_run_step([target_module_ast | _rest] = args, lookup, opts) do
    target_module_name = ModuleIndex.resolve_module_name(target_module_ast, opts.alias_map || %{})
    target_node_id = ModuleIndex.resolve_node_id(target_module_name, lookup)

    if target_node_id do
      infer_module_step(
        target_module_name,
        target_node_id,
        "run",
        lookup,
        %{
          opts
          | assertion_context:
              Map.put(
                opts.assertion_context || %{},
                :source_snippet,
                Utils.short_call_snippet("Reactor", "run", args)
              )
        }
      )
    end
  end

  defp infer_reactor_run_step(_, _, _), do: nil

  defp infer_changeset_step(fun_name, [resource_ast | rest], alias_map, lookup, opts) do
    action =
      case rest do
        [action_ast | _] -> ModuleIndex.extract_action_name(action_ast)
        _ -> nil
      end

    with {node_id, resolved_action} when is_binary(node_id) <-
           resolve_action_target(resource_ast, action, alias_map, lookup) do
      action = resolved_action || action

      focus_node_id =
        if action, do: ModuleIndex.build_action_focus(node_id, action, lookup), else: node_id

      FlowSummary.build_step(%{
        type: :entry,
        kind: :action_prepare,
        label: changeset_step_label(fun_name, node_id, action),
        node_id: node_id,
        focus_node_id: focus_node_id,
        focus_targets: [],
        emits: [],
        reacts_to: nil,
        action: action,
        actor: nil,
        module_function: "Ash.Changeset.#{fun_name}",
        source_snippet: Utils.short_call_snippet("Ash.Changeset", fun_name, rest),
        details: "Only action preparation executed",
        line: opts.line,
        test_name: opts.test_name,
        test_kind: opts.test_kind,
        assertion_context: opts.assertion_context
      })
    end
  end

  defp infer_changeset_step(_, _, _, _, _), do: nil

  defp infer_module_step(module_name, node_id, fun_name, lookup, opts) do
    node = Map.get(lookup.by_id, node_id)
    short_name = List.last(String.split(node_id, "."))

    {type, kind, action} =
      cond do
        match?(%{type: "rule"}, node) and fun_name == "evaluate" ->
          {:assertion, :rule_check, "evaluate"}

        match?(%{type: "job"}, node) and fun_name == "perform" ->
          {:job, :job_execute, fun_name}

        fun_name == "handle_webhook" ->
          {:entry, :trigger_receive, fun_name}

        fun_name == "run" and match?(%{type: type} when type in ["transfer", "reactor"], node) ->
          {:entry, :action_execute, fun_name}

        true ->
          {:entry, :action_execute, fun_name}
      end

    FlowSummary.build_step(%{
      type: type,
      kind: kind,
      label: infer_module_label(type, short_name, fun_name),
      node_id: node_id,
      focus_node_id: ModuleIndex.infer_module_focus(node_id, node, fun_name, lookup),
      focus_targets: [],
      emits: [],
      reacts_to: nil,
      action: action,
      actor: nil,
      module_function: "#{module_name}.#{fun_name}",
      source_snippet:
        Map.get(opts.assertion_context || %{}, :source_snippet) ||
          Utils.short_call_snippet(module_name, fun_name, []),
      details: nil,
      line: opts.line,
      test_name: opts.test_name,
      test_kind: opts.test_kind,
      assertion_context: opts.assertion_context
    })
  end

  defp infer_ash_trace([resource_ast | rest], fun_name, call_ast, caller) do
    with resource_name when is_binary(resource_name) <-
           resolve_runtime_module_name(resource_ast, caller) do
      action =
        case fun_name do
          "create" -> extract_action_from_keyword(rest)
          "update" -> extract_action_from_args(rest)
          "destroy" -> extract_action_from_args(rest)
          _ -> nil
        end

      {type, kind} =
        case fun_name do
          "get" -> {:observation, :read}
          "read" -> {:observation, :read}
          "read_one" -> {:observation, :read}
          "create" -> {:entry, :action_execute}
          "update" -> {:entry, :action_execute}
          "destroy" -> {:entry, :action_execute}
        end

      build_trace_attrs(resource_name, %{
        type: type,
        kind: kind,
        action: action,
        module_function: "Ash.#{fun_name}",
        source_snippet: Macro.to_string(call_ast),
        focus_node_id: action_focus(resource_name, action)
      })
    else
      _ -> nil
    end
  end

  defp infer_ash_trace(_, _, _, _), do: nil

  defp infer_changeset_trace([resource_ast | rest], fun_name, call_ast, caller) do
    with resource_name when is_binary(resource_name) <-
           resolve_runtime_module_name(resource_ast, caller) do
      action =
        case rest do
          [action_ast | _] -> literal_action_name(action_ast)
          _ -> nil
        end

      build_trace_attrs(resource_name, %{
        type: :entry,
        kind: :action_prepare,
        action: action,
        module_function: "Ash.Changeset.#{fun_name}",
        source_snippet: Macro.to_string(call_ast),
        focus_node_id: action_focus(resource_name, action),
        details: "Only action preparation executed"
      })
    else
      _ -> nil
    end
  end

  defp infer_changeset_trace(_, _, _, _), do: nil

  defp infer_reactor_trace([module_ast | _rest], call_ast, caller) do
    with module_name when is_binary(module_name) <-
           resolve_runtime_module_name(module_ast, caller) do
      build_trace_attrs(module_name, %{
        type: :entry,
        kind: :action_execute,
        module_function: "Reactor.run",
        source_snippet: Macro.to_string(call_ast)
      })
    else
      _ -> nil
    end
  end

  defp infer_reactor_trace(_, _, _), do: nil

  defp resolve_runtime_module_name(ast, caller) do
    expanded = Macro.expand(ast, caller)

    cond do
      is_atom(expanded) ->
        expanded
        |> Atom.to_string()
        |> String.trim_leading("Elixir.")

      match?({:__aliases__, _, _}, ast) ->
        ast
        |> Macro.to_string()
        |> String.trim_leading("Elixir.")

      true ->
        nil
    end
  end

  defp extract_keyword_action(keyword_ast) when is_list(keyword_ast) do
    Enum.find_value(keyword_ast, fn
      {:action, value} -> ModuleIndex.extract_action_name(value)
      _ -> nil
    end)
  end

  defp extract_keyword_action(_keyword_ast), do: nil

  defp extract_action_from_keyword(rest) do
    rest
    |> Enum.find_value(fn
      keyword when is_list(keyword) -> Keyword.get(keyword, :action)
      _ -> nil
    end)
    |> literal_action_name()
  end

  defp extract_action_from_args([action_ast | _rest]), do: literal_action_name(action_ast)
  defp extract_action_from_args(_args), do: nil

  defp literal_action_name(value) when is_atom(value), do: Atom.to_string(value)
  defp literal_action_name(value) when is_binary(value), do: value
  defp literal_action_name({name, _, _}) when is_atom(name), do: Atom.to_string(name)
  defp literal_action_name(_value), do: nil

  defp build_trace_attrs(node_id, attrs) when is_binary(node_id) do
    attrs
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
    |> Map.new()
    |> Map.put(:node_id, node_id)
    |> Map.put(:capture_origin, :automatic)
  end

  defp action_focus(node_id, nil), do: node_id
  defp action_focus(node_id, action), do: "#{node_id}:action:#{action}"

  defp resolve_action_target(resource_ast, action, alias_map, lookup) do
    resource_name = ModuleIndex.resolve_module_name(resource_ast, alias_map)
    node_id = ModuleIndex.resolve_node_id(resource_name, lookup)

    cond do
      is_binary(node_id) ->
        {node_id, action}

      resolved = resolve_pipeline_target(resource_ast, alias_map, lookup) ->
        {elem(resolved, 0), action || elem(resolved, 1)}

      is_binary(action) ->
        case find_unique_action_node(action, lookup) do
          nil -> nil
          inferred_node_id -> {inferred_node_id, action}
        end

      true ->
        nil
    end
  end

  defp resolve_pipeline_target({:|>, _, [lhs, rhs]}, alias_map, lookup) do
    resolve_pipeline_target(rhs, lhs, alias_map, lookup) ||
      resolve_pipeline_target(lhs, alias_map, lookup)
  end

  defp resolve_pipeline_target(_, _, _), do: nil

  defp resolve_pipeline_target(
         {{:., _, [{:__aliases__, _, [:Ash, :Changeset]}, action]}, _,
          [resource_ast, action_ast | _]},
         _lhs,
         alias_map,
         lookup
       )
       when action in [:for_create, :for_update, :for_destroy] do
    resolve_pipeline_resource(resource_ast, action_ast, alias_map, lookup)
  end

  defp resolve_pipeline_target(
         {{:., _, [{:__aliases__, _, [:Ash, :Query]}, :for_read]}, _,
          [resource_ast, action_ast | _]},
         _lhs,
         alias_map,
         lookup
       ) do
    resolve_pipeline_resource(resource_ast, action_ast, alias_map, lookup)
  end

  defp resolve_pipeline_target(
         {{:., _, [{:__aliases__, _, [:Ash, :Changeset]}, action]}, _, [action_ast | _]},
         lhs,
         alias_map,
         lookup
       )
       when action in [:for_create, :for_update, :for_destroy] do
    resolve_pipeline_resource(lhs, action_ast, alias_map, lookup)
  end

  defp resolve_pipeline_target(
         {{:., _, [{:__aliases__, _, [:Ash, :Query]}, :for_read]}, _, [action_ast | _]},
         lhs,
         alias_map,
         lookup
       ) do
    resolve_pipeline_resource(lhs, action_ast, alias_map, lookup)
  end

  defp resolve_pipeline_target(_, _, _, _), do: nil

  defp resolve_pipeline_resource(resource_ast, action_ast, alias_map, lookup) do
    action = ModuleIndex.extract_action_name(action_ast)
    resource_name = ModuleIndex.resolve_module_name(resource_ast, alias_map)
    node_id = ModuleIndex.resolve_node_id(resource_name, lookup)

    cond do
      is_binary(node_id) ->
        {node_id, action}

      is_binary(action) ->
        case find_unique_action_node(action, lookup) do
          nil -> nil
          inferred_node_id -> {inferred_node_id, action}
        end

      true ->
        nil
    end
  end

  defp find_unique_action_node(action_name, lookup) do
    normalized_action = Utils.normalize_name(action_name)

    lookup.by_id
    |> Enum.flat_map(fn {node_id, node} ->
      actions = Map.get(node, :actions, [])

      if Enum.any?(actions, &(Utils.normalize_name(Map.get(&1, :name)) == normalized_action)) do
        [node_id]
      else
        []
      end
    end)
    |> Enum.uniq()
    |> case do
      [node_id] -> node_id
      _ -> nil
    end
  end

  defp infer_status_from_pattern({:ok, _}), do: :passed
  defp infer_status_from_pattern(:ok), do: :passed
  defp infer_status_from_pattern(true), do: :passed
  defp infer_status_from_pattern({:error, _, _}), do: :failed
  defp infer_status_from_pattern({:error, _}), do: :failed
  defp infer_status_from_pattern(_), do: :matched
end