lib/bb/mcp/event_buffer/serializer.ex

# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.MCP.EventBuffer.Serializer do
  @moduledoc """
  Convert `BB.MCP.EventBuffer` entries into JSON-safe maps for tool replies.

  BB message payloads may contain Nx tensors (Vec3, Quaternion, Transform),
  large binaries (camera images), or other non-JSON-encodable values. We
  render those via `inspect/1` rather than failing the Jason encode.
  """

  alias BB.MCP.EventBuffer

  @max_binary_bytes 64

  @doc """
  Serialise a buffer entry. `now_ns` is the reference monotonic time used to
  compute `age_ms`, so the agent can reason about how recent each event is.
  """
  @spec serialise(EventBuffer.entry(), integer()) :: map()
  def serialise(entry, now_ns) do
    %{
      "robot" => entry.robot,
      "path" => EventBuffer.path_to_string(entry.path),
      "type" => format_module(entry.payload_module),
      "frame_id" => format_atom(entry.message.frame_id),
      "monotonic_ns" => entry.monotonic_ns,
      "age_ms" => div(now_ns - entry.received_ns, 1_000_000),
      "payload" => jsonable(entry.message.payload)
    }
  end

  defp format_module(nil), do: nil
  defp format_module(module) when is_atom(module), do: inspect(module)

  defp format_atom(nil), do: nil
  defp format_atom(atom) when is_atom(atom), do: Atom.to_string(atom)

  defp jsonable(%Nx.Tensor{} = tensor), do: inspect(tensor)

  defp jsonable(%_{} = struct) do
    struct
    |> Map.from_struct()
    |> Map.new(fn {k, v} -> {Atom.to_string(k), jsonable(v)} end)
  end

  defp jsonable(value) when is_map(value) do
    Map.new(value, fn {k, v} -> {jsonable_key(k), jsonable(v)} end)
  end

  defp jsonable(value) when is_list(value), do: Enum.map(value, &jsonable/1)

  defp jsonable(value) when is_tuple(value),
    do: value |> Tuple.to_list() |> Enum.map(&jsonable/1)

  defp jsonable(nil), do: nil
  defp jsonable(value) when is_boolean(value), do: value
  defp jsonable(value) when is_atom(value), do: Atom.to_string(value)
  defp jsonable(value) when is_number(value), do: value
  defp jsonable(value) when is_binary(value), do: render_binary(value)
  defp jsonable(value), do: inspect(value)

  defp jsonable_key(k) when is_atom(k), do: Atom.to_string(k)
  defp jsonable_key(k) when is_binary(k), do: k
  defp jsonable_key(k), do: inspect(k)

  defp render_binary(value) do
    if String.valid?(value) and byte_size(value) <= @max_binary_bytes do
      value
    else
      "<#{byte_size(value)} bytes>"
    end
  end
end