lib/dripdrop/templates/spintax.ex

defmodule DripDrop.Templates.Spintax do
  @moduledoc """
  Deterministic spintax rendering for optional template variation.
  """

  @doc """
  Applies spintax to every string in a rendered payload when enabled for a step.
  """
  @spec apply(map(), map(), map()) :: map()
  def apply(payload, step, execution) do
    if enabled?(step) do
      seed = seed(execution)
      {payload, _rand} = walk(payload, rand_state(seed), context(execution))
      payload
    else
      payload
    end
  end

  @doc """
  Renders one spintax string with a deterministic seed.
  """
  @spec render(binary(), integer(), map()) :: {binary(), term()}
  def render(text, seed, metadata \\ %{}) when is_binary(text) do
    case spin(text, rand_state(seed), metadata) do
      {:ok, rendered, rand} ->
        {rendered, rand}

      {:error, reason} ->
        emit_error(reason, text, metadata)
        {text, rand_state(seed)}
    end
  end

  @doc """
  Derives the deterministic seed from a step execution.
  """
  @spec seed(map()) :: non_neg_integer()
  def seed(%{id: id, attempt_window: attempt_window}) do
    :erlang.phash2({id, attempt_window || 0})
  end

  defp enabled?(%{config: %{"template_variation" => %{"spintax" => true}}}), do: true
  defp enabled?(%{config: %{template_variation: %{spintax: true}}}), do: true
  defp enabled?(_step), do: false

  defp walk(value, rand, metadata) when is_binary(value),
    do: spin_or_original(value, rand, metadata)

  defp walk(value, rand, metadata) when is_map(value) do
    Enum.reduce(value, {%{}, rand}, fn {key, item}, {acc, rand} ->
      {item, rand} = walk(item, rand, metadata)
      {Map.put(acc, key, item), rand}
    end)
  end

  defp walk(value, rand, metadata) when is_list(value) do
    Enum.reduce(value, {[], rand}, fn item, {acc, rand} ->
      {item, rand} = walk(item, rand, metadata)
      {[item | acc], rand}
    end)
    |> then(fn {items, rand} -> {Enum.reverse(items), rand} end)
  end

  defp walk(value, rand, _metadata), do: {value, rand}

  defp spin_or_original(text, rand, metadata) do
    case spin(text, rand, metadata) do
      {:ok, rendered, rand} ->
        {rendered, rand}

      {:error, reason} ->
        emit_error(reason, text, metadata)
        {text, rand}
    end
  end

  defp spin(text, rand, metadata) do
    cond do
      not String.contains?(text, ["{", "}"]) ->
        {:ok, text, rand}

      unbalanced?(text) ->
        {:error, :unbalanced_braces}

      true ->
        reduce_tokens(text, rand, metadata)
    end
  end

  defp reduce_tokens(text, rand, metadata) do
    case innermost_tokens(text) do
      [] ->
        if String.contains?(text, ["{", "}"]) do
          {:error, :unbalanced_braces}
        else
          {:ok, text, rand}
        end

      tokens ->
        {text, rand} =
          tokens
          |> Enum.reverse()
          |> Enum.reduce({text, rand}, fn {start, stop, token}, {acc, rand} ->
            {replacement, rand} = pick(token, rand, metadata)
            {replace_range(acc, start, stop, replacement), rand}
          end)

        reduce_tokens(text, rand, metadata)
    end
  end

  defp pick(token, rand, metadata) do
    {options, warned?} =
      token
      |> String.split("|")
      |> Enum.map(&String.trim/1)
      |> Enum.reject(&(&1 == ""))
      |> then(fn options -> {options, length(options) < length(String.split(token, "|"))} end)

    if warned?, do: emit_warning(:empty_alternative, token, metadata)

    case options do
      [] ->
        {"", rand}

      options ->
        {index, rand} = :rand.uniform_s(length(options), rand)
        {Enum.at(options, index - 1), rand}
    end
  end

  defp innermost_tokens(text) do
    ~r/\{([^{}]*)\}/
    |> Regex.scan(text, return: :index)
    |> Enum.map(fn [{start, length}, {inner_start, inner_length}] ->
      {start, start + length - 1, binary_part(text, inner_start, inner_length)}
    end)
  end

  defp replace_range(text, start, stop, replacement) do
    prefix = binary_part(text, 0, start)
    suffix = binary_part(text, stop + 1, byte_size(text) - stop - 1)
    prefix <> replacement <> suffix
  end

  defp unbalanced?(text) do
    text
    |> String.graphemes()
    |> Enum.reduce_while(0, fn
      "{", count -> {:cont, count + 1}
      "}", 0 -> {:halt, :invalid}
      "}", count -> {:cont, count - 1}
      _char, count -> {:cont, count}
    end)
    |> case do
      0 -> false
      _invalid_or_count -> true
    end
  end

  defp rand_state(seed) do
    :rand.seed_s(:exsss, {seed, Bitwise.bxor(seed, 0x9E3779B9), Bitwise.bxor(seed, 0x85EBCA6B)})
  end

  defp context(execution) do
    %{
      step_execution_id: execution.id,
      attempt_window: execution.attempt_window || 0
    }
  end

  defp emit_error(reason, text, metadata) do
    :telemetry.execute([:dripdrop, :template, :spintax_error], %{count: 1}, %{
      reason: reason,
      input_byte_size: byte_size(text),
      input_hash: :erlang.phash2(text),
      step_execution_id: Map.get(metadata, :step_execution_id),
      attempt_window: Map.get(metadata, :attempt_window)
    })
  end

  defp emit_warning(reason, token, metadata) do
    :telemetry.execute([:dripdrop, :template, :spintax_warning], %{count: 1}, %{
      reason: reason,
      token_byte_size: byte_size(token),
      token_hash: :erlang.phash2(token),
      step_execution_id: Map.get(metadata, :step_execution_id),
      attempt_window: Map.get(metadata, :attempt_window)
    })
  end
end