defmodule Oban.Pro.Chunk do
  @moduledoc """
  Chunk workers execute jobs together in groups based on a size or a timeout option, e.g. when
  1000 jobs are available or after 10 minutes have elapsed.

  Multiple chunks can run in parallel within a single queue, and each chunk may be composed of
  many thousands of jobs. Aside from improved throughput for a single queue, chunks are ideal as
  the initial stage of data-ingestion and data-processing pipelines.

  ## Usage

  Let's define a worker that sends SMS messages in chunks, rather than individually:

  ```elixir
  defmodule MyApp.ChunkWorker do
    use Oban.Pro.Chunk, queue: :messages, size: 100, timeout: 1000

    @impl true
    def process([_ | _] = jobs) do
      jobs
      |> Enum.map(& &1.args)
      |> MyApp.Messaging.send_batch()

      :ok
    end
  end
  ```

  Notice that we declared a `size` and a `timeout` along with a `queue` for the worker. If `size`
  or `timeout` are omitted they fall back to their defaults: 100 `size` and 1000ms respectively.

  To process _larger_ batches _less_ frequently, we can increase both values:

  ```elixir
  use Oban.Pro.Chunk, size: 500, timeout: :timer.seconds(5)
  ```

  Now chunks will run with up to 500 jobs or every 5 seconds, whichever comes first.

  Unlike other Pro workers, a `Chunk` worker's `process/1` receives a list of jobs rather than a
  single job struct. Fittingly, the [expected return values](#t:result/0) are different as well.

  ## Chunk Partitioning

  By default, chunks are divided into groups based on the `queue` and `worker`. This means that
  each chunk consists of workers belonging to the same queue, regardless of their `args` or
  `meta`. However, this approach may not always be suitable. For instance, you may want to group
  workers based on a specific argument such as `:account_id` instead of just the worker. In such
  cases, you can use the [`:by`](#t:chunk_by/0) option to customize how chunks are partitioned.

  Here are a few examples of the `:by` option that you can use to achieve fine-grained control over
  chunk partitioning:

  ```elixir
  # Explicitly chunk by :worker
  use Oban.Pro.Chunk, by: :worker

  # Chunk by a single args key without considering the worker
  use Oban.Pro.Chunk, by: [args: :account_id]

  # Chunk by multiple args keys without considering the worker
  use Oban.Pro.Chunk, by: [args: [:account_id, :cohort]]

  # Chunk by worker and a single args key
  use Oban.Pro.Chunk, by: [:worker, args: :account_id]

  # Chunk by a single meta key
  use Oban.Pro.Chunk, by: [meta: :source_id]
  ```

  When partitioning chunks of jobs, it's important to note that using only `:args` or `:meta`
  without `:worker` may result in heterogeneous chunks of jobs from different workers.
  Nevertheless, regardless of the partitioning method chunks always consist of jobs from the same
  queue.

  Here's a simple example of partitioning by `worker` and an `author_id` field to batch analysis
  of recent messages per author:

  ```elixir
  defmodule MyApp.LLMAnalysis do
    use Oban.Pro.Chunk, by: [:worker, args: :author_id], size: 50, timeout: 30_000

    @impl true
    def process([%{args: %{"author_id" => author_id}} | _] = jobs) do
      messages =
        jobs
        |> Enum.map(& &1.args["message_id"])
        |> MyApp.Messages.all()

      {:ok, sentiment} = MyApp.GPT.gauge_sentiment(messages)

      MyApp.Messages.record_sentiment(author_id)
    end
  end
  ```

  ## Chunk Results and Error Handling

  `Chunk` worker's result type is tailored to handling multiple jobs at once. The result types
  allow you to succeed an entire chunk, or selectively apply different outcomes to individual
  jobs. Here are the possible results:

  * `:ok` — The chunk succeeded and all jobs can be marked complete

  * `{:ok, result}` — Like `:ok`, but with a result for testing.

  * `{:error, reason, jobs}` — One or more jobs in the chunk failed and may be retried, any
    unlisted jobs are a success.

  * `{:cancel, reason, jobs}` — One or more jobs in the chunk should be cancelled, any unlisted
    jobs are a success.

  * `{:snooze, period, jobs}` — One or more jobs in the chunk should be snoozed and rescheduled
    to run again after the specified period (e.g. `10` for seconds or `{1, :minute}`), any
    unlisted jobs are a success.

  * `[error: {reason, jobs}, cancel: {reason, jobs}, ...]` — Selectively apply different
    outcomes to different jobs. Supports `error`, `cancel`, `discard`, and `snooze` keys. For
    snooze, use `snooze: {period, jobs}`. Any unlisted jobs are a success.

  To illustrate using chunk results let's expand on the message processing example from earlier.
  We'll extend it to complete the whole batch when all messages are delivered or cancel
  undeliverable messages:

  ```elixir
  def process([_ | _] = jobs) do
    notifications =
      jobs
      |> Enum.map(& &1.args)
      |> MyApp.Messaging.send_batch()

    bad_token = fn %{response: response} -> response == :bad_token end

    if Enum.any?(notifications, bad_token) do
      cancels =
        notifications
        |> Enum.zip(jobs)
        |> Enum.filter(fn {notification, _job} -> bad_token.(notification) end)
        |> Enum.map(&elem(&1, 1))

      {:cancel, :bad_token, cancels}
    else
      {:ok, notifications}
    end
  end
  ```

  In the event of an ephemeral crash, like a network issue, the entire chunk may be retried if
  there are any remaining attempts.

  ## Chunk Organization

  Chunks are run by a leader job (which has nothing to do with peer leadership). When the leader
  executes it determines whether a complete chunk is available or if enough time has elapsed to
  run anyhow. If neither case applies, then the leader will sleep until the timeout elapses and
  then execute with as many jobs as it can find.

  Completion queries run every `1000ms` by default. You can use the `:sleep` option to control how
  long the leader delays between queries to check for complete chunks:

  ```elixir
  use Oban.Pro.Chunk, size: 50, sleep: 2_000, timeout: 10_000
  ```

  ## Run Chunks Immediately

  A chunk won't run until either the full number of jobs are available (`size`), or the total time
  has elapsed (`timeout`). In situations where there is a long gap between incoming jobs it may be
  desirable to run immediately when the leader starts. This can be done with the `leading` option.
  For example, to chunk all jobs that arrive within a 5 minute window, but run immediately if more
  than 5 minutes have passed since the last chunk completed:

  ```elixir
  use Oban.Pro.Chunk, size: 100, leading: true, timeout: :timer.minutes(5)
  ```

  ## Optimizing Chunks

  Queues with high concurrency and low throughput may have multiple chunk leaders running
  simultaneously rather than getting bundled into a single chunk. The solution is to reduce the
  queue's global concurrency to a smaller number so that new chunk leader jobs don't start and the
  existing chunk can run a bigger batch.

  ```elixir
  queues: [
    chunked_queue: [global_limit: 1]
  ]
  ```

  A low global limit will reduce overall throughput. Partitioning the queue with the same options
  as the chunk can preserve throughput by allowing one concurrent leader per chunk. For example,
  if you were chunking by `:user_id` in args, you'd partition the queue with the same option:

  ```elixir
  queues: [
    chunked_queue: [local_limit: 20, global_limit: [allowed: 1, partition: [args: :user_id]]]
  ]
  ```
  """

  import Ecto.Query
  import DateTime, only: [utc_now: 0]

  alias Oban.{CrashError, Job, Notifier, PerformError, Period, Repo, Validation}
  alias Oban.Pro.Engines.Smart
  alias Oban.Pro.Utils

  @typedoc """
  A single key or list of keys used to specify fields for partitioning.
  """
  @type key_or_keys :: atom() | [atom()]

  @typedoc """
  Specifies how jobs are grouped into chunks.

  Jobs are always partitioned by queue, but may also be grouped by worker, args, or meta fields.
  """
  @type chunk_by ::
          :worker
          | {:args, key_or_keys()}
          | {:meta, key_or_keys()}
          | [:worker | {:args, key_or_keys()} | {:meta, key_or_keys()}]

  @typedoc """
  A sub-result for error, cancel, or discard outcomes within a keyword list result.
  """
  @type sub_result :: {reason :: term(), [Job.t()]}

  @typedoc """
  A sub-result for snooze outcomes within a keyword list result.
  """
  @type snooze_result :: {period :: Oban.Period.t(), [Job.t()]}

  @typedoc """
  The return value of `process/1`, indicating the outcome for jobs in the chunk.

  Unlisted jobs are marked as completed. See the "Chunk Results and Error Handling" section for
  details on each variant.
  """
  @type result ::
          :ok
          | {:ok, term()}
          | {:cancel | :discard | :error, reason :: term(), [Job.t()]}
          | {:snooze, period :: Oban.Period.t(), [Job.t()]}
          | [
              cancel: sub_result(),
              discard: sub_result(),
              error: sub_result(),
              snooze: snooze_result()
            ]

  @typedoc """
  Options for configuring chunk behavior.
  """
  @type options :: [
          by: chunk_by(),
          leading: boolean(),
          size: pos_integer(),
          sleep: pos_integer(),
          timeout: pos_integer()
        ]

  @doc false
  defmacro __using__(opts) do
    {chunk_opts, other_opts} = Keyword.split(opts, ~w(by leading size sleep timeout)a)

    quote do
      Validation.validate!(unquote(chunk_opts), &Oban.Pro.Chunk.validate/1)

      use Oban.Pro.Worker, unquote(other_opts)

      alias Oban.Pro.Chunk

      @chunk_meta %{
        chunk: true,
        chunk_by: Keyword.get(unquote(chunk_opts), :by, [:worker]),
        chunk_leading: Keyword.get(unquote(chunk_opts), :leading, false),
        chunk_size: Keyword.get(unquote(chunk_opts), :size, 100),
        chunk_sleep: Keyword.get(unquote(chunk_opts), :sleep, 1000),
        chunk_timeout: Keyword.get(unquote(chunk_opts), :timeout, 1000)
      }

      @impl Oban.Worker
      def new(args, opts) when is_map(args) and is_list(opts) do
        meta =
          @chunk_meta
          |> Map.merge(Keyword.get(opts, :meta, %{}))
          |> Map.update!(:chunk_by, &Utils.normalize_by/1)

        chunk_id = Chunk.to_chunk_id(meta.chunk_by, __MODULE__, args, opts)
        meta = Map.put(meta, :chunk_id, chunk_id)

        super(args, Keyword.put(opts, :meta, meta))
      end

      @impl Oban.Worker
      def perform(%Job{} = job) do
        opts = __opts__()

        with {:ok, job} <- Oban.Pro.Worker.before_process(__MODULE__, job, opts) do
          Process.put(:oban_processing, {__MODULE__, job, opts})

          job
          |> Chunk.maybe_process(__MODULE__)
          |> Oban.Pro.Worker.after_process(__MODULE__, job, opts)
        end
      end
    end
  end

  defmacrop in_chunk(meta, chunk_id) do
    quote do
      fragment(
        "? \\? 'chunk_id' AND ?->>'chunk_id' = ?",
        unquote(meta),
        unquote(meta),
        unquote(chunk_id)
      )
    end
  end

  defmacrop finished_at(job) do
    quote do
      type(
        fragment(
          "greatest(?, ?, ?)",
          unquote(job).completed_at,
          unquote(job).discarded_at,
          unquote(job).cancelled_at
        ),
        :utc_datetime_usec
      )
    end
  end

  @doc false
  def validate(opts) do
    Validation.validate_schema(opts,
      by: {:custom, &Oban.Pro.Validation.validate_by/1},
      leading: :boolean,
      size: :pos_integer,
      sleep: :timeout,
      timeout: :timeout
    )
  end

  @doc false
  def to_chunk_id(chunk_by, worker, args, opts) do
    chunk_data =
      Enum.map([:queue | chunk_by], fn
        :queue -> opts |> Keyword.get(:queue, "default") |> to_string()
        :worker -> inspect(worker)
        [:args, keys] -> Utils.take_keys(args, keys)
        [:meta, keys] -> opts |> Keyword.get(:meta, %{}) |> Utils.take_keys(keys)
      end)

    Utils.hash64(chunk_data)
  end

  # Public Interface

  @doc false
  def maybe_process(%Job{conf: conf} = job, worker) do
    cond do
      full_timeout?(conf, job) ->
        fetch_and_process(worker, job)

      full_chunk?(conf, job) ->
        fetch_and_process(worker, job)

      true ->
        job.meta
        |> Map.get("chunk_timeout", 1000)
        |> sleep_and_process(worker, job)
    end
  end

  # Check Helpers

  defp full_chunk?(conf, %{meta: %{"chunk_id" => chunk_id, "chunk_size" => size}}) do
    limit = size - 1

    query =
      Job
      |> select([j], j.id)
      |> where([j], j.state == "available")
      |> where([j], in_chunk(j.meta, ^chunk_id))
      |> limit(^limit)

    Repo.one(conf, from(oj in subquery(query), select: count(oj.id) >= ^limit))
  end

  defp full_timeout?(_, %{meta: %{"chunk_leading" => false}}), do: false

  defp full_timeout?(conf, job) do
    %{id: job_id, meta: %{"chunk_id" => chunk_id, "chunk_timeout" => timeout}} = job

    query =
      Job
      |> where([j], j.id != ^job_id)
      |> where([j], j.state in ~w(completed cancelled discarded))
      |> where([j], in_chunk(j.meta, ^chunk_id))
      |> order_by(desc: :id)
      |> limit(1)
      |> select([j], finished_at(j))

    last_ts = Repo.one(conf, query)

    last_ts &&
      DateTime.utc_now()
      |> DateTime.add(-timeout, :millisecond)
      |> DateTime.after?(last_ts)
  end

  # Processing Helpers

  defp sleep_and_process(total, worker, %Job{conf: conf, meta: meta} = job) do
    sleep = Map.get(meta, "chunk_sleep", 1000)

    Process.sleep(sleep)

    if total - sleep < 0 or full_chunk?(conf, job) do
      fetch_and_process(worker, job)
    else
      sleep_and_process(total - sleep, worker, job)
    end
  end

  defp fetch_and_process(worker, %{conf: conf, meta: %{"chunk_size" => size}} = job) do
    {:ok, chunk} = fetch_chunk(conf, job, size - 1)
    {chunk, _errored} = prepare_chunk(chunk)

    guard_cancel(conf, worker, job, chunk)
    guard_timeout(conf, worker.timeout(job), worker, job, chunk)

    try do
      case worker.process([job | chunk]) do
        :ok ->
          ack_jobs(conf, worker, job, chunk, %{})

        {:ok, result} ->
          with :ok <- ack_jobs(conf, worker, job, chunk, %{}), do: {:ok, result}

        {:cancel, reason, cancelled} ->
          ops = Map.new(cancelled, &{&1.id, {:cancel, reason}})
          ack_jobs(conf, worker, job, chunk, ops)

        {:discard, reason, discarded} ->
          ops = Map.new(discarded, &{&1.id, {:discard, reason}})
          ack_jobs(conf, worker, job, chunk, ops)

        {:error, reason, errored} ->
          ops = Map.new(errored, &{&1.id, {:error, reason}})
          ack_jobs(conf, worker, job, chunk, ops)

        {:snooze, seconds, snoozed} ->
          ops = Map.new(snoozed, &{&1.id, {:snooze, seconds}})
          ack_jobs(conf, worker, job, chunk, ops)

        [_ | _] = multiple ->
          ops =
            for {oper, {reason, jobs}} <- multiple,
                job <- jobs,
                into: %{},
                do: {job.id, {oper, reason}}

          ack_jobs(conf, worker, job, chunk, ops)
      end
    rescue
      error ->
        ops = Map.new(chunk, &{&1.id, {:raised, error}})
        ack_jobs(conf, worker, job, chunk, ops, __STACKTRACE__)

        reraise error, __STACKTRACE__
    catch
      kind, reason ->
        error = CrashError.exception({kind, reason, __STACKTRACE__})
        ops = Map.new(chunk, &{&1.id, {:raised, error}})
        ack_jobs(conf, worker, job, chunk, ops, __STACKTRACE__)

        reraise error, __STACKTRACE__
    end
  end

  # This replicates the query used in Smart.fetch_jobs/3, without the meta tracking or any other
  # complications. Any modifications to the original query must be replicated here. Another
  defp fetch_chunk(conf, %{meta: %{"chunk_id" => chunk_id}} = job, limit) do
    subset_query =
      Job
      |> select([:id])
      |> where([j], j.state == "available")
      |> where([j], in_chunk(j.meta, ^chunk_id))
      |> order_by(asc: :priority, asc: :scheduled_at, asc: :id)
      |> limit(^limit)
      |> lock("FOR UPDATE SKIP LOCKED")

    query =
      Job
      |> with_cte("subset", as: ^subset_query)
      |> join(:inner, [j], x in fragment(~s("subset")), on: true)
      |> where([j, x], j.id == x.id)
      |> select([j, _], j)

    attempted_by = job.attempted_by ++ ["chunk-#{job.id}"]

    updates = [
      set: [state: "executing", attempted_at: DateTime.utc_now(), attempted_by: attempted_by],
      inc: [attempt: 1]
    ]

    Repo.transaction(conf, fn ->
      {_count, chunk} = Repo.update_all(conf, query, updates)

      chunk
    end)
  end

  defp prepare_chunk(chunk) do
    Enum.reduce(chunk, {[], []}, fn job, {acc, err} ->
      with {:ok, worker} <- Oban.Worker.from_string(job.worker),
           {:ok, job} <- Oban.Pro.Worker.before_hook(worker, job, worker.__opts__()) do
        {[job | acc], err}
      else
        {:error, reason} ->
          {acc, [{job, reason} | err]}
      end
    end)
  end

  # Guards

  # Only the leader job is registered as "running" by the queue producer. We listen for pkill
  # messages for _other_ jobs in the chunk and apply that to all jobs. Without this, cancelling
  # doesn't apply to the leader and chunk jobs are orphaned.
  defp guard_cancel(conf, worker, job, chunk) do
    # No supervision tree is running when draining, attempting to notify will raise an exception.
    if Process.get(:oban_draining) do
      :ok
    else
      parent = self()

      Task.start(fn ->
        ref = Process.monitor(parent)
        :ok = Notifier.listen(conf.name, [:signal])

        await_cancel(conf, ref, worker, job, chunk)
      end)
    end
  end

  defp await_cancel(conf, ref, worker, job, chunk) do
    receive do
      {:notification, :signal, %{"action" => "pkill", "job_id" => kill_id}} ->
        if Enum.any?(chunk, &(&1.id == kill_id)) do
          ops = Map.new(chunk, &{&1.id, {:cancel, :shutdown}})

          ack_jobs(conf, worker, job, chunk, ops)

          Notifier.notify(conf.name, :signal, %{action: "pkill", job_id: job.id})
        else
          await_cancel(conf, ref, worker, job, chunk)
        end

      {:DOWN, ^ref, :process, _pid, _reason} ->
        :ok
    end
  end

  # The executor uses :timer.exit_after/2 to kill jobs that exceed the timeout. The queue's
  # producer then catches the DOWN message and uses that to record a job error. The producer
  # isn't aware of the job's chunk, so we monitor the parent process and ack the chunk jobs.
  defp guard_timeout(_conf, :infinity, _worker, _job, _chunk), do: :ok

  defp guard_timeout(conf, timeout, worker, job, chunk) do
    parent = self()

    Task.start(fn ->
      ref = Process.monitor(parent)

      receive do
        {:DOWN, ^ref, :process, _pid, %Oban.TimeoutError{} = error} ->
          ops = Map.new(chunk, &{&1.id, {:raised, error}})

          ack_jobs(conf, worker, job, chunk, ops)

        {:DOWN, ^ref, :process, _pid, _reason} ->
          :ok
      after
        timeout + 100 -> :ok
      end
    end)
  end

  # Acking

  defp ack_jobs(conf, worker, host, chunk, ops, stacktrace \\ []) do
    now = DateTime.utc_now()

    to_ack = fn job ->
      set =
        case Map.get(ops, job.id) do
          nil ->
            %{state: "completed", completed_at: now}

          {:cancel, reason} ->
            error = format_error(job, worker, {:cancel, reason}, [])
            %{state: "cancelled", cancelled_at: now, error: error}

          {:discard, reason} ->
            error = format_error(job, worker, {:discard, reason}, [])
            %{state: "discarded", discarded_at: now, error: error}

          {:error, reason} when job.attempt >= job.max_attempts ->
            error = format_error(job, worker, {:discard, reason}, [])
            %{state: "discarded", discarded_at: now, error: error}

          {:error, reason} ->
            error = format_error(job, worker, {:error, reason}, [])
            %{state: "retryable", scheduled_at: backoff_at(worker, job), error: error}

          {:raised, error} when job.attempt >= job.max_attempts ->
            error = format_error(job, worker, error, stacktrace)
            %{state: "discarded", discarded_at: now, error: error}

          {:raised, error} ->
            error = format_error(job, worker, error, stacktrace)
            %{state: "retryable", scheduled_at: backoff_at(worker, job), error: error}

          {:snooze, period} ->
            scheduled_at = DateTime.add(now, Period.to_seconds(period), :second)
            %{state: "scheduled", scheduled_at: scheduled_at}
        end

      {{:ack, conf.name, job.queue, job.id}, nil, [], set}
    end

    acks =
      chunk
      |> Enum.reject(&(&1.id == host.id))
      |> Enum.map(to_ack)

    with {:ok, _} <- Smart.ack_jobs(acks, conf), do: Map.get(ops, host.id, :ok)
  end

  defp backoff_at(worker, job) do
    DateTime.add(utc_now(), worker.backoff(job), :second)
  end

  defp format_error(job, worker, reason, stacktrace) when is_tuple(reason) do
    format_error(job, worker, PerformError.exception({worker, reason}), stacktrace)
  end

  defp format_error(job, _worker, error, stacktrace) do
    {blamed, stacktrace} = Exception.blame(:error, error, stacktrace)

    formatted = Exception.format(:error, blamed, stacktrace)

    %{attempt: job.attempt, at: utc_now(), error: formatted}
  end
end
