Skip to main content

lib/jido_messaging/topology_validator.ex

defmodule Jido.Messaging.TopologyValidator do
  @moduledoc """
  Validation helpers for bridge-room topology payloads.
  """

  alias Jido.Messaging.{BridgeRoomSpec, ConfigStore}

  @type validation_error :: %{code: atom(), detail: map()}

  @spec validate_bridge_room_spec(module(), BridgeRoomSpec.t()) ::
          :ok | {:error, {:invalid_topology, [validation_error()]}}
  @doc """
  Validates bridge-room topology references before applying them.
  """
  def validate_bridge_room_spec(instance_module, %BridgeRoomSpec{} = spec) when is_atom(instance_module) do
    existing_bridge_ids =
      ConfigStore.list_bridge_configs(instance_module)
      |> Enum.map(& &1.id)
      |> MapSet.new()

    declared_bridge_ids =
      spec.bridge_configs
      |> Enum.map(&map_get(&1, :id))
      |> Enum.reject(&is_nil/1)
      |> Enum.map(&to_string/1)

    all_bridge_ids = MapSet.union(existing_bridge_ids, MapSet.new(declared_bridge_ids))

    errors =
      []
      |> validate_bridge_configs(spec.bridge_configs)
      |> validate_binding_uniqueness(spec.bindings)
      |> validate_binding_bridge_refs(spec.bindings, all_bridge_ids)
      |> validate_routing_policy(spec.routing_policy, all_bridge_ids)

    case errors do
      [] -> :ok
      _ -> {:error, {:invalid_topology, Enum.reverse(errors)}}
    end
  end

  defp validate_bridge_configs(errors, bridge_configs) do
    bridge_ids =
      bridge_configs
      |> Enum.map(&map_get(&1, :id))
      |> Enum.reject(&is_nil/1)
      |> Enum.map(&to_string/1)

    duplicate_bridge_ids =
      bridge_ids
      |> Enum.group_by(& &1)
      |> Enum.filter(fn {_bridge_id, ids} -> length(ids) > 1 end)
      |> Enum.map(&elem(&1, 0))

    errors =
      Enum.reduce(duplicate_bridge_ids, errors, fn bridge_id, acc ->
        [
          %{code: :duplicate_bridge_id, detail: %{bridge_id: bridge_id}}
          | acc
        ]
      end)

    Enum.reduce(bridge_configs, errors, fn config, acc ->
      case map_get(config, :adapter_module) do
        module when is_atom(module) ->
          acc

        other ->
          [
            %{code: :invalid_bridge_adapter_module, detail: %{bridge_id: map_get(config, :id), adapter_module: other}}
            | acc
          ]
      end
    end)
  end

  defp validate_binding_uniqueness(errors, bindings) do
    keys =
      bindings
      |> Enum.map(fn binding ->
        {
          map_get(binding, :channel),
          normalize_id(map_get(binding, :bridge_id)),
          normalize_id(map_get(binding, :external_room_id))
        }
      end)

    duplicates =
      keys
      |> Enum.group_by(& &1)
      |> Enum.filter(fn {_key, values} -> length(values) > 1 end)
      |> Enum.map(&elem(&1, 0))

    Enum.reduce(duplicates, errors, fn {channel, bridge_id, external_room_id}, acc ->
      [
        %{
          code: :duplicate_binding,
          detail: %{channel: channel, bridge_id: bridge_id, external_room_id: external_room_id}
        }
        | acc
      ]
    end)
  end

  defp validate_binding_bridge_refs(errors, bindings, known_bridge_ids) do
    Enum.reduce(bindings, errors, fn binding, acc ->
      bridge_id = normalize_id(map_get(binding, :bridge_id))

      if is_binary(bridge_id) and MapSet.member?(known_bridge_ids, bridge_id) do
        acc
      else
        [%{code: :unknown_binding_bridge_id, detail: %{bridge_id: bridge_id, binding: binding}} | acc]
      end
    end)
  end

  defp validate_routing_policy(errors, routing_policy, known_bridge_ids) when is_map(routing_policy) do
    fallback_order = map_get(routing_policy, :fallback_order)

    case fallback_order do
      nil ->
        errors

      order when is_list(order) ->
        Enum.reduce(order, errors, fn bridge_id, acc ->
          normalized = normalize_id(bridge_id)

          if is_binary(normalized) and MapSet.member?(known_bridge_ids, normalized) do
            acc
          else
            [%{code: :unknown_routing_bridge_id, detail: %{bridge_id: normalized}} | acc]
          end
        end)

      other ->
        [%{code: :invalid_routing_fallback_order, detail: %{fallback_order: other}} | errors]
    end
  end

  defp validate_routing_policy(errors, _routing_policy, _known_bridge_ids), do: errors

  defp map_get(map, key) when is_map(map) and is_atom(key),
    do: Map.get(map, key, Map.get(map, Atom.to_string(key)))

  defp normalize_id(nil), do: nil
  defp normalize_id(value) when is_binary(value), do: value
  defp normalize_id(value), do: to_string(value)
end