lib/foundry/chat_trace.ex

defmodule Foundry.ChatTrace do
  @moduledoc """
  Normalizes structured provider events into a UI-friendly Studio copilot trace.
  """

  @max_paths 12
  @path_keys ~w(path paths file file_path filepath filename target source destination cwd repo_root root)
  @command_keys ~w(command cmd argv args input)
  @tool_keys ~w(tool tool_name name recipient_name)
  @global_context_tools ["project_status", "system_graph"]
  @governed_shell_prefixes [
    "mix foundry.project.context",
    "mix foundry.pattern.find",
    "mix foundry.exdoc",
    "rg ",
    "sed ",
    "cat ",
    "find "
  ]

  def normalize(provider, raw_event) when is_map(raw_event) do
    normalized_provider = normalize_provider(provider, raw_event)
    item = Map.get(raw_event, "item")
    type = Map.get(raw_event, "type", "unknown")
    item_type = extract_item_type(item)
    tool = extract_tool(raw_event, item)
    command = extract_command(raw_event, item)
    paths = extract_paths(raw_event) |> Enum.uniq() |> Enum.take(@max_paths)
    phase = extract_phase(raw_event, type, item_type, tool, command)
    category = classify(type, item_type, tool, command, paths, phase)
    file_access = classify_file_access(type, item_type, tool, command, paths, raw_event)

    %{
      id: System.unique_integer([:positive, :monotonic]),
      provider: normalized_provider,
      type: type,
      item_type: item_type,
      category: category,
      phase: phase,
      tool: tool,
      command: command,
      paths: paths,
      file_access: file_access,
      summary: Map.get(raw_event, "message") || Map.get(raw_event, "summary"),
      duplicate_key: duplicate_key(phase, category, tool, command, paths, type),
      title: build_title(category, phase, type, item_type, tool, command, paths, raw_event),
      detail: build_detail(type, item_type, command, paths, raw_event),
      raw: raw_event
    }
  end

  def normalize(provider, raw_event) do
    normalize(provider, %{"type" => "raw_event", "value" => inspect(raw_event)})
  end

  def summarize_run(events) when is_list(events) do
    filtered_events = Enum.reject(events, &(&1.type in ~w(item.completed thread.started)))
    grouped_events = grouped_timeline(filtered_events)

    tools =
      filtered_events
      |> Enum.map(& &1.tool)
      |> Enum.reject(&is_nil/1)
      |> Enum.uniq()

    files =
      filtered_events
      |> Enum.flat_map(&Map.get(&1, :paths, []))
      |> Enum.uniq()

    read_files =
      filtered_events
      |> Enum.filter(&(Map.get(&1, :file_access) == :read))
      |> Enum.flat_map(&Map.get(&1, :paths, []))
      |> Enum.uniq()

    written_files =
      filtered_events
      |> Enum.filter(&(Map.get(&1, :file_access) == :write))
      |> Enum.flat_map(&Map.get(&1, :paths, []))
      |> Enum.uniq()

    %{
      event_count: length(filtered_events),
      grouped_event_count: Enum.count(grouped_events),
      grouped_events: grouped_events,
      phase_groups: group_by_phase(grouped_events),
      tools: tools,
      files: files,
      read_files: read_files,
      written_files: written_files,
      tool_count: length(tools),
      file_count: length(files),
      phase_counts: Enum.frequencies_by(filtered_events, & &1.phase),
      provenance: provenance(filtered_events)
    }
  end

  def grouped_timeline(events) when is_list(events) do
    events
    |> Enum.reverse()
    |> Enum.reduce([], fn event, acc ->
      case acc do
        [%{duplicate_key: key} = existing | rest] when key != nil and key == event.duplicate_key ->
          merged =
            existing
            |> Map.update(:count, 1, &(&1 + 1))
            |> Map.put(:detail, merged_detail(existing, event))
            |> Map.update(:paths, event.paths, &Enum.uniq(&1 ++ event.paths))

          [merged | rest]

        _ ->
          [Map.put(event, :count, 1) | acc]
      end
    end)
    |> Enum.reverse()
  end

  def phase_label(:context), do: "Context"
  def phase_label(:retrieval), do: "Retrieval"
  def phase_label(:proposal), do: "Proposal"
  def phase_label(:verification), do: "Verification"
  def phase_label(:shell_retrieval), do: "Shell Retrieval"
  def phase_label(:shell_fallback), do: "Shell Fallback"
  def phase_label(:reasoning), do: "Reasoning"
  def phase_label(:session), do: "Session"
  def phase_label(:final), do: "Final"
  def phase_label(_phase), do: "Activity"

  def pretty_raw(%{raw: raw}), do: pretty_raw(raw)

  def pretty_raw(raw) when is_map(raw) do
    Jason.encode!(raw, pretty: true)
  rescue
    _ -> inspect(raw, pretty: true, limit: :infinity)
  end

  def pretty_raw(raw), do: inspect(raw, pretty: true, limit: :infinity)

  defp normalize_provider(provider, raw_event) do
    case Map.get(raw_event, "provider") do
      "foundry" -> :foundry
      nil -> provider
      other when is_binary(other) -> String.to_atom(other)
      _ -> provider
    end
  end

  defp extract_phase(raw_event, type, item_type, tool, command) do
    case Map.get(raw_event, "phase") do
      phase when is_binary(phase) -> phase_to_atom(phase)
      phase when is_atom(phase) and not is_nil(phase) -> phase
      _ -> infer_phase(raw_event, type, item_type, tool, command)
    end
  end

  defp infer_phase(raw_event, type, item_type, tool, command) do
    provider = Map.get(raw_event, "provider")

    cond do
      provider == "foundry" and String.contains?(type, "context") -> :context
      provider == "foundry" and String.contains?(type, "proposal") -> :proposal
      provider == "foundry" and String.contains?(type, "session") -> :session
      provider == "foundry" -> :retrieval
      item_type in ["custom_tool_call", "function_call", "tool_call"] -> :retrieval
      present?(tool) -> :retrieval
      command && String.starts_with?(command, "mix test") -> :verification
      command && shell_retrieval_command?(raw_event, command) -> :shell_retrieval
      command -> :shell_fallback
      String.contains?(type, "reason") -> :reasoning
      String.contains?(type, "completed") -> :final
      true -> :retrieval
    end
  end

  defp phase_to_atom("context"), do: :context
  defp phase_to_atom("retrieval"), do: :retrieval
  defp phase_to_atom("proposal"), do: :proposal
  defp phase_to_atom("verification"), do: :verification
  defp phase_to_atom("shell_retrieval"), do: :shell_retrieval
  defp phase_to_atom("shell_fallback"), do: :shell_fallback
  defp phase_to_atom("reasoning"), do: :reasoning
  defp phase_to_atom("session"), do: :session
  defp phase_to_atom("final"), do: :final
  defp phase_to_atom(_), do: :retrieval

  defp extract_item_type(%{"type" => item_type}) when is_binary(item_type), do: item_type
  defp extract_item_type(_item), do: nil

  defp extract_tool(raw_event, item) do
    find_first_string(item, @tool_keys) || find_first_string(raw_event, @tool_keys)
  end

  defp extract_command(raw_event, item) do
    extract_command_value(find_first(item, @command_keys)) ||
      extract_command_value(find_first(raw_event, @command_keys))
  end

  defp extract_command_value(value) when is_binary(value), do: String.trim(value)

  defp extract_command_value(value) when is_list(value) do
    value
    |> Enum.map(&to_string/1)
    |> Enum.join(" ")
    |> String.trim()
  end

  defp extract_command_value(_value), do: nil

  defp extract_paths(value) when is_map(value) do
    Enum.flat_map(value, fn {key, nested} ->
      if key in @path_keys do
        normalize_paths(nested)
      else
        extract_paths(nested)
      end
    end)
  end

  defp extract_paths(value) when is_list(value), do: Enum.flat_map(value, &extract_paths/1)
  defp extract_paths(_value), do: []

  defp normalize_paths(value) when is_binary(value), do: [value]
  defp normalize_paths(value) when is_list(value), do: Enum.flat_map(value, &normalize_paths/1)
  defp normalize_paths(value) when is_map(value), do: extract_paths(value)
  defp normalize_paths(_value), do: []

  defp classify(type, item_type, tool, command, paths, phase) do
    cond do
      String.contains?(type, "error") -> :error
      phase == :proposal -> :proposal
      phase == :context -> :context
      phase == :session -> :session
      item_type in ["custom_tool_call", "function_call", "tool_call"] -> :tool
      is_binary(tool) and tool != "" -> :tool
      is_binary(command) and command != "" -> :command
      paths != [] -> :file
      String.contains?(type, "reason") -> :reasoning
      String.contains?(type, "agent_message") -> :message
      String.contains?(type, "completed") -> :result
      true -> :other
    end
  end

  defp classify_file_access(_type, _item_type, _tool, _command, [], _raw_event), do: nil

  defp classify_file_access(type, item_type, tool, command, _paths, raw_event) do
    lowered_type = String.downcase(type || "")
    lowered_tool = String.downcase(tool || "")
    lowered_command = String.downcase(command || "")
    lowered_message = String.downcase(Map.get(raw_event, "message", ""))

    cond do
      String.contains?(lowered_type, ["write", "edit", "patch", "create", "save"]) ->
        :write

      String.contains?(lowered_tool, ["apply_patch", "write", "edit", "create"]) ->
        :write

      String.contains?(lowered_command, ["apply_patch", "mv ", "cp ", "touch ", "mkdir ", "tee "]) ->
        :write

      String.contains?(lowered_command, [">", ">>"]) ->
        :write

      String.contains?(lowered_message, ["wrote", "edited", "updated", "created"]) ->
        :write

      item_type in ["custom_tool_call", "function_call", "tool_call"] and present?(command) ->
        :read

      present?(command) ->
        :read

      true ->
        :read
    end
  end

  defp build_title(:context, _phase, _type, _item_type, _tool, _command, _paths, raw_event) do
    Map.get(raw_event, "message", "Loaded Foundry context")
  end

  defp build_title(:session, _phase, _type, _item_type, _tool, _command, _paths, raw_event) do
    Map.get(raw_event, "message", "Updated session memory")
  end

  defp build_title(:proposal, _phase, _type, _item_type, _tool, _command, _paths, raw_event) do
    Map.get(raw_event, "message", "Proposal flow event")
  end

  defp build_title(:tool, _phase, _type, item_type, tool, _command, _paths, _raw_event) do
    cond do
      present?(tool) -> "Used tool #{tool}"
      present?(item_type) -> "Completed #{item_type}"
      true -> "Tool activity"
    end
  end

  defp build_title(:command, phase, _type, _item_type, _tool, command, _paths, _raw_event) do
    prefix =
      case phase do
        :verification -> "Verified with"
        :shell_retrieval -> "Inspected via shell"
        :shell_fallback -> "Ran fallback command"
        _ -> "Ran command"
      end

    "#{prefix} #{truncate(command, 80)}"
  end

  defp build_title(:file, _phase, _type, _item_type, _tool, _command, [path], _raw_event) do
    "Referenced file #{Path.basename(path)}"
  end

  defp build_title(:file, _phase, _type, _item_type, _tool, _command, paths, _raw_event) do
    "Referenced #{length(paths)} files"
  end

  defp build_title(:reasoning, _phase, type, _item_type, _tool, _command, _paths, _raw_event) do
    "Reasoning event #{type}"
  end

  defp build_title(:message, _phase, _type, _item_type, _tool, _command, _paths, _raw_event) do
    "Assistant message update"
  end

  defp build_title(:result, _phase, type, _item_type, _tool, _command, _paths, _raw_event) do
    "Completed event #{type}"
  end

  defp build_title(:error, _phase, type, _item_type, _tool, _command, _paths, _raw_event) do
    "Provider error #{type}"
  end

  defp build_title(:other, phase, type, item_type, _tool, _command, _paths, _raw_event) do
    label =
      [Atom.to_string(phase), type, item_type]
      |> Enum.reject(&is_nil/1)
      |> Enum.join(" / ")

    if label == "", do: "Provider event", else: label
  end

  defp build_detail(type, item_type, command, paths, raw_event) do
    parts =
      [
        if(present?(item_type), do: "item: #{item_type}"),
        if(present?(command), do: "command: #{truncate(command, 140)}"),
        if(present?(Map.get(raw_event, "message")), do: Map.get(raw_event, "message")),
        path_detail(paths)
      ]
      |> Enum.reject(&is_nil/1)

    case parts do
      [] -> "event type: #{type}"
      _ -> Enum.join(parts, " • ")
    end
  end

  defp duplicate_key(phase, category, tool, command, paths, _type) do
    {
      phase,
      category,
      tool,
      normalize_duplicate_command(command),
      Enum.sort(paths)
    }
  end

  defp normalize_duplicate_command(nil), do: nil

  defp normalize_duplicate_command(command) do
    String.replace(command, ~r/\s+/, " ")
  end

  defp merged_detail(existing, _event) do
    base_detail = Map.get(existing, :detail, "Repeated activity")

    case existing.count do
      count when count >= 1 -> "#{base_detail} • repeated #{count + 1}x"
      _ -> base_detail
    end
  end

  defp group_by_phase(grouped_events) do
    grouped_events
    |> Enum.group_by(& &1.phase)
    |> Enum.map(fn {phase, phase_events} ->
      %{
        phase: phase,
        label: phase_label(phase),
        events: phase_events,
        count: length(phase_events)
      }
    end)
    |> Enum.sort_by(&phase_sort_order(&1.phase))
  end

  defp provenance(events) do
    %{
      cached_context_used: Enum.any?(events, &(&1.phase == :context)),
      context_refreshed:
        Enum.any?(events, fn
          %{raw: %{"cache" => "miss"}} -> true
          _ -> false
        end),
      foundry_tools_used:
        Enum.any?(events, fn
          %{provider: :foundry, category: category}
          when category in [:tool, :context, :proposal] ->
            true

          _ ->
            false
        end),
      shell_retrieval_used: Enum.any?(events, &(&1.phase == :shell_retrieval)),
      true_fallback_used: Enum.any?(events, &(&1.phase == :shell_fallback)),
      redundant_global_context_fetches: redundant_global_context_fetches(events),
      shell_fallback_used: Enum.any?(events, &(&1.phase == :shell_fallback)),
      proposal_flow_used: Enum.any?(events, &(&1.phase == :proposal))
    }
  end

  defp phase_sort_order(:context), do: 0
  defp phase_sort_order(:session), do: 1
  defp phase_sort_order(:retrieval), do: 2
  defp phase_sort_order(:shell_retrieval), do: 3
  defp phase_sort_order(:reasoning), do: 4
  defp phase_sort_order(:proposal), do: 5
  defp phase_sort_order(:shell_fallback), do: 6
  defp phase_sort_order(:verification), do: 7
  defp phase_sort_order(:final), do: 8
  defp phase_sort_order(_), do: 9

  defp path_detail([]), do: nil
  defp path_detail([path]), do: "path: #{path}"
  defp path_detail(paths), do: "paths: #{Enum.take(paths, 3) |> Enum.join(", ")}"

  defp find_first(map, keys) when is_map(map), do: Enum.find_value(keys, &Map.get(map, &1))
  defp find_first(_map, _keys), do: nil

  defp find_first_string(map, keys) do
    case find_first(map, keys) do
      value when is_binary(value) and value != "" -> value
      _ -> nil
    end
  end

  defp shell_retrieval_command?(raw_event, command) do
    normalized = normalize_duplicate_command(command)

    not explicit_fallback?(raw_event) and
      Enum.any?(@governed_shell_prefixes, &String.contains?(normalized, &1))
  end

  defp explicit_fallback?(raw_event) do
    text =
      [Map.get(raw_event, "message"), Map.get(raw_event, "summary"), Map.get(raw_event, "type")]
      |> Enum.filter(&is_binary/1)
      |> Enum.join(" ")
      |> String.downcase()

    String.contains?(text, "fallback")
  end

  defp redundant_global_context_fetches(events) do
    events
    |> Enum.count(fn
      %{tool: tool, provider: provider}
      when tool in @global_context_tools and provider != :foundry ->
        true

      %{tool: tool} when tool in @global_context_tools ->
        true

      _ ->
        false
    end)
  end

  defp truncate(nil, _limit), do: nil

  defp truncate(value, limit) do
    if String.length(value) > limit do
      String.slice(value, 0, limit - 1) <> "..."
    else
      value
    end
  end

  defp present?(value) when is_binary(value), do: String.trim(value) != ""
  defp present?(value), do: not is_nil(value)
end