lib/mobus/stepwise/artifacts.ex

defmodule Mobus.Stepwise.Artifacts do
  @moduledoc """
  Canonical helpers for workflow-owned artifacts in the stepwise runtime.

  Artifacts are durable, workflow-scoped data that should survive refreshes,
  retries, and external callbacks (e.g. email confirmation links).
  """

  @type artifact_entry :: %{
          required(String.t()) => term()
        }

  @type artifact_map :: %{optional(String.t()) => artifact_entry()}

  @doc """
  Normalizes an artifact map into canonical format.

  Each artifact entry is coerced to include `"kind"`, `"version"`, `"inserted_at"`,
  and `"data"` keys with string keys throughout. Keys are stringified, and entries
  without explicit metadata default to kind `"opaque"` and version `1`.

  Returns an empty map for `nil` or non-map inputs.

  ## Parameters

    * `artifacts` — a map of artifact entries, or `nil`

  ## Returns

    * A normalized `artifact_map()` with string keys and canonical entry structure.

  ## Examples

      Artifacts.normalize(%{report: %{kind: "pdf", data: "..."}})
      #=> %{"report" => %{"kind" => "pdf", "version" => 1, "inserted_at" => "...", "data" => "..."}}

      Artifacts.normalize(nil)
      #=> %{}

  """
  @spec normalize(map() | nil) :: artifact_map()
  def normalize(nil), do: %{}

  def normalize(artifacts) when is_map(artifacts) do
    now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()

    artifacts
    |> Enum.reduce(%{}, fn {key, value}, acc ->
      key = to_string(key)

      if is_binary(key) do
        Map.put(acc, key, normalize_entry(value, now))
      else
        acc
      end
    end)
  end

  def normalize(_), do: %{}

  @doc """
  Merges two artifact maps, with incoming entries overriding existing ones.

  Both maps are normalized before merging. `nil` inputs are treated as empty maps.

  ## Parameters

    * `existing` — the current artifact map (or `nil`)
    * `incoming` — new artifacts to merge in (or `nil`)

  ## Returns

    * A merged and normalized `artifact_map()`.

  ## Examples

      Artifacts.merge(%{"a" => %{"data" => 1}}, %{"b" => %{"data" => 2}})
      #=> %{"a" => %{...}, "b" => %{...}}

  """
  @spec merge(map() | nil, map() | nil) :: artifact_map()
  def merge(existing, incoming) do
    existing = normalize(existing)
    incoming = normalize(incoming)
    Map.merge(existing, incoming)
  end

  defp normalize_entry(%{} = value, now) do
    kind =
      Map.get(value, "kind") ||
        Map.get(value, :kind) ||
        "opaque"

    version =
      Map.get(value, "version") ||
        Map.get(value, :version) ||
        1

    inserted_at =
      Map.get(value, "inserted_at") ||
        Map.get(value, :inserted_at) ||
        now

    data =
      Map.get(value, "data") ||
        Map.get(value, :data) ||
        value

    entry = %{
      "kind" => to_string(kind),
      "version" => version,
      "inserted_at" => inserted_at,
      "data" => data
    }

    meta = Map.get(value, "meta") || Map.get(value, :meta)
    if is_nil(meta), do: entry, else: Map.put(entry, "meta", meta)
  end

  defp normalize_entry(value, now) do
    %{
      "kind" => "opaque",
      "version" => 1,
      "inserted_at" => now,
      "data" => value
    }
  end
end