defmodule Oban.Pro.Plugins.DynamicQueues do
  @moduledoc """
  The `DynamicQueue` plugin extends Oban's basic queue management by persisting queue changes
  across restarts, globally, across all connected nodes. It also boasts a declarative syntax for
  specifying which nodes a queue will run on.

  `DynamicQueues` are ideal for applications that dynamically start, stop, or modify queues at
  runtime.

  ## Using and Configuring

  To begin using `DynamicQueues`, add the module to your list of Oban plugins in `config.exs`:

  ```elixir
  config :my_app, Oban,
    plugins: [Oban.Pro.Plugins.DynamicQueues]
    ...
  ```

  Without providing a list of queues the plugin doesn't have anything to run. The syntax for
  specifying dynamic queues is identical to Oban's built-in `:queues` option, which means you can
  copy them over:

  ```diff
  - queues: [
  -   default: 20,
  -   mailers: [global_limit: 20],
  -   events: [local_limit: 30, rate_limit: [allowed: 100, period: 60]]
  - ]
  + plugins: [{
  +   Oban.Pro.Plugins.DynamicQueues,
  +   queues: [
  +     default: 20,
  +     mailers: [global_limit: 20],
  +     events: [local_limit: 30, rate_limit: [allowed: 100, period: 60]]
  +   ]
  + }]
  ```

  Now, when `DynamicQueues` initializes, it will persist all of the queues to the database and
  start supervising them. The `queues` syntax is _nearly_ identical to Oban's standard queues,
  with an important enhancement we'll look at shortly.

  Each of the persisted queues are referenced globally, by all other connected Oban instances that
  are running the `DynamicQueues` plugin. Changing the queue's name, pausing, scaling, or changing
  any other options will automatically update queues across all nodes—and persist across restarts.

  Persisted queues are referenced by name, so you can tweak a queue's options by changing the
  definition within your config. For example, to bump the mailer's global limit up to `30`:

      queues: [
        mailers: [global_limit: 30],
        ...
      ]

  That isn't especially interesting—after all, that's exactly how regular queues work! Dynamic
  queues start to shine when you insert, update, or delete them _dynamically_, either through Oban
  Web or your own application code.

  ### Synchronizing Queues

  The `:sync_mode` option controls how DynamicQueues handles queue synchronization during startup.
  There are two available modes:

  * `:manual` - Only deletes queues explicitly marked with `delete: true` and inserts/updates
    the remaining queues. This is the default.
  * `:automatic` - Automatically deletes any queues that aren't defined in the configuration and
    inserts/updates the remaining queues.

  Here's how to configure each mode:

  ```elixir
  # Manual mode (default)
  config :my_app, Oban, plugins: [{DynamicQueues, sync_mode: :manual, queues: [...]}]

  # Automatic mode
  config :my_app, Oban, plugins: [{DynamicQueues, sync_mode: :automatic, queues: [...]}]
  ```

  In manual mode, you must explicitly mark queues for deletion:

  ```elixir
  queues: [
    old_queue: [delete: true],
    default: 20,
    mailers: [limit: 10]
  ]
  ```

  In automatic mode, any queue that exists in the database but isn't defined in the configuration
  will be automatically deleted during startup. This is useful when you want to ensure your
  runtime queue configuration exactly matches what's defined in your application config.

  > #### Choosing a Sync Mode {: .info}
  >
  > Use `:manual` mode when you want fine-grained control over queue deletion and want to preserve
  > dynamically created queues across restarts. Use `:automatic` mode when you want to ensure your
  > queue configuration is the single source of truth and automatically clean up old queues.
  >
  > In either mode, changes to queues are persisted across restarts **until the configuration is
  > changed**. For example, if you change the `global_limit` of a queue through the Web dashboard,
  > that change will persist across restarts until you change the `global_limit` in your config.

  ### Limiting Where Queues Run

  Dynamic queues can be configured to run on a subset of available nodes. This is especially
  useful when wish to restrict resource-intensive queues to only dedicated nodes. Restriction is
  configured through the `only` option, which you use like this:

      queues: [
        basic: [local_limit: 10, only: {:node, :=~, "web|worker"}],
        audio: [local_limit: 5, only: {:node, "worker.1"}],
        video: [local_limit: 5, only: {:node, "worker.2"}],
        learn: [local_limit: 5, only: {:sys_env, "EXLA", "CUDA"}],
        store: [local_limit: 1, only: {:sys_env, "WAREHOUSE", true}]
      ]

  In this example we've defined five queues, with the following restrictions:

  * `basic` — will run on a node named `web` or `worker`
  * `audio` — will only run on a node named `worker.1`
  * `video` — will only run on a node named `worker.2`
  * `learn` — will run wherever `EXLA=CUDA` is an environment variable
  * `store` — will run wherever `WAREHOUSE=true` is an environment variable

  Here are the various match modes, operators, and allowed patterns:

  #### Modes

  * `:node` — matches the node name as set in your Oban config. By default, `node`
    is the node's id in a cluster, the hostname outside a cluster, or a `DYNO`
    variable on Heroku.
  * `:sys_env` — matches a single system environment variable as retrieved by
    `System.get_env/1`

  #### Operators

  * `:==` — compares the pattern and runtime value for _equality_ as strings. This
    is the default operator if nothing is specified.
  * `:!=` — compares the pattern and runtime value for _inequality_ as strings
  * `:=~` — treats the pattern as a regex and matches it against a runtime value

  #### Patterns

  * `boolean` — either `true` or `false`, which is stringified before comparison
  * `string` — either a literal pattern or a regular expression, depending on the
    supplied operator

  ## Runtime Updates

  Dynamic queues are persisted to the database, making it easy to manipulate them directly through
  CRUD operations, or indirectly with Oban's queue operations, i.e. `pause_queue/2`,
  `scale_queue/2`.

  Explicit queue options will be overwritten on restart, while omitted fields are retained. For
  example, consider the following dynamic queue entry:

      queues: [
        default: [limit: 10],
        ...
      ]

  Scaling that `default` queue up or down at runtime _wouldn't_ persist across a restart, because
  the definition will overwrite the `limit`. However, pausing the queue or changing the
  `global_limit` _would_ persist because they aren't included in the queue definition.

      # pause, global_limit, etc. will persist, but limit won't
      default: [limit: 10]

      # pause will persist, but limit and global_limit won't
      default: [limit: 10, global_limit: 20]

      # neither limits nor pausing will persist
      default: [limit: 10, global_limit: 20, paused: true]

  See function documentation for `all/0`, `insert/1`, `update/2`, and `delete/1` for more
  information about runtime updates.

  ## Enabling Polling Mode

  In environments with restricted connectivity (where PubSub doesn't work) you can still use
  DynamicQueues at runtime through polling mode. The polling interval is entirely up to you, as
  it's disabled by default.

      config :my_app, Oban,
        plugins: [{Oban.Pro.Plugins.DynamicQueues, interval: :timer.minutes(1)}]

  With the interval above each DynamicQueues instance will wake up every minute, check the
  database for changes, and start new queues.

  ## Isolation and Namespacing

  All DynamicQueues functions have an alternate clause that accepts an Oban instance name for the
  first argument. This matches base `Oban` functions such as `Oban.pause_queue/2`, which allow you
  to seamlessly work with multiple Oban instances and across multiple database prefixes. For
  example, you can use `all/1` to list all queues for the instance named `ObanPrivate`:

      queues = DynamicQueues.all(ObanPrivate)

  Likewise, to insert a new queue using the configuration associated with the `ObanPrivate`
  instance:

      DynamicQueues.insert(ObanPrivate, private: limit: 10)
  """

  @behaviour Oban.Plugin

  use GenServer

  import Ecto.Query

  alias Ecto.Changeset
  alias Oban.Pro.Engines.Smart
  alias Oban.Pro.{Queue, Utils}
  alias Oban.{Config, Midwife, Notifier, Peer, Registry, Repo, Validation}

  require Logger

  @type oban_name :: term()
  @type operator :: :== | :!= | :=~
  @type partition :: Smart.partition()
  @type pattern :: boolean() | String.t()
  @type period :: Smart.period()
  @type sync_mode :: :manual | :automatic
  @type sys_key :: String.t()

  @type queue_name :: atom() | binary()
  @type queue_input :: [{queue_name(), pos_integer() | queue_opts() | [queue_opts()]}]
  @type queue_opts ::
          {:local_limit, pos_integer()}
          | {:global_limit, Smart.global_limit()}
          | {:only, only()}
          | {:paused, boolean()}
          | {:rate_limit, Smart.rate_limit()}

  @type only ::
          {:node, pattern()}
          | {:node, operator(), pattern()}
          | {:sys_env, sys_key(), pattern()}
          | {:sys_env, sys_key(), operator(), pattern()}

  defstruct [
    :conf,
    :foreman_ref,
    :timer,
    interval: :infinity,
    queues: [],
    restart_attempts: 50,
    restart_delay: 10,
    sync_mode: :manual
  ]

  defguardp is_name(name) when is_binary(name) or (is_atom(name) and not is_nil(name))

  @impl Oban.Plugin
  def start_link(opts) do
    {name, opts} = Keyword.pop(opts, :name)

    GenServer.start_link(__MODULE__, struct!(__MODULE__, opts), name: name)
  end

  @impl Oban.Plugin
  def validate(opts) do
    Validation.validate_schema(opts,
      conf: {:custom, &validate_engine/1},
      name: :any,
      interval: :timeout,
      queues: {:custom, &validate_queues/1},
      sync_mode: {:enum, [:manual, :automatic]}
    )
  end

  @doc """
  Retrieve all persisted queues.

  While it's possible to modify queue's returned from `all/0`, it is recommended that you use
  `update/2` to ensure options are cast and validated correctly.

  ## Examples

  Retrieve a list of all queue schemas with persisted attributes:

      DynamicQueues.all()
  """
  @spec all(oban_name()) :: [Ecto.Schema.t()]
  def all(oban_name \\ Oban) do
    oban_name
    |> Oban.config()
    |> all_queues()
  end

  @doc """
  Retrieve a single persisted queue.

  ## Examples

  Retrieve the default queue's schema with persisted attributes:

      DynamicQueues.get(:default)
  """
  @spec get(oban_name(), queue_name()) :: nil | Ecto.Schema.t()
  def get(oban_name \\ Oban, queue_name) do
    oban_name
    |> Oban.config()
    |> Repo.get_by(Queue, name: to_string(queue_name))
  end

  @doc """
  Persist a list of queue inputs, exactly like the `:queues` option passed as configuration.

  Note that `insert/1` acts like an upsert, making it possible to modify queues if the name
  matches. Still, it is better to use `update/2` to make targeted updates.

  ## Examples

  Insert a variety of queues with standard and advanced options:

      DynamicQueues.insert(
        basic: 10,
        audio: [global_limit: 10],
        video: [global_limit: 10],
        learn: [local_limit: 5, only: {:node, :=~, "learn"}]
      )
  """
  @spec insert(oban_name(), queue_input()) ::
          {:ok, [Ecto.Schema.t()]} | {:error, Ecto.Changeset.t()}
  def insert(oban_name \\ Oban, [_ | _] = entries) do
    conf = Oban.config(oban_name)

    case insert_queues(conf, entries) do
      {:ok, changes} ->
        inserted = Map.values(changes)

        Enum.each(inserted, &notify(conf, :dyn_start, &1.name))

        {:ok, inserted}

      {:error, _name, changeset, _changes} ->
        {:error, changeset}
    end
  end

  @doc """
  Modify a single queue's options.

  Every option available when inserting queues can be updated.

  ## Examples

  The following call demonstrates updating every possible option:

      DynamicQueues.update(
        :video,
        local_limit: 5,
        global_limit: 20,
        rate_limit: [allowed: 10, period: 30, partition: :worker]],
        only: {:node, :=~, "media"},
        paused: false
      )

  Updating a single option won't remove other persisted options. If you'd like to
  clear an uption you must set them to `nil`:

      DynamicQueues.update(:video, global_limit: nil)

  Since `update/2` operates on a single queue, it is possible to rename a queue
  without doing a `delete`/`insert` dance:

      DynamicQueues.update(:video, name: :media)
  """
  @spec update(oban_name(), queue_name(), [queue_opts()]) ::
          {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
  def update(oban_name \\ Oban, name, opts) when is_name(name) do
    conf = Oban.config(oban_name)

    with {:ok, queue} <- fetch_queue(conf, to_string(name)),
         {:ok, queue} <- update_queue(conf, queue, opts) do
      if queue.name == to_string(name) do
        opts =
          opts
          |> Map.new()
          |> Map.delete(:only)
          |> Map.merge(%{action: :scale, ident: :any, queue: name, update: false})

        # Manually send the notification because Oban.scale_queue won't allow unknown fields
        Notifier.notify(conf.name, :signal, opts)
      else
        notify(conf, :dyn_start, queue.name)
        notify(conf, :dyn_stop, name)
      end

      {:ok, queue}
    end
  end

  defp update_queue(conf, queue, opts) do
    params =
      case Keyword.pop(opts, :name) do
        {nil, opts} -> %{opts: Map.new(opts)}
        {name, []} -> %{name: name}
        {name, opts} -> %{name: name, opts: Map.new(opts)}
      end

    changeset = Queue.changeset(queue, params)

    Repo.update(conf, changeset)
  rescue
    Ecto.StaleEntryError ->
      update_queue(conf, Repo.reload!(conf, queue), opts)
  end

  @doc """
  Delete a queue by name at runtime, rather than using the `:delete` option into the `queues` list
  in your configuration.

  ## Examples

  Delete ethe "audio" queue:

      {:ok, _} = DynamicQueues.delete(:audio)
  """
  @spec delete(oban_name(), queue_name()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
  def delete(oban_name \\ Oban, name) when is_name(name) do
    conf = Oban.config(oban_name)

    with {:ok, queue} <- fetch_queue(conf, to_string(name)),
         {:ok, queue} <- Repo.delete(conf, queue) do
      notify(conf, :dyn_stop, queue.name)

      {:ok, queue}
    end
  end

  # Callbacks

  @impl GenServer
  def init(state) do
    :telemetry.execute([:oban, :plugin, :init], %{}, %{conf: state.conf, plugin: __MODULE__})

    ref =
      state.conf.name
      |> Registry.whereis(Foreman)
      |> Process.monitor()

    {:ok, %{state | foreman_ref: ref}, {:continue, :start}}
  end

  @impl GenServer
  def handle_continue(:start, state) do
    :ok = Notifier.listen(state.conf.name, [:signal])

    case manage_queues(state) do
      {:ok, _inserted} ->
        state.conf
        |> all_queues()
        |> Enum.reject(&queue_running?(state.conf, &1))
        |> Enum.each(&start_queue(state.conf, &1))

        {:noreply, schedule_refresh(state)}

      {:error, _name, changeset, _changes} ->
        {:stop, %ArgumentError{message: changeset.errors}, state}
    end
  end

  @impl GenServer
  def handle_call(:flush, _from, state) do
    {:reply, :ok, state}
  end

  @impl GenServer
  def handle_info({:DOWN, ref, :process, _pid, _reason}, %{foreman_ref: ref} = state) do
    state = restart_queues(state, state.restart_attempts)

    {:noreply, state}
  end

  def handle_info({:notification, :signal, payload}, state) do
    case payload do
      %{"action" => "scale", "ident" => "any", "queue" => queue_name} ->
        scale_queue(state.conf, queue_name, payload)

      %{"action" => "pause", "ident" => "any", "queue" => queue_name} ->
        scale_queue(state.conf, queue_name, %{"paused" => true})

      %{"action" => "resume", "ident" => "any", "queue" => queue_name} ->
        scale_queue(state.conf, queue_name, %{"paused" => false})

      %{"action" => "dyn_start", "queue" => queue_name} ->
        start_queue(state.conf, queue_name)

      %{"action" => "dyn_stop", "queue" => queue_name} ->
        stop_queue(state.conf, queue_name)

      _ ->
        :ignore
    end

    {:noreply, state}
  end

  def handle_info(:refresh, state) do
    for queue <- all_queues(state.conf), not queue_running?(state.conf, queue) do
      start_queue(state.conf, queue)
    end

    {:noreply, schedule_refresh(state)}
  end

  def handle_info(message, state) do
    Logger.warning(
      message: "Received unexpected message: #{inspect(message)}",
      source: :oban_pro,
      module: __MODULE__
    )

    {:noreply, state}
  end

  # Validations

  defp validate_engine(%Config{engine: engine}) do
    if engine == Smart do
      :ok
    else
      {:error,
       """
       DynamicQueues requires the Smart engine to run correctly, but you're using:

       engine: #{inspect(engine)}

       You can either use the Smart engine or remove DynamicQueue from your plugins.
       """}
    end
  end

  defp validate_queues(queues) do
    Validation.validate(:queues, queues, &validate_queue/1)
  end

  defp validate_queue({_name, delete: true}), do: :ok

  defp validate_queue({name, _} = tuple) when is_atom(name) do
    changeset = Queue.changeset(tuple)

    if changeset.valid? do
      :ok
    else
      exception = Utils.to_exception(changeset)

      {:error, "expected #{inspect(name)} to be a valid queue, but: #{exception.message}"}
    end
  end

  defp validate_queue(queue) do
    {:error, "expected queue to be a keyword pair, got: #{inspect(queue)}"}
  end

  # Queries

  defp all_queues(conf) do
    Repo.all(conf, order_by(Queue, asc: :inserted_at))
  end

  defp manage_queues(%{sync_mode: :automatic} = state) do
    Repo.transaction(state.conf, fn ->
      delete_missing(state.conf, state.queues)
      insert_queues(state.conf, state.queues)
    end)
  end

  defp manage_queues(state) do
    Repo.transaction(state.conf, fn ->
      {deletes, inserts} =
        Enum.split_with(state.queues, fn
          {_name, opts} when is_list(opts) -> Keyword.get(opts, :delete)
          {_name, _lim} -> false
        end)

      delete_queues(state.conf, deletes)
      insert_queues(state.conf, inserts)
    end)
  end

  defp delete_missing(conf, queues) do
    Repo.delete_all(conf, where(Queue, [q], q.name not in ^queue_names(queues)))
  end

  defp delete_queues(conf, queues) do
    Repo.delete_all(conf, where(Queue, [q], q.name in ^queue_names(queues)))
  end

  defp queue_names(queues), do: Enum.map(queues, &to_string(elem(&1, 0)))

  defp insert_queues(conf, queues) do
    changesets =
      Map.new(queues, fn {name, opts} ->
        changeset = Queue.changeset({name, opts})

        {changeset.changes.name, {changeset, opts}}
      end)

    Repo.transaction(conf, fn ->
      existing = existing_map(conf, Map.keys(changesets))

      Enum.reduce(changesets, %{}, fn {name, {changeset, opts}}, acc ->
        cond do
          is_map_key(existing, name) and Map.get(existing, name).hash == changeset.changes.hash ->
            acc

          is_map_key(existing, name) ->
            params = Queue.normalize({name, opts})

            changeset =
              existing
              |> Map.get(name)
              |> Queue.changeset(params)

            Map.put(acc, name, Repo.update!(conf, changeset))

          true ->
            Map.put(acc, name, Repo.insert!(conf, changeset))
        end
      end)
    end)
  end

  defp existing_map(conf, names) do
    query = where(Queue, [q], q.name in ^names)

    conf
    |> Repo.all(query)
    |> Map.new(&{&1.name, &1})
  end

  defp fetch_queue(conf, name) do
    case Repo.one(conf, where(Queue, name: ^name)) do
      nil -> {:error, "no queue named #{inspect(name)} could be found"}
      queue -> {:ok, queue}
    end
  end

  # Scheduling

  defp schedule_refresh(%{interval: :infinity} = state), do: state

  defp schedule_refresh(state) do
    %{state | timer: Process.send_after(self(), :refresh, state.interval)}
  end

  # Notification

  defp notify(conf, action, queue_name) do
    Notifier.notify(conf.name, :signal, %{action: action, ident: :any, queue: queue_name})
  end

  # Supervision

  defp restart_queues(state, 0) do
    state
  end

  defp restart_queues(state, attempt) do
    case Registry.whereis(state.conf.name, Foreman) do
      pid when is_pid(pid) ->
        ref = Process.monitor(pid)

        state.conf
        |> all_queues()
        |> Enum.each(&start_queue(state.conf, &1))

        %{state | foreman_ref: ref}

      nil ->
        Process.sleep(state.restart_delay)

        restart_queues(state, attempt - 1)
    end
  end

  defp queue_running?(conf, queue) do
    conf.name
    |> Registry.whereis({:supervisor, queue.name})
    |> is_pid()
  end

  defp scale_queue(_conf, _, %{"update" => false}), do: :ok

  defp scale_queue(conf, "*", %{"paused" => paused}) do
    if Peer.leader?(conf) do
      query =
        from q in Queue,
          update: [set: [opts: fragment("jsonb_set(?, '{paused}', ?)", q.opts, ^paused)]]

      Repo.update_all(conf, query, [])
    end
  end

  defp scale_queue(conf, queue_name, payload) do
    with true <- Peer.leader?(conf),
         {:ok, queue} <- fetch_queue(conf, queue_name) do
      new_opts = Map.drop(payload, ["action", "ident", "queue"])

      changeset =
        queue
        |> Queue.changeset(%{opts: new_opts})
        |> Changeset.delete_change(:hash)

      {:ok, _} = Repo.update(conf, changeset)
    end
  end

  defp start_queue(conf, queue_name) when is_binary(queue_name) do
    with {:ok, queue} <- fetch_queue(conf, queue_name), do: start_queue(conf, queue)
  end

  defp start_queue(conf, queue) do
    if run_locally?(conf, queue.only) do
      Midwife.start_queue(conf, Queue.to_keyword_opts(queue))
    else
      :ignore
    end
  end

  defp stop_queue(conf, queue_name) when is_binary(queue_name) do
    Midwife.stop_queue(conf, queue_name)
  end

  defp run_locally?(%{node: node}, %{mode: :node, op: op, value: value}) do
    compare(node, op, value)
  end

  defp run_locally?(_conf, %{mode: :sys_env, op: op, key: key, value: value}) do
    key
    |> System.get_env()
    |> to_string()
    |> compare(op, value)
  end

  defp run_locally?(_conf, _only), do: true

  defp compare(value_a, :==, value_b), do: value_a == value_b
  defp compare(value_a, :!=, value_b), do: value_a != value_b
  defp compare(value_a, :=~, value_b), do: value_a =~ Regex.compile!(value_b, "i")
end
