defmodule Ash.Reactor.Notifications do
  @moduledoc """
  Reactor middleware used to collect and emit notifications upon successful
  completion of the Reactor.
  """

  use Reactor.Middleware

  require Logger

  @agent_key :ash_notification_agent
  @notification_key :ash_notifications

  defguardp has_agent?(context)
            when is_map_key(context, @agent_key) and
                   :erlang.map_get(@agent_key, context) != []

  defguardp has_notifications?(context)
            when is_map_key(context, @notification_key) and
                   :erlang.map_get(@notification_key, context) != []

  @doc """
  When starting a reactor, start an agent to act as a temporary store of
  notifications.
  """
  @impl true
  def init(context) when has_notifications?(context) do
    with {:ok, notifications} <- Map.fetch(context, @notification_key),
         {:ok, context} <- agent_start(context),
         {:ok, context} <- agent_put(context, notifications) do
      context =
        context
        |> Map.delete(@notification_key)

      {:ok, context}
    end
  end

  def init(context), do: agent_start(context)

  @doc """
  When halting the reactor, store any queued notifications in the context for
  eventual resumption.
  """
  @impl true
  def halt(context) when has_agent?(context) do
    with {:ok, notifications} <- agent_get(context),
         {:ok, context} <- agent_stop(context) do
      if Enum.any?(notifications) do
        {:ok,
         Map.update(
           context,
           @notification_key,
           notifications,
           &Enum.concat(&1, notifications)
         )}
      else
        {:ok, context}
      end
    end
  end

  @doc """
  When the reactor completes successfully, publish any queued notifications.
  """
  @impl true
  def complete(result, context) when has_agent?(context) do
    with {:ok, notifications} <- agent_get(context),
         {:ok, context} <- agent_stop(context),
         [] <- __MODULE__.publish(context, notifications) do
      {:ok, result}
    else
      {:error, reason} ->
        {:error, reason}

      notifications when is_list(notifications) ->
        if Process.get(:ash_started_transaction?) do
          Process.put(
            :ash_notifications,
            List.wrap(Process.get(:ash_notifications)) ++
              notifications
          )
        else
          {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)

          Logger.warning("""
            Missed #{Enum.count(notifications)} notifications in Reactor complete hook.

            This happens when your steps return notifications but they are unable to be published
            upon successful completion of the reactor.

            #{Exception.format_stacktrace(stacktrace)}
          """)
        end

        {:ok, result}
    end
  end

  def complete(result, _context), do: {:ok, result}

  @doc """
  When the reactor fails, discard any queued notifications.
  """
  @impl true
  def error(_errors, context) do
    agent_stop(context)

    :ok
  end

  @doc """
  Add notifications to the queue to be published on reactor success.
  """
  @spec enqueue_notifications(Reactor.context(), Enumerable.t(Ash.Notifier.Notification.t())) ::
          :ok | {:error, any}
  def enqueue_notifications(context, notifications) do
    with {:ok, _} <- agent_put(context, notifications) do
      :ok
    end
  end

  @doc """
  Dispatch notifications.
  """
  @spec publish(
          Reactor.context(),
          Ash.Notifier.Notification.t() | [Ash.Notifier.Notification.t()]
        ) ::
          [Ash.Notifier.Notification.t()]
  def publish(context, notifications) when has_agent?(context) do
    case agent_put(context, notifications) do
      {:ok, _} -> []
      {:error, _} -> notifications
    end
  end

  def publish(_, notifications), do: Ash.Notifier.notify(notifications)

  defp agent_start(context) do
    case Agent.start_link(fn -> [] end) do
      {:ok, pid} -> {:ok, Map.update(context, @agent_key, [pid], &[pid | &1])}
      {:error, reason} -> {:error, reason}
    end
  end

  defp agent_get(%{@agent_key => [pid | _]}) do
    notifications = Agent.get(pid, & &1, 100)
    {:ok, notifications}
  rescue
    error -> {:error, error}
  end

  defp agent_get(%{@agent_key => []}),
    do: {:error, "Context does not contain a notification agent"}

  defp agent_stop(%{@agent_key => [pid | agents]} = context) do
    :ok = Agent.stop(pid, :normal)

    {:ok, %{context | @agent_key => agents}}
  rescue
    error -> {:error, error}
  end

  defp agent_put(%{@agent_key => [pid | _]} = context, notifications) do
    :ok = Agent.update(pid, &Enum.concat(&1, notifications))

    {:ok, context}
  rescue
    error -> {:error, error}
  end
end
