defmodule Oban.Pro.Decorator do
  @moduledoc """
  The `Decorator` extension converts functions into Oban jobs with a simple annotation.

  Decorated functions, such as those in contexts or other non-worker modules, can be executed as
  fully fledged background jobs with retries, scheduling, and the other guarantees you'd expect
  from Oban jobs.

  ## Usage

  To get started, use the `Decorator` module, then annotate functions with the `@job` attribute.
  Here's a simple example that uses `@job true` to decorate a function without any options:

  ```elixir
  defmodule MyApp.Business do
    use Oban.Pro.Decorator

    @job true
    def weekly_report(tps_id, opts) do
      ...
    end
  end
  ```

  The `@job` attribute also accepts all standard `Oban.Worker` options, e.g. `max_attempts`,
  `priority`, `queue`. This example swaps out `@job true` for a list of options:

  ```elixir
  @job [max_attempts: 3, priority: 1, queue: :reports, recorded: true]
  def weekly_report(tps_id, opts) do
    ...
  end
  ```

  Now you can build and insert a job by calling `insert_weekly_report/2`:

  ```elixir
  {:ok, job} = MyApp.Business.insert_weekly_report(123, pdf: true, rtf: true)
  ```

  Notice that the second argument is a keyword list of options, which isn't normally allowed in
  job args because it's not JSON serializable.

  ## Generated Functions

  Functions decorated with `@job` generate three variants of the original function:

  * `new_*` — builds an `Oban.Job` changeset ready to be inserted for execution
  * `insert_*` — builds and inserts an `Oban.Job` using `Oban.insert/2`
  * `relay_*` — inserts a job, awaits execution, then returns the job's results

  See the `t:comp_opts/0` typespec for the subset of job options that are supported at compile
  time. Additional options are available at runtime, as described in the [Runtime
  Options](#module-runtime-options) section below.

  ### Using New

  The `new_` variant will build an `Oban.Job` changeset that's ready for insertion, but not
  persisted to the database. This is identical to the output from calling `c:Oban.Worker.new/2` on
  a standard worker.

  ```elixir
  changeset = Business.new_weekly_report(123)
  ```

  The returned changeset is perfect for bulk inserts via `Oban.insert_all/1`:

  ```elixir
  [123, 456, 789]
  |> Enum.map(&Business.new_weekly_report/1)
  |> Oban.insert_all()
  ```

  It can also be used to compose batches or workflows:

  ```elixir
  alias Oban.Pro.Workflow

  Workflow.new()
  |> Workflow.add(:rep_1, Business.new_weekly_report(1))
  |> Workflow.add(:rep_2, Business.new_weekly_report(2))
  |> Workflow.add(:rep_3, Business.new_weekly_report(3))
  |> Workflow.add(:fin, Business.new_finish_up(), deps: ~w(rep_1 rep_2 rep_3)a)
  |> Oban.insert_all()
  ```

  ### Using Insert

  The `insert_` variant builds a changeset and immediately calls `Oban.insert/3` to enqueue it.
  The result is a success tuple containing the job, or a changeset with errors.

  ```elixir
  {:ok, job} = Business.insert_weekly_report(123)
  ```

  By default, jobs are inserted using the standard `Oban` instance. For applications that run
  multiple Oban instances, or use a non-standard name, you can override the instance with the
  `:oban` option:

  ```elixir
  {:ok, job} = Business.insert_weekly_report(123, oban: SomeOban)
  ```

  ### Using Relay

  The `relay_` variant builds a changeset, inserts it, then leverages `Oban.Pro.Relay` to await
  execution and return a result:

  ```elixir
  {:ok, result} = Business.relay_weekly_report(123)
  ```

  The default timeout is a brief 5000ms, which doesn't account for scheduling or queueing time.
  Provide an alternate timeout to wait longer:

  ```elixir
  case Business.relay_weekly_report(123, timeout: 30_000) do
    {:ok, result} -> IO.inspect(result, label: "RESULT")
    {:error, reason} -> IO.inspect(reason, label: "ERROR")
  end
  ```

  Note that the `timeout` option only controls how long the local process will block while
  awaiting a result. The job will keep executing regardless of the timeout period.

  > #### Considerations and Caveats {: .info}
  >
  > Decorated functions are a convenient way to run functions in the background, and suitable in many
  > situations. However, there are circumstances where they're unsuitable and you should exercise
  > care:
  >
  > * Advanced worker functionality such as custom backoffs, hooks, structured args, or
  >   callbacks requires a dedicated worker module and isn't suitable for decoration.
  >
  > * Args of any type are safely serialized, but dumping large amounts of data may cause
  >   performance problems because it must be serialized, stored, and deserialized.
  >
  > * Changing function signatures while jobs are in-flight can cause jobs to fail, just like
  >   changing the shape of args passed to a `process/1` callback.

  ## Runtime Options

  Each decorated function has an additional generated clause that accepts job options, e.g.
  `new_weekly_report/1` also has a `new_weekly_report/2` variant.

  All compile time options can be overridden at runtime. For example, to override the `queue` and
  `max_attempts`:

  ```elixir
  Business.insert_weekly_report(123, queue: "default", max_attempts: 10)
  ```

  In addition to compile time options, runtime options accepted by `Oban.Job.new/2` (other than
  `worker`) are also allowed. This makes it possible to schedule decorated jobs:

  ```elixir
  Business.insert_weekly_report(123, schedule_in: {1, :minute})
  ```

  See `t:full_opts/0` for the complete typespec of runtime options.

  ## Patterns and Guards

  Each generated variant retains pattern matches and guards from the original function. That
  allows expressive, defensive data validation before a job executes.

  For example, use a guard to ensure the `id` argument is an integer:

  ```elixir
  @job true
  def notify_user(id) when is_integer(id), do: ...
  ```

  Or, pattern match on a map with a `user_id` key and ensure the `id` is an integer:

  ```elixir
  @job true
  def notify_user(%{user_id: id}) when is_integer(id), do: ...
  ```

  > #### Multiple Function Clauses Not Supported {: .warning}
  >
  > The `@job` decorator only works with single-clause functions. When decorating a function
  > with multiple clauses, only the first clause is captured. Subsequent clauses are ignored for
  > decoration, which will cause `FunctionClauseError` when calling the decorated variants.
  >
  > Instead of using multiple clauses, use a single function clause with pattern matching inside:
  >
  > ```diff
  > @job true
  > -def process_data(1, 2), do: :special_case
  > -def process_data(a, b), do: {:ok, a + b}
  > +def process_data(a, b) do
  > +  case {a, b} do
  > +    {1, 2} -> :special_case
  > +    {a, b} -> {:ok, a + b}
  > +  end
  > +end
  > ```

  ## Complex Types

  Any Elixir term may be passed as an argument, not just JSON encodable values. That enables
  passing native data-types such as tuples, keywords, or structs that can't easily be used in
  regular jobs.

  ```elixir
  @job true
  def add_discount(%User{id: _}, amount: 10_000), do: ...
  ```

  > #### Avoid Non-Portable Types {: .warning}
  >
  > Pass non-portable data types such as `pid`, `reference`, `port` with caution. There's no
  > guarantee that a job will run on the same node and those specific values available.
  > Furthermore, be careful passing anonymous functions because they are closures over the local
  > environment.

  ## Return Values

  Decorated jobs respect the same standard return types as `c:Oban.Pro.Worker.process/1`. That
  means you can return an `{:error, reason}` tuple to signify an error, or `{:cancel, reason}` to
  quietly cancel a job. However, because decorated functions weren't necessarily designed to be
  executed in a job, unexpected returns are considered a success.

  While there's no harm in returning `nil`, a struct, or some other non-standard value, it's best
  to return an explicitly support a type such as `:ok` or `{:ok, value}`.

  ```diff
   @job true
   def update_account(user_id, params) do
     user = Repo.get(User, user_id)

     do_update(user, params)

  -  user
  +  {:ok, user}
   end
  ```

  ## Unique and Replace

  Unique and replace options are available for decorated jobs. However, they have purposeful
  limitations for compatibility with the decorated worker:

  * `unique` — only supports `states`, `period`, and `timestamp` options. The `fields` and `keys`
     options aren't supported.
  * `replace` — expects the newer, per-state syntax, and it doesn't support replacing the `worker`
    or `args`.

  Both options can be defined at compilie in the `@job` annotation:

  ```elixir
  @job [unique: [period: :infinity], replace: [scheduled: [:scheduled_at]]]
  ```

  Or as runtime options:

  ```elixir
  Business.send_notice(user, schedule_in: 60, replace: [scheduled: [:scheduled_at]])
  ```

  ## Limiting Decoration

  To avoid generating unnecessary functions you can disable generating `new`, `insert`, or `relay`
  functions via passing flags to `use`:

  ```elixir
  use Oban.Pro.Decorator, new: false
  use Oban.Pro.Decorator, insert: false
  use Oban.Pro.Decorator, relay: false
  ```

  Filtering options can be combined to restrict generation to one variant. For example, to only
  generate `insert_*` functions:

  ```elixir
  use Oban.Pro.Decorator, new: false, relay: false
  ```

  ## Testing Decorated Jobs

  Testing decorated jobs is tricky because they're always enqueued with `Oban.Pro.Decorated` as
  the worker. The assert helpers `Oban.Pro.Testing` have a `:decorated` option specifically to
  make testing decorated jobs more convenient.

  Pass a captured function with the original arity to the `decorated` option:

  ```elixir
  assert_enqueued decorated: &Business.weekly_report/2
  ```

  Use a list of to assert on args (not a map, as you would for a standard job):

  ```elixir
  assert_enqueued args: [123, pdf: true], decorated: &Business.weekly_report/2
  ```

  ## Accessing the Decorated Job

  The `Decorator` module provides a convenient way to access the currently executing job through
  the `current_job/0` function. This can be useful when you need job context information during
  execution.

  In any module that uses `Oban.Pro.Decorator`, you can call the `current_job/0` function to
  retrieve the current job. This is particularly useful when you need to access job metadata as
  part of a workflow. For example:

  ```elixir
  defmodule MyApp.Doubler do
    use Oban.Pro.Decorator

    alias Oban.Pro.Workflow

    def invoke do
      Workflow.new()
      |> Workflow.add(:dbl_1, new_double(1))
      |> Workflow.add(:dbl_2, new_double(2))
      |> Workflow.add(:dbl_3, new_double(3))
      |> Workflow.add(:print, new_print(), deps: [:dbl_1, :dbl_2, :dbl_3])
      |> Oban.insert_all()
    end

    @job recorded: true
    def double(value), do: value * 2

    @job true
    def print do
      current_job()
      |> Workflow.all_recorded(only_deps: true)
      |> IO.inspect()
    end
  end
  ```

  If `current_job/0` is called outside of a job context (not during job execution), it returns
  `nil`.
  """

  use Oban.Pro.Worker

  alias __MODULE__, as: Decorator
  alias Oban.Pro.Relay
  alias Oban.Validation

  args_schema do
    field :mod, :string
    field :fun, :string
    field :arg, :term
  end

  @typedoc """
  Options allowed in `@job` annotations at compile time.
  """
  @type comp_opts :: [
          max_attempts: pos_integer(),
          oban: GenServer.name(),
          priority: 0..9,
          queue: atom() | binary(),
          recorded: boolean() | keyword(),
          replace: [Job.replace_by_state_option()],
          tags: Job.tags(),
          unique:
            true
            | [
                period: timeout(),
                states: atom() | [Job.unique_state()],
                timestamp: :inserted_at | :scheduled_at
              ]
        ]

  @typedoc """
  Options allowed at runtime when calling generated `new_`, `insert_`, or `relay_` functions.
  """
  @type full_opts :: [
          max_attempts: pos_integer(),
          meta: map(),
          oban: GenServer.name(),
          priority: 0..9,
          queue: atom() | binary(),
          recorded: boolean() | keyword(),
          replace: [Job.replace_by_state_option()],
          scheduled_at: DateTime.t(),
          schedule_in: Job.schedule_in_option(),
          tags: Job.tags(),
          timeout: timeout(),
          unique:
            true
            | [
                period: Job.unique_period(),
                states: Job.unique_state_group() | [Job.unique_state()],
                timestamp: Job.unique_timestamp()
              ]
        ]

  @doc false
  defmacro __using__(opts) do
    deco_opts = Keyword.merge([new: true, insert: true, relay: true], opts)

    quote bind_quoted: [deco_opts: deco_opts] do
      Module.put_attribute(__MODULE__, :oban_deco_opts, deco_opts)
      Module.register_attribute(__MODULE__, :job, accumulate: true)
      Module.register_attribute(__MODULE__, :oban_decorated, accumulate: true)

      @on_definition Oban.Pro.Decorator
      @before_compile Oban.Pro.Decorator
    end
  end

  @doc false
  defmacro __before_compile__(env) do
    gen_opts = Module.get_attribute(env.module, :oban_deco_opts)

    for {fun, orig_args, guards, opts} <- Module.get_attribute(env.module, :oban_decorated) do
      validate!(opts)

      {args, {_idx, renamed}} =
        Macro.prewalk(orig_args, {0, %{}}, fn
          {var, _meta, nil}, {idx, acc} ->
            ast = {:"arg#{idx}", [], __MODULE__}

            {ast, {idx + 1, Map.put(acc, var, ast)}}

          other, idx_acc ->
            {other, idx_acc}
        end)

      guards =
        case guards do
          [] ->
            quote(do: [is_boolean(true)])

          [_ | _] ->
            Macro.prewalk(guards, fn
              {var, _meta, nil} when is_map_key(renamed, var) -> Map.fetch!(renamed, var)
              other -> other
            end)
        end

      quote generated: true,
            bind_quoted: [
              new: :"new_#{fun}",
              ins: :"insert_#{fun}",
              rel: :"relay_#{fun}",
              gen_opts: gen_opts,
              fun: fun,
              mod: env.module,
              opts: opts,
              args: Macro.escape(args, unquote: true),
              args_opts: Macro.escape(args ++ [Macro.var(:oban_opts, nil)], unquote: true),
              guards: Macro.escape(guards, unquote: true)
            ] do
        def current_job do
          with {_module, job, _opts} <- Process.get(:oban_processing), do: job
        end

        if gen_opts[:new] do
          def unquote(new)(unquote_splicing(args)) when unquote_splicing(guards) do
            Decorator.new(unquote(mod), unquote(fun), unquote(args), unquote(opts))
          end

          def unquote(new)(unquote_splicing(args_opts)) when unquote_splicing(guards) do
            full_opts = Keyword.merge(unquote(opts), unquote(Macro.var(:oban_opts, nil)))

            Decorator.new(unquote(mod), unquote(fun), unquote(args), full_opts)
          end
        end

        if gen_opts[:insert] do
          def unquote(ins)(unquote_splicing(args)) when unquote_splicing(guards) do
            Decorator.insert(unquote(mod), unquote(fun), unquote(args), unquote(opts))
          end

          def unquote(ins)(unquote_splicing(args_opts)) when unquote_splicing(guards) do
            full_opts = Keyword.merge(unquote(opts), unquote(Macro.var(:oban_opts, nil)))

            Decorator.insert(unquote(mod), unquote(fun), unquote(args), full_opts)
          end
        end

        if gen_opts[:relay] do
          def unquote(rel)(unquote_splicing(args)) when unquote_splicing(guards) do
            Decorator.relay(unquote(mod), unquote(fun), unquote(args), unquote(opts))
          end

          def unquote(rel)(unquote_splicing(args_opts)) when unquote_splicing(guards) do
            full_opts = Keyword.merge(unquote(opts), unquote(Macro.var(:oban_opts, nil)))

            Decorator.relay(unquote(mod), unquote(fun), unquote(args), full_opts)
          end
        end
      end
    end
  end

  @doc false
  def __on_definition__(env, :def, fun, args, guards, _body) do
    with [opts] <- Module.get_attribute(env.module, :job) do
      opts = if is_list(opts), do: opts, else: []

      Module.put_attribute(env.module, :oban_decorated, {fun, List.wrap(args), guards, opts})
    end

    Module.delete_attribute(env.module, :job)
  end

  def __on_definition__(_env, _kind, _fun, _args, _guards, _body), do: :ok

  @doc false
  def new(mod, fun, arg, opts) when is_atom(mod) and is_atom(fun) and is_list(arg) do
    name = "#{inspect(mod)}.#{fun}/#{length(arg)}"
    meta = %{decorated: true, decorated_name: name}

    opts =
      opts
      |> Keyword.drop([:oban, :worker])
      |> Keyword.update(:meta, meta, &Map.merge(&1, meta))

    new(%{mod: inspect(mod), fun: to_string(fun), arg: arg}, opts)
  end

  @doc false
  def insert(mod, fun, arg, opts) do
    oban = Keyword.get(opts, :oban, Oban)

    mod
    |> new(fun, arg, opts)
    |> then(&Oban.insert(oban, &1))
  end

  @doc false
  def relay(mod, fun, arg, opts) do
    oban = Keyword.get(opts, :oban, Oban)
    {timeout, opts} = Keyword.pop(opts, :timeout, 5_000)

    mod
    |> new(fun, arg, opts)
    |> then(&Relay.async(oban, &1))
    |> Relay.await(timeout)
  end

  @impl Oban.Pro.Worker
  def process(%{args: %{mod: mod, fun: fun, arg: arg}}) do
    mod =
      mod
      |> String.split(".")
      |> Module.safe_concat()
      |> Code.ensure_loaded!()

    fun = String.to_existing_atom(fun)

    case apply(mod, fun, arg) do
      :ok -> :ok
      {:ok, _} = result -> result
      {:error, _} = error -> error
      {:snooze, _} = snooze -> snooze
      {:cancel, _} = cancel -> cancel
      other -> {:ok, other}
    end
  end

  defp validate!(opts) do
    Validation.validate_schema!(opts,
      max_attempts: :pos_integer,
      oban: :any,
      priority: {:range, 0..9},
      queue: {:or, [:atom, :string]},
      recorded: {:or, [:boolean, :list]},
      replace: {:custom, &Job.validate_replace/1},
      tags: {:list, :string},
      unique: {:custom, &validate_unique/1}
    )
  end

  defp validate_unique(unique) do
    cond do
      is_list(unique) and Keyword.has_key?(unique, :fields) ->
        {:error, "the :fields option isn't allowed for decorated jobs"}

      is_list(unique) and Keyword.has_key?(unique, :keys) ->
        {:error, "the :keys option isn't allowed for decorated jobs"}

      true ->
        Job.validate_unique(unique)
    end
  end
end
