defmodule Oban.Pro.Workflow do
  @moduledoc ~S"""
  Workflows compose jobs with flexible dependency relationships, enabling sequential execution,
  fan-out parallelization, and fan-in convergence patterns. By declaratively defining jobs and
  their connections, you can build complex processing pipelines that are both fault-tolerant and
  horizontally scalable across all nodes.

  ## Basic Usage

  Workflows are composed of regular jobs or decorated functions linked together through
  declarative dependencies. A workflow consists of these key elements:

  - **Jobs** - Individual units of work identified by unique names
  - **Dependencies** - Relationships specifying execution order

  Let's create a workflow using a decorated `echo/1` function that demonstrates how jobs execute
  in order:

  ```elixir
  defmodule EchoWorkflow do
    use Oban.Pro.Decorator

    alias Oban.Pro.Workflow

    def insert do
      Workflow.new()
      |> Workflow.add(:a, new_echo(1))
      |> Workflow.add(:b, new_echo(2), deps: :a)
      |> Workflow.add(:c, new_echo(3), deps: :b)
      |> Workflow.add(:d, new_echo(4), deps: :b)
      |> Workflow.add(:e, new_echo(5), deps: [:c, :d])
      |> Oban.insert_all()
    end

    @job true
    def echo(value), do: IO.inspect(value)
  end
  ```

  The workflow is initialized with `Workflow.new/1`, jobs are added with `Workflow.add/3`,
  connections between jobs are added with the `deps` option, and finally the workflow is inserted
  with `Oban.insert_all/1`.

  When executed, this workflow will print each job's `value` in the prescribed order. Steps `:c`
  and `:d` both depend on `:b`, so they may execute in parallel. Visually, the workflow looks like
  this:

  ![Simple Diagram](assets/simple-workflow.svg)

  ## Common Patterns

  Workflows support several execution patterns. These patterns form the building blocks of
  workflow design, allowing you to create both simple linear processes and complex dependency
  graphs. Each pattern serves a specific purpose in orchestrating job execution:

  > #### Example Workers {: .info}
  >
  > The following examples all use a generic `Worker` module to help focus on declaring
  > dependencies. The patterns apply to any type of worker module or decorated function.

  ### Sequential Execution

  Jobs run one after another, waiting for `scheduled` or `retryable` delays:

  ```elixir
  Workflow.new()
  |> Workflow.add(:first, new_job())
  |> Workflow.add(:second, Worker.new(%{}), deps: :first)
  |> Workflow.add(:third, Worker.new(%{}), deps: :second)
  ```

  ### Fan-Out (1-to-many)

  One job triggers multiple parallel jobs:

  ```elixir
  Workflow.new()
  |> Workflow.add(:parent, Worker.new(%{}))
  |> Workflow.add(:child_1, Worker.new(%{}), deps: :parent)
  |> Workflow.add(:child_2, Worker.new(%{}), deps: :parent)
  |> Workflow.add(:child_3, Worker.new(%{}), deps: :parent)
  ```

  ### Fan-In (many-to-1)

  Multiple parallel jobs converge to a single job:

  ```elixir
  Workflow.new()
  |> Workflow.add(:step_1, Worker.new(%{}))
  |> Workflow.add(:step_2, Worker.new(%{}))
  |> Workflow.add(:step_3, Worker.new(%{}))
  |> Workflow.add(:final, Worker.new(%{}), deps: [:step_1, :step_2, :step_3])
  ```

  ### Diamond Pattern

  Combines fan-out and fan-in:

  ```elixir
  Workflow.new()
  |> Workflow.add(:start, Worker.new(%{}))
  |> Workflow.add(:left, Worker.new(%{}), deps: :start)
  |> Workflow.add(:right, Worker.new(%{}), deps: :start)
  |> Workflow.add(:end, Worker.new(%{}), deps: [:left, :right])
  ```

  These patterns can be combined to create complex workflows tailored to your specific business
  processes.

  ## Advanced Patterns

  Once you're comfortable with basic workflows, you can leverage more advanced patterns to handle
  complex scenarios. These patterns enable shared state, modular composition, simplified
  dependencies, and dynamic generation of workflows.

  ### Sub-Workflows

  Workflows can be nested hierarchically using sub workflows. This allows you to compose complex
  workflows from simpler ones, making it easier to organize and reuse workflow patterns.

  The `add_workflow/4` function lets you add an entire workflow as a dependency of another
  workflow. Like `add/4`, it accepts a name and optional dependencies, but instead of a job
  changeset, it takes another workflow.

  Here's an example of creating a primary workflow and adding sub workflows with dependencies:

  ```elixir
  alias MyApp.{WorkerA, WorkerB, WorkerC}
  alias Oban.Pro.Workflow

  extr_flow =
    Workflow.new(workflow_name: "extract")
    |> Workflow.add(:extract, WorkerA.new(%{source: "database"}))
    |> Workflow.add(:transform, WorkerB.new(%{type: "normalize"}), deps: :extract)

  note_flow =
    Workflow.new(workflow_name: "notify")
    |> Workflow.add(:prepare, WorkerA.new(%{template: "report"}))
    |> Workflow.add(:send, WorkerB.new(%{method: "email"}), deps: :prepare)

  # Create the main workflow and add sub workflows to it
  Workflow.new()
  |> Workflow.add(:setup, WorkerC.new(%{mode: "initialize"}))
  |> Workflow.add_workflow(:extract, extr_flow, deps: :setup)
  |> Workflow.add_workflow(:notify, note_flow, deps: :extract)
  |> Workflow.add(:finalize, WorkerC.new(%{mode: "cleanup"}), deps: :notify)
  |> Oban.insert_all()
  ```

  In this example, the main workflow has a single job named `:setup`, followed by two sub
  workflows, and ends with a `:finalize` job. The dependencies ensure proper execution order:
  `setup -> extraction -> notification -> finalize`.

  Sub-workflows are a powerful pattern for:

  - Organizing large job dependencies into logical units
  - Enabling reuse across applications
  - Simplifying the maintenance of complex job graphs

  ### Sharing Results

  Directed dependencies between jobs, paired with the `recorded` option, allow downstream jobs to
  fetch the output of upstream jobs. This is particularly useful for multi-step processes where
  each step builds on previous results.

  Consider this workflow that simulates a multi-step API interaction:

  ```elixir
  defmodule MyApp.WorkerA do
    use Oban.Pro.Worker, recorded: true

    @impl true
    def process(%Job{args: %{"api_key" => api_key}}) do
      token =
        api_key
        |> String.graphemes()
        |> Enum.shuffle()
        |> to_string()

      {:ok, token}  # This return value will be recorded
    end
  end
  ```

  The second worker fetches the `token` from the first job by calling `get_recorded/2` with the name
  `:a`, which we'll set while building the workflow later.

  ```elixir
  defmodule MyApp.WorkerB do
    use Oban.Pro.Worker, recorded: true

    @impl true
    def process(%Job{args: %{"url" => url}} = job) do
      token = Oban.Pro.Workflow.get_recorded(job, :a)

      {:ok, {token, url}}
    end
  end
  ```

  Then the final worker uses `all_recorded/3` with the `only_deps` option to fetch the results
  from all upstream jobs, then it prints out everything that was fetched.

  ```elixir
  defmodule MyApp.WorkerC do
    use Oban.Pro.Worker

    @impl true
    def process(job) do
      job
      |> Oban.Pro.Workflow.all_recorded(only_deps: true)
      |> IO.inspect()

      :ok
    end
  end
  ```

  Now compose the workers together:

  ```elixir
  alias MyApp.{WorkerA, WorkerB, WorkerC}

  Workflow.new()
  |> Workflow.add(:a, WorkerA.new(%{api_key: "23kl239bjljlk309af"}))
  |> Workflow.add(:b, WorkerB.new(%{url: "elixir-lang.org"}), deps: [:a])
  |> Workflow.add(:c, WorkerB.new(%{url: "www.erlang.org"}), deps: [:a])
  |> Workflow.add(:d, WorkerB.new(%{url: "oban.pro"}), deps: [:a])
  |> Workflow.add(:e, WorkerC.new(%{}), deps: [:b, :c, :d])
  |> Oban.insert_all()
  ```

  When the workflow runs, the final step prints something like:

  ```elixir
  %{
    "b" => {"93l2jlj3kl90baf2k3", "elixir-lang.org"},
    "c" => {"93l2jlj3kl90baf2k3", "www.erlang.org"},
    "d" => {"93l2jlj3kl90baf2k3", "oban.pro"}
  }
  ```

  Sharing results between jobs is a powerful building block for processing pipelines. While the
  approach above works well, cascading functions provide an even more elegant way to share and
  transform data between workflow steps.

  ### Cascading Functions

  Cascade mode allows you to build workflows using function captures that automatically receive
  context and previous step results. Each function receives a map containing the workflow context
  and the results of its dependencies. Results from each step are recorded and made available to
  subsequent steps.

  Here's an ETL (Extract, Transform, Load) pipeline using cascading functions:

  ```elixir
  defmodule MyApp.ETL do
    def insert(source, date) do
      Workflow.new()
      |> Workflow.put_context(%{source: source, date: date})
      |> Workflow.add_cascade(:extract, &extract/1)
      |> Workflow.add_cascade(:transform, &transform/1, deps: :extract)
      |> Workflow.add_cascade(:load, &load/1, deps: :transform)
      |> Workflow.add_cascade(:notify, &notify/1, deps: [:transform, :load])
      |> Oban.insert_all()
    end

    def extract(%{source: source, date: date}) do
      if source =~ ~r/[a-z0-9]+/ do
        %{records: [1, 2, 3], extracted_at: DateTime.utc_now()}
      else
        {:cancel, "unprocessable source"}
      end
    end

    def transform(%{extract: %{records: records}}) do
      transformed = Enum.map(records, & &1 * 2)
      %{records: transformed, count: length(transformed)}
    end

    def load(%{transform: %{records: records}}) do
      %{loaded: true, count: length(records)}
    end

    def notify(%{transform: transform, load: load}) do
      IO.puts("Processed #{transform.count} records, loaded #{load.count}")
    end
  end
  ```

  When this workflow runs, each function is automatically called with a context map containing the
  shared context (source and date), and results from dependencies (accessible by their step
  names).

  There's no need to manually fetch recorded results as with standard workflow jobs! For standard
  workflows that need to fetch results from dependencies, including grafted sub-workflows, see
  `all_recorded/3`.

  Cascading functions are especially valuable for:

  - Data pipelines where each step needs the output of previous steps
  - Validating aggregate context before proceeding
  - Minimizing worker and function definition boilerplate

  ### Cascading Sub-Workflow

  Cascades can be added as sub-workflows that efficiently fan out workloads across multiple items.
  This works similar to `add_many/4`, but for cascading functions.

  To create a fan-out cascade, provide a tuple containing an enumerable (like a list, map, or
  range) and a function capture with arity 2. The function will be called once for each item in
  the enumerable, with the first argument receiving the current item and the second argument
  receiving the cumulative context.

  ```elixir
  defmodule MyApp.BatchETL do
    def insert(sources, date) do
      Workflow.new()
      |> Workflow.put_context(%{date: date})
      |> Workflow.add_cascade(:sources, {sources, &extract_source/2})
      |> Workflow.add_cascade(:transform, &transform_all/1, deps: :sources)
      |> Workflow.add_cascade(:load, &load_data/1, deps: :transform)
      |> Workflow.add_cascade(:notify, &send_notification/1, deps: [:transform, :load])
      |> Oban.insert_all()
    end

    # Each `source` from the list is passed as the first argument
    def extract_source(source, context) do
      %{source: source, records: [1, 2, 3], extracted_at: DateTime.utc_now()}
    end

    def transform_all(%{sources: sources}) do
      total_records = Enum.sum_by(sources, fn {_key, result} -> length(result.records) end)

      transformed =
        sources
        |> Enum.flat_map(fn {_key, %{records: records}} -> records end)
        |> Enum.map(&(&1 * 2))

      %{records: transformed, count: total_records}
    end

    def load_data(%{transform: %{records: records}}) do
      %{loaded: true, count: length(records)}
    end

    def send_notification(%{transform: transform, load: load, date: date}) do
      IO.puts("On #{date}, processed #{transform.count} records, loaded #{load.count}")
    end
  end
  ```

  This example processes multiple data sources in parallel, then consolidates the results in
  subsequent steps. The `extract_source/2` function is automatically called for each source in the
  provided list, with the results collected into a map.

  Cascading sub-workflows are useful for:

  - Processing collections of items in parallel with shared context
  - Building data pipelines that operate on multiple sources simultaneously
  - Implementing fan-out/fan-in patterns with minimal boilerplate
  - Maintaining clean separation between item processing and result aggregation

  ### Dynamic Workflows

  Many workflows aren't static—the number of jobs and their interdependencies aren't known
  beforehand. You can generate workflows dynamically based on runtime conditions.

  The following worker creates a workflow that fans-out and back in twice, using a variable number
  of dependencies:

  ```elixir
  defmodule MyApp.Dynamic do
    use Oban.Pro.Worker

    alias Oban.Pro.Workflow

    @impl true
    def process(%{meta: %{"name" => name}}) do
      IO.puts(name)
    end

    def insert_workflow(count) when is_integer(count) do
      range = Range.new(0, count)
      a_deps = Enum.map(range, &"a_#{&1}")
      b_deps = Enum.map(range, &"b_#{&1}")

      Workflow.new()
      |> Workflow.add(:a, new(%{}), [])
      |> fan_out(:a, range)
      |> Workflow.add(:b, new(%{}), deps: a_deps)
      |> fan_out(:b, range)
      |> Workflow.add(:c, new(%{}), deps: b_deps)
      |> Oban.insert_all()
    end

    defp fan_out(workflow, base, range) do
      Enum.reduce(range, workflow, fn key, acc ->
        Workflow.add(acc, "#{base}_#{key}", new(%{}), deps: [base])
      end)
    end
  end
  ```

  This approach is useful for:

  - Processing variable-sized collections
  - Creating workflows based on database queries
  - Building complex workflows from configuration
  - Implementing multi-tenant workflows with different requirements

  ### Grafting Sub-Workflows

  Grafting allows sub-workflows to be attached after a workflow has started. With grafting, you
  define placeholders that are expanded into full sub-workflows later when data becomes available.

  Graft jobs serve as placeholders in a workflow. When a graft job executes, it _must_ build
  and attach a new sub-workflow at that point. Any downstream jobs that depend on the grafter
  will wait for the entire grafted sub-workflow to complete.

  Here's an example workflow that takes an `account_id` then grafts on a sub-workflow to contact
  all users in the account:

  ```elixir
  defmodule MyApp.AccountNotifier do
    alias Oban.Pro.Workflow

    def insert(account_id) do
      Workflow.new()
      |> Workflow.put_context(%{account_id: account_id})
      |> Workflow.add_graft(:users, &graft_users/1)
      |> Workflow.add_cascade(:notify, &send_summary/1, deps: :users)
      |> Oban.insert_all()
    end

    def graft_users(%{account_id: account_id}) do
      user_ids = MyApp.Account.get_user_ids!(account_id)

      {user_ids, &send_notice/2}
      |> Workflow.apply_graft()
      |> Oban.insert_all()
    end

    def send_notice(user_id, context) do
      # Notify users
    end

    def send_summary(%{account_id: account_id, users: user_ids}) do
      # Deliver summary
    end
  end
  ```

  The summary job waits for the grafting job and the grafted workflow to complete before
  executing.

  This pattern is particularly useful for:

  - Building sub-workflows dynamically based on data discovered during execution
  - Conditionally executing different workflow branches
  - Managing resource-intensive sub-workflows

  ### Appending Jobs

  Sometimes all jobs aren't known when the workflow is created. In that case, you can add more
  jobs with optional dependency checking using `append/2`. An appended workflow starts with one or
  more jobs, which reuses the original `workflow_id`, and optionally builds a set of dependencies
  to check against.

  In this example we disable deps checking with `check_deps: false`:

  ```elixir
  def process(job) do
    jobs =
      job
      |> Workflow.append(check_deps: false)
      |> Workflow.add(:d, WorkerD.new(%{}), deps: [:a])
      |> Workflow.add(:e, WorkerE.new(%{}), deps: [:b])
      |> Workflow.add(:f, WorkerF.new(%{}), deps: [:c])
      |> Oban.insert_all()

    {:ok, jobs}
  end
  ```

  The new jobs specify deps on preexisting jobs named `:a`, `:b`, and `:c`, but there isn't any
  guarantee those jobs actually exist. That could lead to an incomplete workflow where the new
  jobs may never complete.

  To be safe and check jobs while appending we'll fetch all of the previous jobs with `all_jobs/3`
  and feed them in:

  ```elixir
  def process(job) do
    jobs = Workflow.all_jobs(job)

    jobs
    |> Workflow.append()
    |> Workflow.add(:d, WorkerD.new(%{}), deps: :a)
    |> Workflow.add(:e, WorkerE.new(%{}), deps: :b)
    |> Workflow.add(:f, WorkerF.new(%{}), deps: :c)
    |> Oban.insert_all()

    :ok
  end
  ```

  Now there isn't any risk of an incomplete workflow from missing dependencies, at the expense of
  loading some extraneous jobs.

  Appending jobs is particularly useful for:

  - Extending workflows based on user interaction or external events
  - Adding additional reporting or cleanup steps after seeing initial results
  - Breaking large workflows into stages that are built incrementally

  ## Customization

  Workflows provide several customization options to control their behavior, from identification
  to error handling. This section covers how to tailor workflows to your specific requirements.

  #### Workflow ID

  Every workflow needs a unique identifier. By default, `workflow_id` is a time-ordered, random
  [UUIDv7][uuid], which ensures uniqueness for any period. For more control, you can provide a
  custom ID:

  ```elixir
  Workflow.new(workflow_id: "custom-but-still-unique-id")
  ```

  [uuid]: https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format-04#section-5.2

  #### Workflow Name

  Additionally, workflows accept an optional name that describes their purpose:

  ```elixir
  Workflow.new(workflow_name: "nightly-etl")
  ```

  While the `workflow_id` must be unique, the `workflow_name` doesn't have to be, so it can serve
  as a general-purpose label for categorizing or grouping workflows.

  #### Context Key Format

  By default, cascade functions receive context with atom keys for top level dependencies.
  However, _nested_ dependencies will have string keys. To prevent this inconsistency you can opt
  in to atomizing string keys to atoms with the `atom_keys` option:

  * `atom_keys` — Atomize top level keys for cascade context. Defaults to `true`.

  Apply this option when creating a workflow:

  ```elixir
  Workflow.new(atom_keys: true)
  |> Workflow.add_cascade(:fetch, &MyApp.fetch/1)
  |> Workflow.add_cascade(:process, &MyApp.process/1, deps: :fetch)
  ```

  With `atom_keys: false`, the `process/1` function receives `%{"fetch" => result}` instead of
  `%{fetch: result}`.

  #### Dependency Handling

  By default, workflows use conservative dependency handling - if an upstream job is cancelled,
  discarded, or deleted, dependent jobs are automatically cancelled. You can customize this
  behavior with these options:

  * `ignore_cancelled` — Treat `cancelled` dependencies as completed rather than cancelling
    downstream jobs. Defaults to `false`.

  * `ignore_discarded` — Treat `discarded` dependencies as completed rather than cancelling
    downstream jobs. Defaults to `false`.

  * `ignore_deleted` — Treat `deleted` (typically pruned) dependencies as completed rather than
    cancelling downstream jobs. Defaults to `false`.

  Apply these options to the entire workflow:

  ```elixir
  Workflow.new(ignore_cancelled: true, ignore_deleted: true, ignore_discarded: true)
  ```

  Or configure individual jobs within a workflow:

  ```elixir
  Workflow.new()
  |> Workflow.add(:a, MyWorkflow.new(%{}))
  |> Workflow.add(:b, MyWorkflow.new(%{}), deps: :a, ignore_cancelled: true)
  |> Workflow.add(:c, MyWorkflow.new(%{}), deps: :b, ignore_discarded: true)
  |> Workflow.add(:d, MyWorkflow.new(%{}), deps: :c, ignore_deleted: true)
  ```

  > #### Preventing Stuck Workflows {: .tip}
  >
  >  Be sure that you're running the `DynamicLifeline` to rescue stuck workflows when upstream
  >  dependencies are deleted unexpectedly.
  >
  >  ```elixir
  >  config :my_app, Oban,
  >    plugins: [Oban.Pro.Plugins.DynamicLifeline],
  >    ...
  >  ```

  #### Cancellation Callbacks

  Workflow jobs are automatically `cancelled` when their upstream dependencies are `cancelled`,
  `discarded`, or `deleted` (unless specifically configured with the `ignore_*` options mentioned
  above). Since these workflow jobs are cancelled before execution, standard
  `c:Oban.Pro.Worker.after_process/3` hooks won't be called.

  Instead, you can implement the optional `c:after_cancelled/2` callback specifically for
  workflows:

  ```elixir
  defmodule MyApp.Workflow do
    use Oban.Pro.Worker

    @behaviour Oban.Pro.Workflow

    require Logger

    @impl Oban.Pro.Workflow
    def after_cancelled(reason, job) do
      Logger.warn("Workflow job #{job.id} cancelled because a dependency was #{reason}")

      # Perform any cleanup required
      notify_monitoring_system(job, reason)

      :ok
    end
  end
  ```

  The `reason` parameter will be one of:

  - `:cancelled` - A dependency was cancelled
  - `:discarded` - A dependency was discarded
  - `:deleted` - A dependency was deleted

  #### Handling Failures with Status Checking

  For situations where you can't use the `after_cancelled` callback, or when you have many
  different workers in a workflow, you can combine the `ignore_cancelled` option with status
  checking in a downstream job.

  This pattern allows a final "cleanup" job to examine the state of all upstream jobs and take
  appropriate action:

  ```elixir
  Workflow.new(ignore_cancelled: true)
  |> Workflow.add(:step_1, StepOne.new(%{}))
  |> Workflow.add(:step_2, StepTwo.new(%{}), deps: :step_1)
  |> Workflow.add(:step_3, StepThree.new(%{}), deps: :step_2)
  |> Workflow.add(:report, Reporter.new(%{}), deps: ~w(step_1 step_2 step_3))
  ```

  Then, in your final worker, examine the state of the upstream jobs using `status/1`:

  ```elixir
  defmodule MyApp.Reporter do
    use Oban.Pro.Worker

    @impl true
    def process(job) do
      status = Workflow.status(job)

      if status.counts.cancelled > 0 do
        # Some results were cancelled
      else
        :ok
      end
    end
  end
  ```

  This approach offers several advantages over hooks:

  - It works with any type of worker, including decorated functions and cascades
  - You can centralize error handling in a single place
  - The workflow continues to the reporting step even if some steps fail

  ## Introspection

  Workflow jobs are tied together through `meta` attributes. You can retrieve these jobs using
  several functions, each with different optimization characteristics.

  ### Fetching Jobs

  There are several functions for retrieving jobs, `get_job/2` and `all_jobs/3`, which are
  optimized for different use cases:

  ```elixir
  # Get a single specific job by name
  job_a = Workflow.get_job(job, :a)

  # Get all jobs in a workflow
  all_jobs = Workflow.all_jobs(job)

  # Get only the dependencies of the current job
  deps = Workflow.all_jobs(job, only_deps: true)

  # Get specific named jobs
  [job_a, job_b] = Workflow.all_jobs(job, names: [:a, :b])

  # Access from outside a worker using workflow_id
  jobs = Workflow.all_jobs("some-uuid-1234-5678")
  ```

  ### Streaming Jobs

  For large workflows where loading all jobs at once would consume too much memory, use
  `stream_jobs/3`:

  ```elixir
  def process(job) do
    {:ok, workflow_jobs} =
      MyApp.Repo.transaction(fn ->
        job
        |> Oban.Pro.Workflow.stream_jobs()
        |> Stream.filter(& &1.state == "completed")
        |> Enum.to_list()
      end)

    process_completed_jobs(workflow_jobs)

    :ok
  end
  ```

  > #### Transaction Required {: .info}
  >
  > Streaming is provided by Ecto's `Repo.stream`, and must take place within a transaction.
  > This approach lets you control the number of jobs loaded from the database, minimizing memory
  > usage for large workflows.

  ### Retrieving Results

  To fetch recorded output from workflow jobs, without loading the full job first, use
  `get_recorded/2` and `all_recorded/3`:

  ```elixir
  # Get results from a specific job
  result_a = Workflow.get_recorded(job, :a)

  # Get results from all jobs
  results = Workflow.all_recorded(job)

  # Get only results from dependencies
  dep_results = Workflow.all_recorded(job, only_deps: true)
  ```

  ### Checking Status

  To get detailed status information about a workflow, including the overall workflow state, job
  counts, time measurement, and sub-workflow details, use `status/1`:

  ```elixir
  status = Workflow.status(job)
  ```

  This outputs a map with details, somewhat like this:

  ```elixir
  %{
    id: "some-uuid-1234-5678",
    name: "my-workflow",
    total: 3,
    state: :completed,
    duration: 72,
    counts: %{
      cancelled: 0,
      available: 0,
      completed: 3,
      scheduled: 0,
      discarded: 0,
      executing: 0,
      retryable: 0
    },
    started_at: ~U[2025-04-10 16:34:34],
    stopped_at: ~U[2025-04-10 16:34:34],
    subs: %{}
  }
  ```

  ## Visualization

  Workflows are a type of a [Directed Acyclic Graph][dag], which means we can represent them
  visually as nodes (jobs) connected by edges (dependencies). By converting workflows into
  standard graph description languages, we can create clear visual representations of job
  execution patterns.

  ### Converting to Graph Formats

  Along with the general purpose `to_graph/1` that outputs a `digraph`, there are several built-in
  visualization options:

  * `to_dot/1` - Generates [GraphViz][gv] DOT format
  * `to_mermaid/1` - Produces [Mermaid][mermaid] flowchart markup

  For example, let's generate a mermaid flowchart from our account workflow:

  ```elixir
  Workflow.to_mermaid(archive_account_workflow(123))
  ```

  This produces the following mermaid output, where each vertex represents a job's name and the
  label shows the worker:

  ```text
  flowchart TD
      backup_1[MyApp.BackupPost] --> delete[MyApp.DeleteAccount]
      backup_2[MyApp.BackupPost] --> delete[MyApp.DeleteAccount]
      backup_3[MyApp.BackupPost] --> delete[MyApp.DeleteAccount]
      receipt[MyApp.FinalReceipt] --> backup_1[MyApp.BackupPost]
      receipt[MyApp.FinalReceipt] --> backup_2[MyApp.BackupPost]
      receipt[MyApp.FinalReceipt] --> backup_3[MyApp.BackupPost]
      receipt[MyApp.FinalReceipt] --> email_1[MyApp.EmailSubscriber]
      receipt[MyApp.FinalReceipt] --> email_2[MyApp.EmailSubscriber]
      email_1[MyApp.EmailSubscriber] --> delete[MyApp.DeleteAccount]
      email_2[MyApp.EmailSubscriber] --> delete[MyApp.DeleteAccount]
  ```

  ### Rendering Visualizations

  You can render these visualizations using the command line with a `mermaid` binary, directly in
  [LiveBook][live] using [Kino][kino], or in documentation sites.

  For LiveBook, you can pipe a workflow through `to_mermaid/1` and then into `Kino.Mermaid`:

  ```elixir
  workflow
  |> Workflow.to_mermaid()
  |> Kino.Mermaid.new()
  ```

  This generates an SVG diagram like this:

  ![Mermaid Diagram](assets/mermaid-workflow.svg)

  The visualization clearly shows how our workflow starts with a single `receipt` job, fans-out to
  multiple `email` and `backup` jobs, and finally fans-in to the `delete` job—making complex
  dependency relationships immediately apparent.

  [dag]: https://en.wikipedia.org/wiki/Directed_acyclic_graph
  [mermaid]: https://mermaid.js.org/
  [gv]: https://graphviz.org
  [live]: https://livebook.dev/
  [kino]: https://hexdocs.pm/kino/Kino.Mermaid.html
  """

  @behaviour Oban.Pro.Flusher
  @behaviour Oban.Pro.Handler

  import Ecto.Query

  alias Ecto.Changeset
  alias Oban.Pro.Stages.Chain
  alias Oban.Pro.Utils
  alias Oban.Pro.Workflow.Cascade
  alias Oban.{Config, Job, Repo, Validation}

  require Logger

  # Types

  @type add_opt ::
          {:deps, name() | [name()]}
          | {:ignore_cancelled, boolean()}
          | {:ignore_deleted, boolean()}
          | {:ignore_discarded, boolean()}

  @type add_opts :: [add_opt()]

  @type add_cascade_opts :: [Job.option() | add_opt()]

  @type add_workflow_opts :: [deps: name() | [name()]]

  @type append_opts :: [
          check_deps: boolean(),
          ignore_cancelled: boolean(),
          ignore_deleted: boolean(),
          ignore_discarded: boolean(),
          workflow_id: String.t(),
          workflow_name: String.t()
        ]

  @type cancel_opts :: [names: [name()]]

  @type cancel_reason :: :deleted | :discarded | :cancelled

  @type cascade_capture :: (map() -> any()) | {Enum.t(), (any(), map() -> any())}

  @type fetch_opts :: [
          log: Logger.level(),
          names: [name()],
          only_deps: boolean(),
          timeout: timeout(),
          with_subs: boolean()
        ]

  @type job_or_wid :: Job.t() | workflow_id()

  @type name :: atom() | String.t()

  @type new_opts :: [
          ignore_cancelled: boolean(),
          ignore_deleted: boolean(),
          ignore_discarded: boolean(),
          workflow_id: String.t(),
          workflow_name: String.t()
        ]

  @type status :: %{
          id: workflow_id(),
          name: String.t() | nil,
          total: non_neg_integer(),
          state: :executing | :completed | :cancelled | :discarded | :unknown,
          counts: %{atom() => non_neg_integer()},
          duration: non_neg_integer() | nil,
          started_at: DateTime.t() | nil,
          stopped_at: DateTime.t() | nil,
          subs: %{String.t() => map()}
        }

  @type t :: %__MODULE__{
          id: workflow_id(),
          changesets: [Job.changeset()],
          check_deps: boolean(),
          names: MapSet.t(),
          opts: map()
        }

  @type workflow_id :: String.t()

  # Callbacks

  @doc """
  Called after a workflow job is cancelled due to upstream jobs being `cancelled`, `deleted`, or
  `discarded`.

  This callback is _only_ called when a job is cancelled because of an upstream dependency. It is
  _never_ called after normal job execution. For that, use `c:Oban.Pro.Worker.after_process/3`.
  """
  @callback after_cancelled(cancel_reason(), job :: Job.t()) :: :ok

  @optional_callbacks after_cancelled: 2

  # Constants

  @all_states Enum.map(Job.states(), &to_string/1)
  @hold_date ~U[3000-01-01 00:00:00.000000Z]
  @incomplete_set MapSet.new(~w(available executing retryable scheduled))
  @chan_keys ~w(max_attempts meta priority queue schedule_in scheduled_at tags)a
  @work_keys ~w(ignore_cancelled ignore_deleted ignore_discarded workflow_id workflow_name)a

  # Macros

  defstruct [
    :id,
    changesets: [],
    check_deps: true,
    eliminations: %{},
    grafts: %{},
    names: MapSet.new(),
    opts: %{},
    subs: %{}
  ]

  defguardp is_job_or_wid(job_or_wid) when is_struct(job_or_wid, Job) or is_binary(job_or_wid)

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

  # This is a replacement for `push`, which uses `array_append` and isn't compatible with jsonb
  # arrays. The `||` operator works with both arrays and jsonb.
  defmacrop concat_errors(column, error) do
    quote do
      fragment("? || ?", unquote(column), unquote(error))
    end
  end

  defmacrop drop_hold(meta) do
    quote do
      fragment(~s(? || '{"on_hold":false}'), unquote(meta))
    end
  end

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

  defmacrop in_workflow_or_sup(meta, workflow_id) do
    quote do
      fragment(
        "? \\? 'workflow_id' AND (?->>'workflow_id' = ? OR ?->>'sup_workflow_id' = ?)",
        unquote(meta),
        unquote(meta),
        unquote(workflow_id),
        unquote(meta),
        unquote(workflow_id)
      )
    end
  end

  defmacrop in_held_workflow(meta, workflow_id) do
    quote do
      fragment(
        "? \\? 'workflow_id' AND ?->>'workflow_id' = ? AND ?->>'on_hold' = 'true'",
        unquote(meta),
        unquote(meta),
        unquote(workflow_id),
        unquote(meta)
      )
    end
  end

  defmacrop in_held_sup_workflow(meta, workflow_id) do
    quote do
      fragment(
        "? \\? 'sup_workflow_id' AND ?->>'sup_workflow_id' = ? AND ?->>'on_hold' = 'true'",
        unquote(meta),
        unquote(meta),
        unquote(workflow_id),
        unquote(meta)
      )
    end
  end

  defguardp is_legacy_scheduled(meta)
            when is_map_key(meta, "orig_scheduled_at") and
                   :erlang.map_get("orig_scheduled_at", meta) < 9_999_999_999

  # Telemetry

  @impl Oban.Pro.Handler
  def on_start do
    events = [
      [:oban, :engine, :cancel_all_jobs, :stop],
      [:oban, :plugin, :stop]
    ]

    :telemetry.attach_many("oban.workflow", events, &__MODULE__.handle_event/4, nil)
  end

  @impl Oban.Pro.Handler
  def on_stop do
    :telemetry.detach("oban.workflow")
  end

  @doc false
  def handle_event([:oban, :plugin, _], _time, %{conf: conf, discarded_jobs: jobs}, nil) do
    for %{meta: %{"workflow_id" => workflow_id}} <- jobs do
      on_flush(workflow_id, conf)
    end
  end

  def handle_event([:oban, :engine, _, _], _time, %{conf: conf, jobs: jobs}, nil) do
    for %{meta: %{"workflow_id" => workflow_id}} <- jobs do
      on_flush(workflow_id, conf)
    end
  end

  def handle_event(_event, _time, _meta, _conf), do: :ok

  # Handling

  @doc false
  @impl Oban.Pro.Flusher
  def to_flush_mfa(job, conf) do
    case job.meta do
      %{"ancestor_ids" => ancestors, "workflow_id" => workflow_id} ->
        {__MODULE__, :on_flush, [Enum.uniq([workflow_id | ancestors]), conf]}

      %{"sup_workflow_id" => sup_workflow_id, "workflow_id" => workflow_id} ->
        {__MODULE__, :on_flush, [[sup_workflow_id, workflow_id], conf]}

      %{"on_hold" => _, "workflow_id" => workflow_id} ->
        {__MODULE__, :on_flush, [workflow_id, conf]}

      _ ->
        :ignore
    end
  end

  @doc false
  def on_flush(workflow_id, conf) when is_binary(workflow_id) do
    # The internal, external, and wildcard deps must be queried independently in order for the
    # planner to utilize indexes properly.
    lateral_query =
      Job
      |> select([j], j.state)
      |> where(
        [j],
        # This uses a hardcoded reference to the `sf0` alias for the `parent_as(:elem)` source.
        # This is to avoid using `parent_as(:elem).value`, which is valid syntax but
        # incompatible with how CRDB handles jsonb references. Ecto requires a field reference
        # on any source currently, and this is a workaround.
        fragment("? \\? 'workflow_id'", j.meta) and
          fragment(
            """
            (
              (jsonb_typeof(sf0) = 'string' AND ?->>'workflow_id' = ?->>'workflow_id' AND ?->>'name' = sf0 #>>'{}') OR
              (jsonb_typeof(sf0) = 'array' AND sf0->>1 <> '*' AND ?->>'workflow_id' = sf0->>0 AND ?->>'name' = sf0->>1) OR
              (jsonb_typeof(sf0) = 'array' AND sf0->>1 = '*' AND ?->>'workflow_id' = sf0->>0) OR
              (jsonb_typeof(sf0) = 'array' AND sf0->>1 = '*' AND ? \\? 'sup_workflow_id' AND ?->>'sup_workflow_id' = sf0->>0)
            )
            """,
            j.meta,
            parent_as(:jobs).meta,
            j.meta,
            j.meta,
            j.meta,
            j.meta,
            j.meta,
            j.meta
          )
      )

    deps_query =
      from v in fragment("jsonb_array_elements(?->'deps')", parent_as(:jobs).meta),
        as: :elem,
        inner_lateral_join: j in subquery(lateral_query),
        on: true,
        select: fragment("coalesce(array_agg(?), '{}')", j.state)

    # A complete set of conditions must be used for held workflows and sub workflows. The planner
    # won't use the correct indexes when an "OR" is used.
    query =
      from(j in Job, as: :jobs)
      |> select([j], {j, subquery(deps_query)})
      |> lock("FOR UPDATE SKIP LOCKED")
      |> where(
        [j],
        (j.state == "scheduled" and in_held_workflow(j.meta, ^workflow_id)) or
          (j.state == "scheduled" and in_held_sup_workflow(j.meta, ^workflow_id))
      )

    conf
    |> Repo.all(query)
    |> Enum.map(&to_stage_operation/1)
    |> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
    |> Enum.map(&apply_operations(&1, conf))
    |> on_flush(conf)
  end

  def on_flush(workflow_ids, conf) when is_list(workflow_ids) do
    workflow_ids
    |> List.flatten()
    |> Enum.each(&on_flush(&1, conf))
  end

  @doc """
  Instantiate a new workflow struct with a unique workflow id.

  ## Examples

  Create a standard workflow without any options:

      Workflow.new()

  Create a workflow with a custom name:

      Workflow.new(workflow_name: "logistics")

  Create a workflow with a static id and some options:

      Workflow.new(workflow_id: "workflow-id", ignore_cancelled: true, ignore_discarded: true)
  """
  @spec new(opts :: new_opts()) :: t()
  def new(opts \\ []) do
    validate!(opts)

    opts =
      opts
      |> Keyword.put(:workflow, true)
      |> Keyword.put_new_lazy(:workflow_id, &Oban.Pro.UUIDv7.generate/0)
      |> Map.new()

    %__MODULE__{id: opts.workflow_id, opts: opts}
  end

  defp validate!(opts) do
    Validation.validate_schema!(opts,
      atom_keys: :boolean,
      ignore_cancelled: :boolean,
      ignore_deleted: :boolean,
      ignore_discarded: :boolean,
      workflow_id: :string,
      workflow_name: :string
    )
  end

  @doc """
  Add a named job to the workflow along with optional dependencies.

  ## Examples

  Add jobs to a workflow with dependencies:

      Workflow.new()
      |> Workflow.add(:a, WorkerA.new(%{id: id}))
      |> Workflow.add(:b, WorkerB.new(%{id: id}), deps: :a)
      |> Workflow.add(:c, WorkerC.new(%{id: id}), deps: :a)
      |> Workflow.add(:d, WorkerC.new(%{id: id}), deps: [:b, :c])
  """
  @doc since: "1.5.0"
  @spec add(t(), name(), Job.changeset(), add_opts()) :: t()
  def add(%__MODULE__{} = workflow, name, changeset, opts \\ []) when is_name(name) do
    {deps, opts} = Keyword.pop(opts, :deps, [])

    deps = expand_deps(deps, workflow)
    name = to_string(name)

    prevent_chain!(changeset, name)
    prevent_dupe!(workflow, name)
    confirm_deps!(workflow, deps)

    meta =
      changeset
      |> Changeset.get_change(:meta, %{})
      |> Map.put(:deps, deps)
      |> Map.put(:name, name)
      |> Map.put(:on_hold, Enum.any?(deps))
      |> Map.put(:workflow_id, workflow.id)
      |> Map.merge(workflow.opts)
      |> Map.merge(Map.new(opts))

    meta =
      if Changeset.get_field(changeset, :state) == "scheduled" do
        orig_at =
          changeset
          |> Changeset.get_change(:scheduled_at)
          |> DateTime.to_unix(:microsecond)

        Map.put(meta, :orig_scheduled_at, orig_at)
      else
        meta
      end

    changeset =
      if Enum.any?(deps) do
        changeset
        |> Changeset.put_change(:meta, meta)
        |> Changeset.put_change(:state, "scheduled")
        |> Changeset.put_change(:scheduled_at, @hold_date)
      else
        Changeset.put_change(changeset, :meta, meta)
      end

    changesets = workflow.changesets ++ [changeset]

    %{workflow | changesets: changesets, names: MapSet.put(workflow.names, name)}
  end

  defp expand_deps(deps, workflow, mode \\ :main) do
    deps
    |> List.wrap()
    |> Enum.reduce([], fn name, acc ->
      name = to_string(name)

      cond do
        is_map_key(workflow.grafts, name) ->
          grafter_name = "grafter_#{name}"

          grafter_dep =
            if is_map_key(workflow.subs, grafter_name) do
              [Map.get(workflow.subs, grafter_name), "*"]
            else
              grafter_name
            end

          [grafter_dep | [[Map.get(workflow.subs, name), "*"] | acc]]

        is_map_key(workflow.eliminations, name) ->
          Map.get(workflow.eliminations, name) ++ acc

        is_map_key(workflow.subs, name) ->
          [[Map.get(workflow.subs, name), "*"] | acc]

        mode == :sub ->
          [[workflow.id, name] | acc]

        true ->
          [name | acc]
      end
    end)
    |> Enum.reverse()
  end

  @doc """
  Add a cascading function to the workflow with optional dependencies.

  Cascading functions receive a context map containing the workflow's shared context and any
  results from their upstream dependencies. Results of the function are automatically recorded,
  unless they're one of the following standard control tuples:

  - `{:cancel, reason}`
  - `{:error, reason}`
  - `{:snooze, seconds}`

  > #### Function Captures Only {: .warning}
  >
  > Cascading functions _must_ be captures like `&MyApp.foo/1` for external functions or `&foo/1`
  > for local functions. Anonymous functions won't work. The function always receives a single
  > map parameter containing all context and previous results.

  ## Cascading Sub-Workflow

  Cascades can be added as sub-workflows that efficiently fan out workloads across multiple items.
  This works similar to `add_many/4`, but for cascading functions.

  To create a fan-out cascade, provide a tuple containing an enumerable (like a list, map, or
  range) and a function capture with arity 2. The function will be called once for each item in
  the enumerable, with the first argument receiving the current item and the second argument
  receiving the cumulative context.

  ```elixir
  user_ids = [101, 202, 303]

  Workflow.new()
  |> Workflow.put_context(%{operation: "sync"})
  |> Workflow.add_cascade(:users, {user_ids, &MyApp.sync_user/2})
  |> Workflow.add_cascade(:notify, &MyApp.send_summary/1, deps: :users)

  def sync_user(user_id, context) do
    # ...
  end
  ```

  ## Options

  A subset of standard job options can be passed directly to customize the cascade jobs. The
  available options are:

  * `:max_attempts`
  * `:meta`
  * `:priority`
  * `:queue`
  * `:schedule_in`
  * `:scheduled_at`
  * `:tags`

  Pass the additional options alongside `deps` or other `add/4` workflow options.

  ## Examples

  Add a cascading function to a workflow:

      Workflow.new()
      |> Workflow.put_context(%{user_id: 123})
      |> Workflow.add_cascade(:activate, &MyApp.activate/1)
      |> Workflow.add_cascade(:notify, &MyApp.notify/1, deps: :activate)
      |> Oban.insert_all()

  Customize the cascade job:

      Workflow.new()
      |> Workflow.add_cascade(:activate, &MyApp.activate/1, queue: :activation)
      |> Workflow.add_cascade(:retrofit, &MyApp.retrofit/1, max_attempts: 3)

  Add cascade steps from a range:

      Workflow.new()
      |> Workflow.put_context(%{user_id: 123})
      |> Workflow.add_cascade(:activations, {1..5, &MyApp.activate/2})

  Add cascade steps from a map, using the map keys as custom names:

      accounts = %{admin: 1, user: 2, guest: 3}

      Workflow.new()
      |> Workflow.put_context(%{action: "sync"})
      |> Workflow.add_cascade(:accounts, {accounts, &MyApp.sync_account/2})
  """
  @doc since: "1.6.0"
  @spec add_cascade(t(), name(), cascade_capture(), add_cascade_opts()) :: t()
  def add_cascade(workflow, name, fun, opts \\ [])

  def add_cascade(%__MODULE__{} = workflow, name, fun, opts) when is_function(fun) do
    {changeset, opts} = build_cascade(fun, opts)

    add(workflow, name, changeset, opts)
  end

  def add_cascade(%__MODULE__{} = workflow, name, {items, fun}, opts)
      when is_name(name) and is_function(fun, 2) do
    info = Function.info(fun)

    cond do
      info[:arity] != 2 ->
        raise ArgumentError, "context function must have arity of 2"

      info[:module] == :erl_eval ->
        raise ArgumentError, "context function must be a capture and not anonymous"

      true ->
        argf = &%{arg: &1, mod: inspect(info[:module]), fun: to_string(info[:name])}
        decn = "#{inspect(info[:module])}.#{info[:name]}/#{info[:arity]}"
        meta = %{decorated: true, decorated_name: decn}

        {chan_opts, opts} = Keyword.split(opts, @chan_keys)
        {work_opts, opts} = Keyword.split(opts, @work_keys)

        chan_opts = Keyword.update(chan_opts, :meta, meta, &Map.merge(&1, meta))

        sub =
          items
          |> with_names()
          |> Enum.reduce(new(work_opts), fn {key, item}, acc ->
            add(acc, to_string(key), Cascade.new(argf.(item), chan_opts))
          end)

        add_workflow(workflow, name, sub, opts)
    end
  end

  @doc """
  Adds a grafting point to the workflow where sub-workflows can be attached later.

  A grafter serves as a placeholder in the workflow topology that downstream jobs can
  depend upon. Later, another job can dynamically generate a sub-workflow and attach it
  at this grafting point using `graft/3`.

  The grafter can be defined using either a job changeset or a cascading function.

  ## Examples

  Add a grafting point with a job changeset:

      Workflow.new()
      |> Workflow.add_graft(:users, MyWorker.new(%{}))
      |> Workflow.add(:notify, WorkerC.new(%{}), deps: :users)

  Add a grafting point with a cascading function:

      Workflow.new()
      |> Workflow.put_context(%{account_id: account_id})
      |> Workflow.add_graft(:users, &build_users_workflow/1)
      |> Workflow.add_cascade(:notify, &send_summary/1, deps: :users)

  Add multiple grafting points with a fan-out tuple:

      Workflow.new()
      |> Workflow.add_graft(:accounts, {account_ids, &sync_account/2})
      |> Workflow.add(:finalize, FinalizerWorker.new(%{}), deps: :accounts)

  Each function in a fan-out graft receives the item and context, and should call
  `apply_graft/1` to dynamically create a sub-workflow:

      def sync_account(account_id, _context) do
        {user_ids(account_id), &sync_user/2}
        |> Workflow.apply_graft()
        |> Oban.insert_all()

        :ok
      end
  """
  @doc since: "1.6.0"
  @spec add_graft(t(), name(), Job.changeset() | cascade_capture(), add_opts()) :: t()
  def add_graft(workflow, name, changeset_or_fun, opts \\ [])

  def add_graft(%__MODULE__{} = workflow, name, fun, opts) when is_function(fun) do
    {changeset, opts} = build_cascade(fun, opts)

    add_graft(workflow, name, changeset, opts)
  end

  def add_graft(%__MODULE__{} = workflow, name, {_items, fun} = cascade, opts)
      when is_name(name) and is_function(fun, 2) do
    name = to_string(name)
    uuid = "grafted_#{Oban.Pro.UUIDv7.generate()}"
    grafter_name = "grafter_#{name}"

    graft_meta = %{graft_id: uuid, graft_name: name}
    opts = Keyword.update(opts, :meta, graft_meta, &Map.merge(&1, graft_meta))

    workflow = %{
      workflow
      | grafts: Map.put(workflow.grafts, name, grafter_name),
        subs: Map.put(workflow.subs, name, uuid)
    }

    add_cascade(workflow, grafter_name, cascade, opts)
  end

  def add_graft(%__MODULE__{} = workflow, name, changeset, opts) when is_name(name) do
    name = to_string(name)
    uuid = "grafted_#{Oban.Pro.UUIDv7.generate()}"
    grafter_name = "grafter_#{name}"

    meta =
      changeset
      |> Changeset.get_change(:meta, %{})
      |> Map.put(:graft_id, uuid)
      |> Map.put(:graft_name, name)

    changeset = Changeset.put_change(changeset, :meta, meta)

    workflow =
      workflow
      |> Map.update!(:grafts, &Map.put(&1, name, grafter_name))
      |> Map.update!(:subs, &Map.put(&1, name, uuid))

    add(workflow, grafter_name, changeset, opts)
  end

  defp build_cascade(fun, opts) do
    info = Function.info(fun)

    cond do
      info[:arity] != 1 ->
        raise ArgumentError, "context function must have arity 1"

      info[:module] == :erl_eval ->
        raise ArgumentError, "context function must be a capture and not anonymous"

      true ->
        args = %{mod: inspect(info[:module]), fun: to_string(info[:name])}
        decn = "#{inspect(info[:module])}.#{info[:name]}/#{info[:arity]}"
        meta = %{decorated: true, decorated_name: decn}

        {chan_opts, opts} =
          opts
          |> Keyword.update(:meta, meta, &Map.merge(&1, meta))
          |> Keyword.split(@chan_keys)

        {Cascade.new(args, chan_opts), opts}
    end
  end

  @doc """
  Add multiple named jobs as a sub workflow along with optional dependencies.

  ## Empty Sub-Workflows

  When an empty collection of jobs is provided, the sub-workflow step is skipped entirely.
  Any dependencies on this step are automatically transferred to downstream steps to maintain
  the correct execution order.

  Empty sub-workflows have no job results to retrieve or cascade outputs to reference. Consider
  this when creating dependencies on sub-workflows that might be empty at runtime.

  ## Examples

  Add multiple jobs as a sub workflow with a list, where the keys will be the job's position,
  starting at 0:

      Workflow.add_many(workflow, :sub_1, [WorkerA.new(%{}), WorkerB.new(%{})])

  Add multiple jobs as a sub workflow with a map, where the keys are the job names:

      Workflow.add_many(workflow, :sub_1, %{a: WorkerA.new(%{}), b: WorkerB.new(%{})})

  Add multiple jobs as sub workflows and add a dependency on them:

      Workflow.new()
      |> Workflow.add_many(:a, changesets_a)
      |> Workflow.add_many(:b, changesets_b)
      |> Workflow.add(:c, WorkerC.new(%{}), deps: [:a, :b])

  Workflow options pay be provided to name or otherwise customize the sub-workflow:

      Workflow.add_many(workflow, :sub_1, changesets, ignore_cancelled: true)
  """
  @doc since: "1.6.0"
  @spec add_many(t(), name(), Enum.t(), new_opts() | add_opts()) :: t()
  def add_many(%__MODULE__{} = workflow, name, changesets, opts \\ []) when is_name(name) do
    {work_opts, opts} = Keyword.split(opts, @work_keys)

    sub =
      changesets
      |> with_names()
      |> Enum.reduce(new(work_opts), fn {key, chg}, acc -> add(acc, to_string(key), chg) end)

    add_workflow(workflow, name, sub, opts)
  end

  defp with_names(items) when is_list(items), do: Enum.with_index(items, &{&2, &1})
  defp with_names(items) when is_struct(items), do: items |> Enum.to_list() |> with_names()
  defp with_names(items) when is_map(items), do: items

  @doc """
  Add a sub workflow with a name and optional dependencies to another workflow.

  This function lets you compose workflows by adding one workflow as a dependency within another
  workflow. Workflows may depend on jobs or other workflows, and jobs may depend on entire
  workflows. If a job depends on a workflow, it won't execute until every job in the workflow is
  in a final state (completed, cancelled, or discarded).

  ## Examples

  Add a sub workflow to a workflow with dependencies:

      sub_1 =
        Workflow.new()
        |> Workflow.add(:a, WorkerB.new(%{id: id}))
        |> Workflow.add(:b, WorkerC.new(%{id: id}))

      sub_2 =
        Workflow.new()
        |> Workflow.add(:a, WorkerB.new(%{id: id}))
        |> Workflow.add(:b, WorkerC.new(%{id: id}))

      Workflow.new()
      |> Workflow.add(:a, WorkerA.new(%{id: id}))
      |> Workflow.add_workflow(:b, sub_1, deps: :a)
      |> Workflow.add_workflow(:c, sub_2, deps: :b)
      |> Workflow.add(:d, WorkerD.new(%{id: id}), deps: [:b, :c])
  """
  @doc since: "1.6.0"
  @spec add_workflow(t(), name(), t(), add_workflow_opts()) :: t()
  def add_workflow(%_{} = workflow, name, %_{} = sub_workflow, opts \\ []) when is_name(name) do
    {deps, opts} = Keyword.pop(opts, :deps, [])

    deps = expand_deps(deps, workflow, :sub)
    name = to_string(name)

    prevent_dupe!(workflow, name)
    confirm_deps!(workflow, deps)

    sub_changesets =
      Enum.map(sub_workflow.changesets, fn changeset ->
        meta_deps = get_in(changeset.changes, [:meta, :deps])

        if deps != [] and meta_deps == [] do
          old_ancestors = get_in(changeset.changes, [:meta, :ancestor_ids]) || []
          new_ancestors = Map.get(workflow.opts, :ancestor_ids, [])
          all_ancestors = old_ancestors ++ [workflow.id] ++ new_ancestors

          opts
          |> Map.new()
          |> Map.put(:deps, deps)
          |> Map.put(:sup_workflow_id, workflow.id)
          |> Map.put(:sub_name, name)
          |> Map.put(:workflow_id, sub_workflow.id)
          |> Map.put(:ancestor_ids, all_ancestors)
          |> put_hold(changeset)
        else
          new_ancestors = Map.get(workflow.opts, :ancestor_ids, [])

          meta =
            if get_in(changeset.changes, [:meta, :sup_workflow_id]) do
              old_ancestors = get_in(changeset.changes, [:meta, :ancestor_ids]) || []
              all_ancestors = old_ancestors ++ [workflow.id] ++ new_ancestors

              %{sup_workflow_id: workflow.id, ancestor_ids: all_ancestors}
            else
              %{
                sub_name: name,
                sup_workflow_id: workflow.id,
                workflow_id: sub_workflow.id,
                ancestor_ids: [workflow.id] ++ new_ancestors
              }
            end

          Changeset.update_change(changeset, :meta, &Map.merge(&1, meta))
        end
      end)

    subs = Map.put(workflow.subs, name, sub_workflow.id)

    case sub_changesets do
      [_ | _] ->
        changesets = workflow.changesets ++ sub_changesets

        %{workflow | changesets: changesets, subs: subs}

      [] ->
        eliminations = Map.put(workflow.eliminations, name, deps)

        %{workflow | eliminations: eliminations, subs: subs}
    end
  end

  defp put_hold(meta, changeset) do
    meta =
      if Changeset.get_field(changeset, :state) == "scheduled" do
        orig_at =
          changeset
          |> Changeset.get_change(:scheduled_at)
          |> DateTime.to_unix(:microsecond)

        meta
        |> Map.put(:on_hold, true)
        |> Map.put(:orig_scheduled_at, orig_at)
      else
        Map.put(meta, :on_hold, true)
      end

    changeset
    |> Changeset.update_change(:meta, &Map.merge(&1, meta))
    |> Changeset.put_change(:state, "scheduled")
    |> Changeset.put_change(:scheduled_at, @hold_date)
  end

  @doc """
  Adds a context value to the workflow that can be accessed by all jobs.

  This is a convenience function that creates a recorded job named `:context` containing
  any value that needs to be shared across multiple workflow jobs.

  ## Examples

  Add a context value to a workflow:

      Workflow.new()
      |> Workflow.put_context(%{user_id: 123, action: "process"})
      |> Workflow.add(:job_a, WorkerA.new(%{}))
      |> Workflow.add(:job_b, WorkerB.new(%{}))
      |> Oban.insert_all()

  Later, retrieve the context in a worker:

      def process(job) do
        context = Workflow.get_context(job)
        # Use context map...
      end
  """
  @doc since: "1.6.0"
  @spec put_context(t(), map()) :: t()
  def put_context(%__MODULE__{} = workflow, value) when is_map(value) do
    opts = [
      meta: %{context: true, recorded: true, return: Utils.encode64(value)},
      worker: Oban.Pro.Workers.Context
    ]

    changeset =
      %{}
      |> Job.new(opts)
      |> Changeset.put_change(:state, "completed")
      |> Changeset.put_change(:completed_at, DateTime.utc_now())

    add(workflow, :context, changeset)
  end

  @doc """
  Gets a workflow's context value.

  This is a convenience function that internally calls `get_recorded/2` with the `:context` name.

  ## Examples

  Retrieve the context within a worker:

      def process(job) do
        context = Workflow.get_context(job)
      end

  Retrieve the context with a workflow id:

      Workflow.get_context("some-uuid-1234-5678")

  Retrieve the context using a custom Oban instance name:

      Workflow.get_context(MyApp.Oban, "some-uuid-1234-5678")
  """
  @doc since: "1.6.0"
  @spec get_context(Oban.name(), job_or_wid()) :: nil | map()
  def get_context(oban_name \\ Oban, job_or_wid) do
    get_recorded(oban_name, job_or_wid, :context)
  end

  @doc """
  Instantiate a new workflow from one or more existing workflow jobs.

  ## Examples

  Append to a workflow seeded with all other jobs in the workflow:

      jobs
      |> Workflow.append()
      |> Workflow.add(:d, WorkerD.new(%{}), deps: [:a])
      |> Workflow.add(:e, WorkerE.new(%{}), deps: [:b])
      |> Oban.insert_all()

  Append to a workflow from a single job and bypass checking deps:

      job
      |> Workflow.append(check_deps: false)
      |> Workflow.add(:d, WorkerD.new(%{}), deps: [:a])
      |> Workflow.add(:e, WorkerE.new(%{}), deps: [:b])
      |> Oban.insert_all()
  """
  @doc since: "1.5.0"
  @spec append(Job.t() | [Job.t()], append_opts()) :: t()
  def append(jobs, opts \\ [])

  def append([%Job{meta: %{"workflow_id" => _} = meta} | _] = jobs, opts) do
    {check, opts} = Keyword.pop(opts, :check_deps, true)

    workflow_opts =
      meta
      |> Map.take(~w(workflow_id workflow_name))
      |> Keyword.new(fn {key, val} -> {String.to_existing_atom(key), val} end)

    # Preserve ancestor chain for nested sub-workflows
    ancestors =
      case meta do
        %{"ancestor_ids" => ids} -> ids
        %{"sup_workflow_id" => sup} -> [sup]
        _ -> []
      end

    workflow =
      opts
      |> Keyword.merge(workflow_opts)
      |> new()

    opts = Map.put(workflow.opts, :ancestor_ids, ancestors)

    %{workflow | check_deps: check, names: MapSet.new(jobs, & &1.meta["name"]), opts: opts}
  end

  def append(job, opts), do: append([job], opts)

  @doc """
  Attaches a sub-workflow to a previously defined grafting point.

  Grafting points are defined using `add_graft/4` and serve as placeholders where sub-workflows can be
  attached dynamically during execution. This function must be called from within a worker's
  `process/1` callback, and only from a job that was defined using `add_graft/4`.

  ## Examples

  Graft a sub-workflow using a cascade:

      def process(job) do
        user_ids = active_user_ids(job.args["account_id"])

        {user_ids, &build_user_workflow/2}
        |> Workflow.apply_graft()
        |> Oban.insert_all()

        :ok
      end

  Graft a sub-workflow using a workflow:

      def process(job) do
        workflow =
          job.args["account_id"]
          |> active_user_ids()
          |> Enum.reduce(Workflow.new(), fn user_id, workflow ->
            Workflow.add(workflow, user_id, UserWorker.new(%{user_id: user_id}))
          end)

        workflow
        |> Workflow.apply_graft()
        |> Oban.insert_all()

        :ok
      end

  Add workflow-specific options when grafting:

      {user_ids, &build_user_workflow/2}
      |> Workflow.apply_graft(workflow_name: "my-workflow")
      |> Oban.insert_all()
  """
  @doc since: "1.6.0"
  @spec apply_graft(t() | cascade_capture(), new_opts() | add_cascade_opts()) :: t()
  def apply_graft(cascade_or_workflow, opts \\ [])

  def apply_graft({_enum, fun} = cascade, opts) when is_function(fun) do
    {job, graft_id, graft_name} = extract_graft!()

    job
    |> append(check_deps: false)
    |> add_cascade(graft_name, cascade, Keyword.put(opts, :workflow_id, graft_id))
  end

  def apply_graft(%__MODULE__{} = workflow, opts) do
    {job, graft_id, graft_name} = extract_graft!()

    root_id =
      case job.meta["workflow_id"] do
        "grafted_" <> _ = parent_graft_id -> parent_graft_id
        _ -> graft_id
      end

    sub_names = expand_sub_names(workflow)

    changesets =
      Enum.map(workflow.changesets, fn changeset ->
        changeset = Changeset.update_change(changeset, :meta, &Map.put(&1, :workflow_id, root_id))

        # Convert internal wildcard deps to explicit name deps so internal dependencies resolve
        # correctly.
        deps = get_in(changeset.changes, [:meta, :deps]) || []

        updated_deps =
          Enum.flat_map(deps, fn
            [sub_id, "*"] ->
              case Map.get(sub_names, sub_id) do
                nil -> [[sub_id, "*"]]
                names -> names
              end

            other ->
              [other]
          end)

        if updated_deps != deps do
          Changeset.update_change(changeset, :meta, &Map.put(&1, :deps, updated_deps))
        else
          changeset
        end
      end)

    workflow = %{workflow | id: root_id, changesets: changesets}

    job
    |> append(check_deps: false)
    |> add_workflow(graft_name, workflow, opts)
  end

  defp expand_sub_names(workflow) do
    Enum.reduce(workflow.changesets, %{}, fn changeset, acc ->
      meta = changeset.changes[:meta] || %{}
      name = meta[:name]

      sub_id = if meta[:sup_workflow_id], do: meta[:workflow_id]

      if sub_id && name do
        Map.update(acc, sub_id, [name], &[name | &1])
      else
        acc
      end
    end)
  end

  defp extract_graft! do
    case Process.get(:oban_processing) do
      {_module, %{meta: %{"graft_id" => _}} = job, _opts} ->
        %{"graft_id" => graft_id, "graft_name" => graft_name} = job.meta

        {job, graft_id, graft_name}

      {_module, _job, _opts} ->
        raise "current job isnt't marked for grafting with add_graft/4"

      _ ->
        raise "current job isn't available, graft/1 must be called from process/1"
    end
  end

  @doc false
  def all_jobs(job_or_wid), do: all_jobs(Oban, job_or_wid, [])

  @doc false
  def all_jobs(job_or_wid, opts) when is_job_or_wid(job_or_wid) and is_list(opts) do
    all_jobs(Oban, job_or_wid, opts)
  end

  def all_jobs(oban_name, job_or_wid) when is_job_or_wid(job_or_wid) do
    all_jobs(oban_name, job_or_wid, [])
  end

  @doc """
  Get all jobs for a workflow, optionally filtered by upstream deps.

  ## Examples

  Retrieve all workflow jobs within a `process/1` function:

      def process(job) do
        job
        |> Workflow.all_jobs()
        |> do_things_with_jobs()

        :ok
      end

  Retrieve all of the current job's deps:

      jobs = Workflow.all_jobs(job, only_deps: true)

  Retrieve an explicit list of dependencies:

      [job_a, job_b] = Workflow.all_jobs(job, names: [:a, :b])

  Include sub workflow jobs:

      [sub_a, sub_b] = Workflow.all_jobs(job, names: [:sub], with_subs: true)

  Retrieve all jobs using a `workflow_id`:

      jobs = Workflow.all_jobs("some-uuid-1234-5678")

  Retrieve only some named jobs using a `workflow_id`:

      [job_a, job_b] = Workflow.all_jobs("some-uuid-1234-5678", deps: [:a, :b])

  Retrieve all jobs using a `workflow_id` and a custom Oban instance name:

      jobs = Workflow.all_jobs(MyApp.Oban, "some-uuid-1234-5678")
  """
  @doc since: "1.5.0"
  @spec all_jobs(Oban.name(), job_or_wid(), fetch_opts()) :: [Job.t()]
  def all_jobs(oban_name, job_or_wid, opts)

  def all_jobs(_oban_name, %Job{conf: conf, meta: meta}, opts) do
    %{"deps" => deps, "workflow_id" => workflow_id} = meta

    {query_opts, opts} = Keyword.split(opts, ~w(names only_deps with_subs)a)

    Repo.all(conf, workflow_query(workflow_id, deps, query_opts), opts)
  end

  def all_jobs(oban_name, workflow_id, opts) when is_binary(workflow_id) do
    if Keyword.has_key?(opts, :only_deps) do
      raise RuntimeError, ":only_deps isn't a valid option with a workflow_id"
    end

    {query_opts, opts} = Keyword.split(opts, ~w(names with_subs)a)

    oban_name
    |> Oban.config()
    |> Repo.all(workflow_query(workflow_id, [], query_opts), opts)
  end

  @doc false
  def all_recorded(job_or_wid), do: all_recorded(Oban, job_or_wid, [])

  @doc false
  def all_recorded(job_or_wid, opts) when is_job_or_wid(job_or_wid) and is_list(opts) do
    all_recorded(Oban, job_or_wid, opts)
  end

  def all_recorded(oban_name, job_or_wid) when is_job_or_wid(job_or_wid) do
    all_recorded(oban_name, job_or_wid, [])
  end

  @doc """
  Get all recordings for workflow jobs, optionally filtered by name or relationship.

  The result is a map of the job's name and recorded output. Unrecorded jobs, or those without a
  recording will be present in the map with a a `nil` value.

  ## Examples

  Retrieve results for all workflow jobs within a `process/1` function:

      def process(job) do
        %{"a" => job_a_return, "b" => job_b_return} = Workflow.all_recorded(job)

        :ok
      end

  Retrieve recordings for all of the current job's deps:

      recorded = Workflow.all_recorded(job, only_deps: true)

  Retrieve recordings for a list of dependencies:

      %{"a" => _, "b" => _} = Workflow.all_recorded(job, names: [:a, :b])

  Retrieve recordings for some named jobs using the `workflow_id`:

      %{"a" => _, "b" => _} = Workflow.all_recorded("some-uuid-1234-5678", names: [:a, :b])

  Retrieve recordings including results from grafted sub-workflows:

      recorded = Workflow.all_recorded(job, with_subs: true)

  Retrieve recordings for a specific grafted sub-workflow by name:

      %{"users" => user_results} = Workflow.all_recorded(job, names: [:users], with_subs: true)
  """
  @doc since: "1.5.0"
  @spec all_recorded(Oban.name(), job_or_wid(), fetch_opts()) :: map()
  def all_recorded(oban_name, job_or_wid, opts)

  def all_recorded(_oban_name, %Job{conf: conf, meta: meta}, opts) do
    %{"deps" => deps, "workflow_id" => workflow_id} = meta

    all_recorded(conf, workflow_id, deps, opts)
  end

  def all_recorded(%Config{} = conf, workflow_id, opts) when is_binary(workflow_id) do
    if Keyword.has_key?(opts, :only_deps) do
      raise RuntimeError, ":only_deps isn't a valid option with a workflow_id"
    end

    all_recorded(conf, workflow_id, [], opts)
  end

  def all_recorded(oban_name, workflow_id, opts) when is_binary(workflow_id) do
    oban_name
    |> Oban.config()
    |> all_recorded(workflow_id, [], opts)
  end

  defp all_recorded(conf, workflow_id, deps, opts) when is_binary(workflow_id) do
    {query_opts, opts} = Keyword.split(opts, ~w(names only_deps with_subs)a)

    with_subs = Keyword.get(query_opts, :with_subs, false)

    query =
      workflow_id
      |> workflow_query(deps, query_opts)
      |> select([j], j.meta)

    conf
    |> Repo.all(query, opts)
    |> Enum.reject(&Map.get(&1, "context"))
    |> Enum.reduce(%{}, fn meta, acc ->
      case meta do
        %{"name" => name, "sub_name" => work_name} when with_subs ->
          acc
          |> Map.put_new(work_name, %{})
          |> put_in([work_name, name], extract_recorded(meta))

        %{"name" => name} ->
          Map.put(acc, name, extract_recorded(meta))
      end
    end)
  end

  @doc false
  def cancel_jobs(job_or_wid), do: cancel_jobs(Oban, job_or_wid, [])

  @doc false
  def cancel_jobs(job_or_wid, opts) when is_job_or_wid(job_or_wid) and is_list(opts) do
    cancel_jobs(Oban, job_or_wid, opts)
  end

  def cancel_jobs(oban_name, job_or_wid) when is_job_or_wid(job_or_wid) do
    cancel_jobs(oban_name, job_or_wid, [])
  end

  @doc """
  Cancel one or more workflow jobs.

  This uses `Oban.cancel_all_jobs/2` internally, and adheres to the same cancellation rules.
  Namely, it won't touch jobs in a `completed`, `cancelled`, or `discarded` state.

  Cancelling jobs via this function bulk-updates their state in the database without loading them
  into memory or resolving their workers. As a result, **worker `after_process/3` hooks are not
  called** for cancelled jobs. If you need to execute cleanup logic or other side effects when jobs
  are cancelled, consider using workflow callbacks like `after_cancelled/2` or implement cleanup
  logic at the application level.

  ## Examples

  Cancel jobs with a workflow `job` from a `process/1` function:

      def process(job) do
        if should_stop_processing?(job.args) do
          Workflow.cancel_jobs(job)
        else
          ...
        end
      end

  Cancel specific workflow jobs:

      Workflow.cancel_jobs("some-uuid-1234-5678", names: [:a, :b])

  Cancel all jobs in a workflow by `workflow_id`:

      Workflow.cancel_jobs("some-uuid-1234-5678")

  Cancel jobs from anywhere with a custom Oban instance name:

      Workflow.cancel_jobs(MyApp.Oban, "some-uuid-1234-5678")
  """
  @doc since: "1.5.0"
  @spec cancel_jobs(Oban.name(), job_or_wid(), cancel_opts()) :: {:ok, integer()}
  def cancel_jobs(oban_name, job_or_wid, opts)

  def cancel_jobs(_oban_name, %Job{conf: conf, meta: %{"workflow_id" => wid}}, opts) do
    cancel_jobs(conf.name, wid, opts)
  end

  def cancel_jobs(oban_name, wid, opts) when is_binary(wid) do
    query_opts =
      opts
      |> Keyword.take([:names, :with_subs])
      |> Keyword.put_new(:with_subs, true)

    Oban.cancel_all_jobs(oban_name, workflow_query(wid, [], query_opts))
  end

  @doc """
  Retry all jobs in a workflow.

  By default, this function will retry all jobs that aren't `executing` or `available`. Jobs with
  dependencies will be placed on hold to run again once their dependencies are complete.

  ## Examples

  Retry only failed jobs in a workflow from within a process function:

      def process(job) do
        Workflow.retry_jobs(job)
      end

  Retry a workflow by ID:

      Workflow.retry_jobs("some-uuid-1234-5678")

  Retry a workflow with a custom Oban instance:

      Workflow.retry_jobs(MyApp.Oban, "some-uuid-1234-5678")
  """
  @doc since: "1.6.0"
  @spec retry_jobs(Oban.name(), job_or_wid(), keyword()) :: {:ok, integer()}
  def retry_jobs(oban_name \\ Oban, job_or_wid, opts \\ [])

  def retry_jobs(_oban_name, %Job{conf: conf, meta: %{"workflow_id" => wid}}, opts) do
    retry_jobs(conf.name, wid, opts)
  end

  def retry_jobs(oban_name, wid, opts) when is_binary(wid) do
    conf = Oban.config(oban_name)

    Repo.transaction(conf, fn ->
      jobs = all_jobs(oban_name, wid, opts)

      {with_deps, wout_deps} =
        jobs
        |> Enum.reject(& &1.meta["context"])
        |> Enum.split_with(fn %{meta: meta} -> Enum.any?(meta["deps"]) end)

      base = where(Job, [j], j.state not in ~w(available executing))

      if Enum.any?(wout_deps) do
        query =
          base
          |> where([j], j.id in ^Enum.map(wout_deps, & &1.id))
          |> update([j],
            set: [
              state: "available",
              max_attempts: fragment("GREATEST(?, ? + 1)", j.max_attempts, j.attempt),
              scheduled_at: ^DateTime.utc_now(),
              completed_at: nil,
              cancelled_at: nil,
              discarded_at: nil
            ]
          )

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

      if Enum.any?(with_deps) do
        query =
          base
          |> where([j], j.id in ^Enum.map(with_deps, & &1.id))
          |> update([j],
            set: [
              state: "scheduled",
              max_attempts: fragment("GREATEST(?, ? + 1)", j.max_attempts, j.attempt),
              meta: fragment("(? || ?) - 'orig_scheduled_at'", j.meta, ^%{on_hold: true}),
              scheduled_at: ^DateTime.utc_now(),
              completed_at: nil,
              cancelled_at: nil,
              discarded_at: nil
            ]
          )

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

      length(jobs)
    end)
  end

  @doc """
  Get a single workflow job by name.

  ## Examples

  Get the workflow job named `:step_1` from within `process/`:

      def process(job) do
        case Workflow.get_job(job, :step_1) do
          nil -> ...
          dep_job -> use_the_job(dep_job)
        end
      end

  Get a named workflow job with the `workflow_id`:

      Workflow.get_job("some-uuid-1234-5678", :step_2)

  Get a named workflow job with the `workflow_id` and a custom Oban instance name:

      Workflow.get_job(MyApp.Oban, "some-uuid-1234-5678", :step_2)
  """
  @doc since: "1.5.0"
  @spec get_job(Oban.name(), job_or_wid(), name()) :: nil | Job.t()
  def get_job(oban_name \\ Oban, job_or_wid, deps_name)

  def get_job(_oban_name, %Job{conf: conf, meta: %{"workflow_id" => wid}}, deps_name) do
    get_job(conf.name, wid, deps_name)
  end

  def get_job(oban_name, wid, deps_name) when is_binary(wid) do
    case all_jobs(oban_name, wid, names: [deps_name]) do
      [job | _] -> job
      [] -> nil
    end
  end

  @doc """
  Fetch the recorded output from a workflow job.

  This function provides an optimized way to get recorded output with a single query.

  A `nil` value is returned when a dependency is incomplete, missing, unrecorded, or lacks
  recorded output.

  ## Examples

  Get the recorded output from `:step_1` within `process/`:

      def process(job) do
        case Workflow.get_recorded(job, :step_1) do
          nil -> ...
          val -> use_recorded_output(val)
        end
      end

  Get recorded output using the workflow id:

      Workflow.get_recorded("some-uuid-1234-5678", :step_2)

  Get recorded output using the `workflow_id` and a custom Oban instance name:

      Workflow.get_recorded(MyApp.Oban, "some-uuid-1234-5678", :step_2)
  """
  @doc since: "1.5.0"
  @spec get_recorded(Oban.name(), job_or_wid(), name()) :: any()
  def get_recorded(oban_name \\ Oban, job_or_wid, deps_name)

  def get_recorded(_oban_name, %Job{conf: conf, meta: %{"workflow_id" => wid}}, deps_name) do
    get_recorded(conf, wid, deps_name)
  end

  def get_recorded(%Config{} = conf, wid, deps_name) when is_binary(wid) do
    query =
      wid
      |> workflow_query([], names: [deps_name])
      |> select([j], j.meta)

    conf
    |> Repo.one(query)
    |> extract_recorded()
  end

  def get_recorded(oban_name, wid, deps_name) when is_binary(wid) do
    oban_name
    |> Oban.config()
    |> get_recorded(wid, deps_name)
  end

  @doc false
  def stream_jobs(job_or_wid), do: stream_jobs(Oban, job_or_wid, [])

  @doc false
  def stream_jobs(job_or_wid, opts) when is_job_or_wid(job_or_wid) and is_list(opts) do
    stream_jobs(Oban, job_or_wid, opts)
  end

  def stream_jobs(oban_name, job_or_wid) when is_job_or_wid(job_or_wid) do
    stream_jobs(oban_name, job_or_wid, [])
  end

  @doc """
  Stream all jobs for a workflow.

  This function behaves identically to `all_jobs/3`, except it streams jobs lazily from within a
  `Repo` transaction.

  ## Examples

  Stream with filtering to only preserve `completed` jobs:

      def process(job) do
        {:ok, workflow_jobs} =
          MyApp.Repo.transaction(fn ->
            job
            |> Workflow.stream_jobs()
            |> Stream.filter(& &1.state == "completed")
            |> Enum.to_list()
          end)

        do_things_with_jobs(workflow_jobs)

        :ok
      end

  Stream workflow jobs from anywhere using the `workflow_id`:

      MyApp.Repo.transaction(fn ->
        "some-uuid-1234-5678"
        |> Workflow.stream_jobs()
        |> Enum.map(& &1.args["account_id"])
      end)

  Stream workflow jobs using a custom Oban instance name:

      MyApp.Repo.transaction(fn ->
        MyApp.Oban
        |> Workflow.stream_jobs("some-uuid-1234-5678")
        |> Enum.map(& &1.args["account_id"])
      end)
  """
  @doc since: "1.5.0"
  @spec stream_jobs(Oban.name(), Job.t() | String.t(), fetch_opts()) :: Enum.t()
  def stream_jobs(oban_name, job_or_wid, opts)

  def stream_jobs(_oban_name, %Job{conf: conf, meta: meta}, opts) do
    %{"deps" => deps, "workflow_id" => wid} = meta

    {query_opts, opts} = Keyword.split(opts, [:names, :only_deps])

    Repo.stream(conf, workflow_query(wid, deps, query_opts), opts)
  end

  def stream_jobs(oban_name, wid, opts) when is_binary(wid) do
    if Keyword.has_key?(opts, :only_deps) do
      raise RuntimeError, ":only_deps isn't a valid option with a workflow_id"
    end

    {query_opts, opts} = Keyword.split(opts, [:names])

    oban_name
    |> Oban.config()
    |> Repo.stream(workflow_query(wid, [], query_opts), opts)
  end

  @doc """
  Get detailed status information about a workflow.

  Returns a map with the following fields:

  - `:id` - The workflow's unique id
  - `:name` - The name of the workflow, if it has one
  - `:total` - Total number of jobs in the workflow
  - `:state` - Overall status of the workflow (e.g. executing, completed, cancelled, discarded)
  - `:counts` - Map of job state counts (e.g. `%{completed: 10, available: 2}`)
  - `:duration` - Elapsed time of the workflow in milliseconds (nil if not yet ran)
  - `:started_at` - When the first job in the workflow was inserted
  - `:stopped_at` - When the last job in the workflow finished
  - `:subs` - A map of status output for sub-worfklows where the key is the sub name and the value
    is a `status/1` output

  An example of the full output:

  ```elixir
  %{
    id: "some-uuid-1234-5678",
    name: "my-workflow",
    total: 3,
    state: :completed,
    duration: 72,
    counts: %{
      cancelled: 0,
      available: 0,
      completed: 3,
      scheduled: 0,
      discarded: 0,
      executing: 0,
      retryable: 0
    },
    started_at: ~U[2025-04-10 16:34:34.114730Z],
    stopped_at: ~U[2025-04-10 16:34:34.186935Z],
    subs: %{}
  }
  ```

  ## Examples

  Get a workflow's status using a job:

      status = Workflow.status(job)

  Get a workflow's status using the workflow_id:

      status = Workflow.status("some-uuid-1234-5678")

  Get a workflow's status using a custom Oban instance name:

      status = Workflow.status(MyApp.Oban, "some-uuid-1234-5678")
  """
  @doc since: "1.6.0"
  @spec status(Oban.name(), job_or_wid()) :: status()
  def status(oban_name \\ Oban, job_or_wid)

  def status(_oban_name, %Job{conf: conf, meta: %{"workflow_id" => wid}}) do
    status(conf, wid)
  end

  def status(%Config{} = conf, workflow_id) when is_binary(workflow_id) do
    query =
      Job
      |> where([j], in_workflow(j.meta, ^workflow_id))
      |> group_by([j], [j.meta["workflow_id"], j.meta["workflow_name"]])
      |> select([j], %{
        id: j.meta["workflow_id"],
        name: j.meta["workflow_name"],
        ignore_cancelled: fragment("array_agg(?)", j.meta["ignore_cancelled"]),
        ignore_discarded: fragment("array_agg(?)", j.meta["ignore_discarded"]),
        states: fragment("array_agg(?)", j.state),
        min_scheduled: min(j.inserted_at),
        max_completed: max(j.completed_at),
        max_cancelled: max(j.cancelled_at),
        max_discarded: max(j.discarded_at)
      })

    case Repo.one(conf, query) do
      nil ->
        %{
          id: workflow_id,
          name: nil,
          total: 0,
          state: :unknown,
          counts: Map.new(Job.states(), &{&1, 0}),
          duration: nil,
          started_at: nil,
          stopped_at: nil,
          subs: %{}
        }

      result ->
        expand_status(conf, workflow_id, result)
    end
  end

  def status(oban_name, workflow_id) when is_binary(workflow_id) do
    oban_name
    |> Oban.config()
    |> status(workflow_id)
  end

  defp expand_status(conf, workflow_id, result) do
    total = length(result.states)
    empty = Map.new(Job.states(), &{&1, 0})

    counts =
      [result.states, result.ignore_cancelled, result.ignore_discarded]
      |> Enum.zip()
      |> Enum.reduce(empty, fn tuple, acc ->
        state =
          case tuple do
            {"cancelled", true, _} -> :completed
            {"discarded", _, true} -> :completed
            {state, _ican, _idisc} -> String.to_existing_atom(state)
          end

        Map.update(acc, state, 1, &(&1 + 1))
      end)

    state =
      cond do
        counts.completed + counts.cancelled + counts.discarded < total -> :executing
        counts.cancelled > 0 -> :cancelled
        counts.discarded > 0 -> :discarded
        true -> :completed
      end

    stopped_at =
      [result.max_completed, result.max_cancelled, result.max_discarded]
      |> Enum.reject(&is_nil/1)
      |> Enum.max(fn -> nil end)

    duration =
      if stopped_at do
        DateTime.diff(stopped_at, result.min_scheduled, :millisecond)
      else
        nil
      end

    subs =
      Job
      |> where([j], fragment("? \\? 'sup_workflow_id'", j.meta))
      |> where([j], fragment("?->>'sup_workflow_id' = ?", j.meta, ^workflow_id))
      |> select([j], %{wid: j.meta["workflow_id"], name: j.meta["sub_name"]})
      |> distinct(true)
      |> then(&Repo.all(conf, &1))
      |> Map.new(fn %{wid: wid, name: name} -> {name, status(conf, wid)} end)

    %{
      id: result.id,
      name: result.name,
      total: total,
      state: state,
      counts: counts,
      duration: duration,
      started_at: result.min_scheduled,
      stopped_at: stopped_at,
      subs: subs
    }
  end

  @doc """
  Converts the given workflow to a `:digraph`.

  The resulting digraph can be explored, evaluated, and then be converted to a number of other
  formats.

  ## Examples

  Generate a digraph from a workflow:

      graph = Workflow.to_graph(workflow)

  Check the path between jobs in a workflow:

      workflow
      |> Workflow.to_graph()
      |> :digraph.get_path("step_1", "step_5")
  """
  @doc since: "1.5.0"
  @spec to_graph(flow :: t()) :: :digraph.graph()
  def to_graph(%__MODULE__{changesets: changesets, subs: subs}) do
    graph = :digraph.new([:acyclic])

    changesets
    |> Enum.group_by(& &1.changes.meta[:sub_name])
    |> Enum.each(fn
      {nil, changesets} ->
        add_graph_nodes(graph, changesets)

      {sub, changesets} ->
        sub_graph =
          [:acyclic]
          |> :digraph.new()
          |> add_graph_nodes(changesets)

        :digraph.add_vertex(graph, sub, sub_graph)
    end)

    for %{changes: %{meta: meta}} <- changesets, dep <- meta.deps do
      # This would also create edges in the top graph for values that shouldn't be there.
      case dep do
        [wid, "*"] ->
          sub_name = Enum.find_value(subs, &if(elem(&1, 1) == wid, do: elem(&1, 0)))
          dep_name = meta[:sub_name] || meta[:name]

          :digraph.add_edge(graph, sub_name, dep_name)

        [_id, dep] ->
          :digraph.add_edge(graph, dep, meta.sub_name)

        dep ->
          :digraph.add_edge(graph, dep, meta.name)
      end
    end

    graph
  end

  defp add_graph_nodes(graph, changesets) do
    for %{changes: %{meta: meta, worker: worker}} <- changesets do
      label = Map.get(meta, :decorated_name, worker)

      :digraph.add_vertex(graph, meta.name, label)
    end

    graph
  end

  @doc """
  Converts the given workflow to a [graphviz](https://graphviz.org/) `dot` digraph.

  ## Examples

  Generate a dot digraph:

      Workflow.to_dot(workflow)
  """
  @doc since: "1.5.0"
  @spec to_dot(flow :: t()) :: String.t()
  def to_dot(%__MODULE__{} = workflow) do
    graph = to_graph(workflow)
    nodes = :digraph.vertices(graph)
    edges = :digraph.edges(graph)

    nodes_dot = visit_nodes(nodes, graph, :dot)
    edges_dot = visit_edges(edges, graph, :dot)

    """
    digraph {
      node [shape=box];
      #{nodes_dot}
      #{edges_dot}
    }
    """
  end

  @doc """
  Generate a [Mermaid](https://mermaid.js.org/) flowchart in top-down orientation from a workflow.

  ## Examples

  Generate a flowchart:

      Workflow.to_mermaid(workflow)
  """
  @doc since: "1.5.0"
  @spec to_mermaid(flow :: t()) :: String.t()
  def to_mermaid(%__MODULE__{} = workflow) do
    graph = to_graph(workflow)
    nodes = :digraph.vertices(graph)
    edges = :digraph.edges(graph)

    nodes_mermaid = visit_nodes(nodes, graph, :mermaid)
    edges_mermaid = visit_edges(edges, graph, :mermaid)

    """
    flowchart TD
      #{nodes_mermaid}
      #{edges_mermaid}
    """
  end

  @doc false
  def rescue_workflows(conf, opts \\ []) do
    {limit, opts} = Keyword.pop(opts, :limit, 1_000)

    query =
      Job
      |> where([j], j.state == "scheduled")
      |> where([j], fragment("? \\? 'workflow_id'", j.meta))
      |> where([j], fragment("?->>'on_hold' = 'true'", j.meta))
      |> select([j], fragment("?->>'workflow_id'", j.meta))
      |> distinct(true)
      |> limit(^limit)

    conf
    |> Repo.all(query, opts)
    |> on_flush(conf)
  end

  # Construction Helpers

  defp prevent_chain!(changeset, name) do
    if Chain.chain?(changeset) do
      raise "#{inspect(name)} is a chain, which isn't allowed in a workflow"
    end
  end

  defp prevent_dupe!(%{names: names, subs: subs}, name) do
    if MapSet.member?(names, name) or is_map_key(subs, name) do
      raise "#{inspect(name)} is already a member of the workflow"
    end
  end

  defp confirm_deps!(%{check_deps: true, names: names, grafts: grafts}, [_ | _] = deps) do
    Enum.each(deps, fn
      [_wd, "*"] ->
        :ok

      [_wd, name] ->
        if not MapSet.member?(names, name) do
          raise "deps #{inspect(name)} is not a member of the workflow"
        end

      name when is_binary(name) ->
        if not (MapSet.member?(names, name) or is_map_key(grafts, name)) do
          raise "deps #{inspect(name)} is not a member of the workflow"
        end
    end)
  end

  defp confirm_deps!(_workflow, _deps), do: :ok

  # Visualization Helpers

  defp visit_nodes(nodes, graph, mode, prefix \\ nil) do
    Enum.map_join(nodes, "\n", fn node ->
      {_node, label} = :digraph.vertex(graph, node)

      if is_tuple(label) do
        sub_nodes = visit_nodes(:digraph.vertices(label), label, mode, "#{node}_")
        sub_edges = visit_edges(:digraph.edges(label), label, mode)

        format_subg(mode, node, sub_nodes, sub_edges)
      else
        format_node(mode, "#{prefix}#{node}", label)
      end
    end)
  end

  defp visit_edges(edges, graph, mode) do
    edges
    |> Enum.map(fn edge ->
      {_, in_vert, out_vert, _} = :digraph.edge(graph, edge)

      {in_vert, out_vert}
    end)
    |> Enum.uniq()
    |> Enum.map_join("\n  ", &format_edge(mode, &1))
  end

  defp format_node(:dot, node, label) do
    ~s|"#{node}" [label="#{node} (#{label})"];|
  end

  defp format_node(:mermaid, node, label) do
    ~s|#{node}["#{node} (#{label})"]|
  end

  defp format_edge(:dot, {in_vert, out_vert}) do
    ~s|"#{in_vert}" -> "#{out_vert}";|
  end

  defp format_edge(:mermaid, {in_vert, out_vert}) do
    "#{in_vert} --> #{out_vert}"
  end

  defp format_subg(:dot, node, sub_nodes, sub_edges) do
    """
    subgraph "cluster_#{node}" {
      label="#{node}";
      #{sub_nodes}
      #{sub_edges}
    }
    """
  end

  defp format_subg(:mermaid, node, sub_nodes, sub_edges) do
    """
    subgraph #{node}
      #{sub_nodes}
      #{sub_edges}
    end
    """
  end

  # Query Helpers

  defp workflow_query(wid, deps, opts) do
    query =
      Job
      |> where([j], j.state in @all_states)
      |> order_by(asc: :id)

    query =
      if opts[:with_subs] do
        where(query, [j], in_workflow_or_sup(j.meta, ^wid))
      else
        where(query, [j], in_workflow(j.meta, ^wid))
      end

    cond do
      opts[:with_subs] && is_list(opts[:names]) ->
        names = Enum.map(opts[:names], &to_string/1)

        where(
          query,
          [j],
          fragment("coalesce(?->>'sub_name', ?->>'name')", j.meta, j.meta) in ^names
        )

      is_list(opts[:names]) ->
        names = Enum.map(opts[:names], &to_string/1)

        where(query, [j], j.meta["name"] in ^names)

      opts[:with_subs] && opts[:only_deps] ->
        where(
          query,
          [j],
          fragment("coalesce(?->>'sub_name', ?->>'name')", j.meta, j.meta) in ^deps
        )

      opts[:only_deps] ->
        where(query, [j], j.meta["name"] in ^deps)

      true ->
        query
    end
  end

  defp to_stage_operation({%{meta: meta} = job, deps_states}) do
    # A grafted sub-workflow won't have jobs until the grafter runs. Ignore the "deleted" check
    # for the sub-workflow and rely entirely on the grafter job itself.
    deps = Enum.reject(meta["deps"], &match?(["grafted_" <> _, "*"], &1))

    deps_set =
      if length(deps_states) < length(deps) do
        MapSet.new(["deleted" | deps_states])
      else
        MapSet.new(deps_states)
      end

    op =
      cond do
        MapSet.member?(deps_set, "deleted") and meta["ignore_deleted"] != true ->
          {:cancelled, :deleted}

        MapSet.member?(deps_set, "cancelled") and meta["ignore_cancelled"] != true ->
          {:cancelled, :cancelled}

        MapSet.member?(deps_set, "discarded") and meta["ignore_discarded"] != true ->
          {:cancelled, :discarded}

        not MapSet.disjoint?(deps_set, @incomplete_set) ->
          :ignore

        meta["context"] == true ->
          :ignore

        is_legacy_scheduled(meta) ->
          {:scheduled, DateTime.from_unix!(meta["orig_scheduled_at"])}

        is_map_key(meta, "orig_scheduled_at") ->
          {:scheduled, DateTime.from_unix!(meta["orig_scheduled_at"], :microsecond)}

        true ->
          :available
      end

    {op, job}
  end

  defp apply_operations({:ignore, _}, _), do: []

  defp apply_operations({op, jobs}, conf) do
    now = DateTime.utc_now()
    ids = Enum.map(jobs, & &1.id)

    base =
      Job
      |> where([j], j.id in ^ids and j.state == "scheduled")
      |> update([j], set: [meta: drop_hold(j.meta)])

    query =
      case op do
        :available ->
          base
          |> select([j], nil)
          |> update([j], set: [scheduled_at: ^now, state: "available"])

        {:scheduled, at} ->
          base
          |> select([j], nil)
          |> update([j], set: [scheduled_at: ^at, state: "scheduled"])

        {:cancelled, reason} ->
          error = %{
            kind: :error,
            reason: Oban.Pro.WorkflowError.exception(reason),
            stacktrace: []
          }

          formatted = Job.format_attempt(%Job{attempt: 1, unsaved_error: error})

          base
          |> select([j], j.meta["workflow_id"])
          |> update([j],
            set: [
              cancelled_at: ^now,
              state: "cancelled",
              errors: concat_errors(j.errors, ^[formatted])
            ]
          )
      end

    {_count, workflow_ids} = Repo.update_all(conf, query, set: [])

    with {:cancelled, reason} <- op, do: Enum.each(jobs, &apply_callback(reason, &1, conf))

    workflow_ids
    |> Enum.reject(&is_nil/1)
    |> Enum.uniq()
  end

  defp apply_callback(reason, job, conf) do
    with {:ok, worker} <- Oban.Worker.from_string(job.worker),
         true <- function_exported?(worker, :after_cancelled, 2),
         {:ok, job} <- Oban.Pro.Worker.before_process(worker, job, worker.__opts__()) do
      worker.after_cancelled(reason, %{job | conf: conf})
    end
  catch
    kind, value ->
      Logger.error(fn ->
        "[Oban.Pro.Workflow] callback error: " <> Exception.format(kind, value, __STACKTRACE__)
      end)

      :ok
  end

  # Recorded Helpers

  defp extract_recorded(meta) do
    case meta do
      %{"recorded" => true, "return" => value, "safe_decode" => true} ->
        Utils.decode64(value, [:safe])

      %{"recorded" => true, "return" => value} ->
        Utils.decode64(value, [])

      _ ->
        nil
    end
  end
end
