///|
/// Which command scope a synchronization targets.
pub(all) enum CommandScope {
  Global
  Guild(@model.GuildId)
  Guilds(Array[@model.GuildId])
} derive(Debug)

///|
/// Configuration errors detected before starting an application executor.
pub(all) suberror AppConfigError {
  EmptyToken
  /// Two registrations share a command name and type; `first` and `second`
  /// are their call sites.
  DuplicateCommand(name~ : String, first~ : SourceLoc, second~ : SourceLoc)
  DuplicateCommandPath(command~ : String, path~ : String)
  NestedSubcommandGroup(command~ : String, path~ : String)
  ChoicesWithAutocomplete(command~ : String, path~ : String)
  RequiredOptionAfterOptional(command~ : String, path~ : String)
  /// Two component routes share an effective prefix; `first` and `second`
  /// are their call sites.
  DuplicateComponentRoute(
    custom_id~ : String,
    first~ : SourceLoc,
    second~ : SourceLoc
  )
  /// Two modal routes share an effective prefix; `first` and `second` are
  /// their call sites.
  DuplicateModalRoute(
    custom_id~ : String,
    first~ : SourceLoc,
    second~ : SourceLoc
  )
  EmptyComponentRoute
  EmptyModalRoute
  InvalidRouteId(custom_id~ : String, reason~ : String)
  InvalidModalFieldCount(custom_id~ : String, count~ : Int32)
  DuplicateModalTextInput(custom_id~ : String, field~ : String)
  /// A modal title or field outside a limit Discord documents, such as a text
  /// input `max_length` over 4000 or a label over 45 units.
  ModalOutsideLimits(custom_id~ : String, violation~ : @model.LimitViolation)
  /// A `file_types` filter with more than 10 entries or an extension that is
  /// not dot-prefixed. `owner` is a command option path or modal field id.
  InvalidFileTypes(owner~ : String, reason~ : String)
  /// A command name, description, option, or choice outside a limit Discord
  /// documents, such as a 26th option or an uppercase slash command name.
  CommandOutsideLimits(command~ : String, violation~ : @model.LimitViolation)
  InvalidCooldown(command~ : String, seconds~ : Int32)
  InvalidMaxInFlight(value~ : Int32)
  /// `max_pending` must allow at least one waiting deferred interaction.
  InvalidMaxPending(value~ : Int32)
} derive(Debug)

///|
/// A declaration and the call site that registered it.
priv struct Declared[T] {
  entry : T
  loc : SourceLoc
}

///|
priv struct RegisteredModal {
  pattern : AppRoutePattern
  field_count : Int32?
  text_input_ids : Array[String]?
  file_type_filters : Array[(String, Array[@model.FileTypeFilter])]
  limit_violations : Array[@model.LimitViolation]
  ack : (@framework.ModalCtx) -> AckPlan
  // Rejects a submission the handler cannot answer before it takes handler
  // capacity, so the error policy answers even when every slot is busy.
  preflight : (@framework.ModalCtx) -> Unit raise HandlerError
  dispatch : async (
    AppCtx,
    ComponentWaiter?,
    (AppWarning) -> Unit,
    @framework.ModalCtx,
  ) -> Unit
}

///|
priv struct AutocompleteHandler {
  name : String
  dispatch : async (@framework.AutocompleteCtx) -> Unit
}

///|
/// Executor-provided function that runs a handler task concurrently on
/// the surrounding async runtime.
pub type Spawner = async (async () -> Unit) -> Unit

///|
/// Collects application declarations: commands, component and modal routes,
/// autocomplete handlers, middleware, and failure hooks. `build` validates
/// them into an immutable `App`.
pub struct AppBuilder {
  priv max_in_flight_ : Int32
  priv max_pending_ : Int32
  priv commands_ : Array[Declared[RegisteredCommand]]
  priv components_ : Array[Declared[RegisteredComponent]]
  priv modals_ : Array[Declared[RegisteredModal]]
  priv autocompletes_ : Array[AutocompleteHandler]
  priv middleware_ : Array[InteractionMiddleware]
  priv mut policy_ : ErrorPolicy?
  priv mut warn_ : (AppWarning) -> Unit
  priv cooldown_store_ : &@cooldown.CooldownStore?
  priv cooldown_outage_ : CooldownOutage
}

///|
/// Validated, immutable application declarations shared by gateway and HTTP
/// executors. Build one with `AppBuilder::build`; every `App` has passed
/// validation, so executors and `attach` never see an invalid declaration.
pub struct App {
  priv max_in_flight_ : Int32
  priv max_pending_ : Int32
  // How long a deferred interaction may wait for a handler permit; the
  // interaction token's lifetime in production, shortened by whitebox tests.
  priv deferred_wait_ms_ : Int32
  priv commands_ : Array[RegisteredCommand]
  priv components_ : Array[RegisteredComponent]
  priv modals_ : Array[RegisteredModal]
  priv autocompletes_ : Array[AutocompleteHandler]
  priv middleware_ : Array[InteractionMiddleware]
  priv policy_ : ErrorPolicy?
  priv warn_ : (AppWarning) -> Unit
  priv cooldowns_ : Cooldowns
}

///|
/// Start collecting application declarations. `max_in_flight` caps
/// concurrently running interaction handlers; routing and component waiters
/// are not counted, so a handler waiting for a component never blocks the
/// interaction that wakes it. A gateway executor gives its event handlers a
/// separate budget of the same size. `max_pending` caps the deferred
/// interactions waiting for a handler permit; one more is refused with
/// `HandlerError::Busy` before it is acknowledged, and a deferred interaction
/// still waiting when its token expires (15 minutes after the executor
/// received it) is dropped with a warning. Cooldowns use a fresh in-memory
/// store for each built App, timed by the clock of the executor it is first
/// attached to, unless a shared `cooldown_store` is supplied; when that store
/// cannot answer, `cooldown_outage` decides whether commands run, and the first
/// failure of each outage is reported as `AppWarning::CooldownUnavailable`.
pub fn AppBuilder::AppBuilder(
  max_in_flight~ : Int32 = 64,
  max_pending~ : Int32 = 1024,
  cooldown_store? : &@cooldown.CooldownStore,
  cooldown_outage~ : CooldownOutage = Allow,
) -> AppBuilder {
  {
    max_in_flight_: max_in_flight,
    max_pending_: max_pending,
    commands_: [],
    components_: [],
    modals_: [],
    autocompletes_: [],
    middleware_: [],
    policy_: None,
    warn_: warning => println(warning.to_string()),
    cooldown_store_: cooldown_store,
    cooldown_outage_: cooldown_outage,
  }
}

///|
/// Validate the declarations and freeze them into an `App`. Later changes to
/// this builder, or to the commands and specs registered on it, do not affect
/// the returned `App`.
pub fn AppBuilder::build(self : AppBuilder) -> App raise AppConfigError {
  // Validate the snapshot the App keeps, so nothing the caller still holds
  // can change a declaration after it passed.
  let commands = self.commands_.map(declared => {
    ..declared,
    entry: declared.entry.snapshot(),
  })
  { ..self, commands_: commands, }.validate()
  {
    max_in_flight_: self.max_in_flight_,
    max_pending_: self.max_pending_,
    deferred_wait_ms_: @framework.INTERACTION_TOKEN_LIFETIME_MS,
    commands_: commands.map(declared => declared.entry),
    components_: self.components_.map(declared => declared.entry),
    modals_: self.modals_.map(declared => declared.entry),
    autocompletes_: self.autocompletes_.copy(),
    middleware_: self.middleware_.copy(),
    policy_: self.policy_,
    warn_: self.warn_,
    cooldowns_: {
      store: self.cooldown_store_,
      outage: self.cooldown_outage_,
      down: false,
    },
  }
}

///|
/// Install middleware around every command, component, and modal handler
/// (not autocomplete). First installed is outermost. Runs inside the error
/// policy: raising HandlerError behaves exactly like a failing check.
pub fn AppBuilder::middleware(
  self : AppBuilder,
  middleware : InteractionMiddleware,
) -> Unit {
  self.middleware_.push(middleware)
}

///|
/// Register a typed slash or context-menu command built with the
/// `command` / `user_command` / `message_command` builders. `loc` is filled
/// with the caller's location, which duplicate-registration errors report.
#callsite(autofill(loc))
pub fn[A] AppBuilder::command(
  self : AppBuilder,
  command : Command[A],
  loc~ : SourceLoc,
) -> Unit {
  self.register(command.erase(), loc~)
}

///|
/// Register a type-erased command. `App::command` is the typed
/// entry point; use this when composing pre-erased commands, e.g. from
/// a plugin. `loc` is filled with the caller's location.
#callsite(autofill(loc))
pub fn AppBuilder::register(
  self : AppBuilder,
  command : RegisteredCommand,
  loc~ : SourceLoc,
) -> Unit {
  self.commands_.push({ entry: command, loc, })
}

///|
/// Route a component's exact id or `id:state`, decoding state before the handler.
/// Use the same route's `custom_id` when building buttons and selects. `loc`
/// is filled with the caller's location.
#callsite(autofill(loc))
pub fn[A] AppBuilder::on_component(
  self : AppBuilder,
  route : ComponentRoute[A],
  handler : ComponentHandler[A],
  loc~ : SourceLoc,
) -> Unit {
  self.components_.push({ entry: route.erase(handler), loc, })
}

///|
/// Register a fully raw literal-prefix component route. Longer prefixes win.
/// `AppBuilder::build` rejects empty or duplicate effective prefixes. `loc`
/// is filled with the caller's location.
#callsite(autofill(loc))
pub fn AppBuilder::on_component_raw(
  self : AppBuilder,
  prefix~ : String,
  handler : async (@framework.ComponentCtx) -> Unit,
  loc~ : SourceLoc,
) -> Unit {
  self.components_.push({
    entry: {
      pattern: Prefix(prefix),
      ack: Respond,
      dispatch: (_, _, _, raw) => handler(raw),
    },
    loc,
  })
}

///|
/// Route submissions of the typed `modal` to `handler`; field values
/// are decoded through the modal's `ModalFields` before the handler
/// runs. Matches the exact modal id or that id followed by `:` and state.
/// `loc` is filled with the caller's location.
#callsite(autofill(loc))
pub fn[S, A] AppBuilder::on_modal(
  self : AppBuilder,
  modal : Modal[S, A],
  handler : ModalSubmitHandler[S, A],
  loc~ : SourceLoc,
) -> Unit {
  self.modals_.push({
    entry: {
      pattern: Id(modal.route_.id_),
      field_count: Some(modal.fields_.definitions_.length()),
      text_input_ids: Some(modal_text_input_ids(modal.fields_.definitions_)),
      file_type_filters: modal_file_type_filters(modal.fields_.definitions_),
      limit_violations: @model.modal_limit_violations(
        title=modal.title_,
        components=modal.fields_.definitions_,
      ),
      ack: raw => handler.ack_plan(raw),
      preflight: raw => handler.preflight(raw),
      dispatch: (bot, waiter, warn, raw) => {
        dispatch_modal_handler(modal, handler, bot, waiter, warn, raw)
      },
    },
    loc,
  })
}

///|
/// Register a fully raw literal-prefix modal route. Longer prefixes win.
/// `AppBuilder::build` rejects empty or duplicate effective prefixes. `loc`
/// is filled with the caller's location.
#callsite(autofill(loc))
pub fn AppBuilder::on_modal_raw(
  self : AppBuilder,
  prefix~ : String,
  handler : async (@framework.ModalCtx) -> Unit,
  loc~ : SourceLoc,
) -> Unit {
  self.modals_.push({
    entry: {
      pattern: Prefix(prefix),
      field_count: None,
      text_input_ids: None,
      file_type_filters: [],
      limit_violations: [],
      ack: _ => Respond,
      preflight: _ => (),
      dispatch: (_, _, _, raw) => handler(raw),
    },
    loc,
  })
}

///|
/// Register a raw autocomplete handler. A raw handler takes precedence over
/// Arg-level `suggest` handlers registered for the same command name.
pub fn AppBuilder::autocomplete(
  self : AppBuilder,
  name : String,
  handler : async (@framework.AutocompleteCtx) -> Unit,
) -> Unit {
  self.autocompletes_.push({ name, dispatch: handler, })
}

///|
/// Replace the error policy invoked when a handler raises. The default
/// policy maps `HandlerError` variants to ephemeral user-facing messages.
/// For `Malformed` and any other error it warns with the details and shows
/// the user only a generic message.
pub fn AppBuilder::error_policy(
  self : AppBuilder,
  policy : ErrorPolicy,
) -> Unit {
  self.policy_ = Some(policy)
}

///|
/// Replace the warning hook used for non-fatal diagnostics. The hook receives
/// an `AppWarning`; by default its one-line `to_string()` form is printed.
pub fn AppBuilder::on_warn(
  self : AppBuilder,
  hook : (AppWarning) -> Unit,
) -> Unit {
  self.warn_ = hook
}

///|
fn App::current_policy(self : App) -> ErrorPolicy {
  self.policy_.unwrap_or_else(() => default_error_policy(self.warn_))
}

///|
fn validate_file_types(
  owner : String,
  filters : Array[@model.FileTypeFilter],
) -> Unit raise AppConfigError {
  if filters.length() > 10 {
    raise InvalidFileTypes(
      owner~,
      reason="\{filters.length()} entries (at most 10)",
    )
  }
  for filter in filters {
    if filter is Extension(extension) &&
      (!extension.has_prefix(".") || extension.length() < 2) {
      raise InvalidFileTypes(
        owner~,
        reason="extension \{extension} is not dot-prefixed",
      )
    }
  }
}

///|
fn validate_command_options(
  command : String,
  parent_path : Array[String],
  options : Array[@model.CommandOption],
) -> Unit raise AppConfigError {
  let mut saw_optional = false
  for option in options {
    let path = parent_path.copy()
    path.push(option.name)
    if option.autocomplete is Some(true) && option.choices is Some(_) {
      raise ChoicesWithAutocomplete(command~, path=path.join("/"))
    }
    if !(option.typ is (SubCommand | SubCommandGroup)) {
      if option.required is Some(true) {
        if saw_optional {
          raise RequiredOptionAfterOptional(command~, path=path.join("/"))
        }
      } else {
        saw_optional = true
      }
    }
    if option.file_types is Some(filters) {
      validate_file_types("\{command}/\{path.join("/")}", filters)
    }
    if option.options is Some(children) {
      validate_command_options(command, path, children)
    }
  }
}

///|
/// Check declarations for configuration errors: command names, descriptions,
/// options, and choices outside Discord's documented limits, invalid command
/// trees and cooldowns, invalid modal field counts, duplicate modal text-input ids,
/// modal titles and fields outside Discord's documented limits,
/// malformed `file_types` upload filters, empty or duplicate component/modal
/// effective prefixes, and invalid typed route ids.
/// `build` calls this before freezing the declarations.
fn AppBuilder::validate(self : AppBuilder) -> Unit raise AppConfigError {
  if self.max_in_flight_ <= 0 {
    raise InvalidMaxInFlight(value=self.max_in_flight_)
  }
  if self.max_pending_ <= 0 {
    raise InvalidMaxPending(value=self.max_pending_)
  }
  let commands : Map[String, SourceLoc] = Map([])
  for declared in self.commands_ {
    let command = declared.entry
    let command_key = command.key()
    if commands.get(command_key) is Some(first) {
      raise DuplicateCommand(name=command.name_, first~, second=declared.loc)
    }
    commands[command_key] = declared.loc
    for path in command.nested_group_paths_ {
      raise NestedSubcommandGroup(command=command.name_, path=path.join("/"))
    }
    let paths : Set[String] = Set([])
    for path in command.node_paths_ {
      let key = path_key(path)
      if paths.contains(key) {
        raise DuplicateCommandPath(command=command.name_, path=path.join("/"))
      }
      paths.add(key)
    }
    if command.definition.limit_violations() is [violation, ..] {
      raise CommandOutsideLimits(command=command.name_, violation~)
    }
    validate_command_options(command.name_, [], command.definition.options)
    if command.cooldown_ is Some(config) && config.seconds <= 0 {
      raise InvalidCooldown(command=command.name_, seconds=config.seconds)
    }
  }
  let components : Map[String, SourceLoc] = Map([])
  let component_ids : Array[String] = []
  for component in self.components_ {
    validate_route(
      component.entry.pattern,
      components,
      component_ids,
      modal=false,
      loc=component.loc,
    )
  }
  let modals : Map[String, SourceLoc] = Map([])
  let modal_ids : Array[String] = []
  for declared in self.modals_ {
    let modal = declared.entry
    validate_route(
      modal.pattern,
      modals,
      modal_ids,
      modal=true,
      loc=declared.loc,
    )
    let custom_id = modal.pattern.base()
    if modal.field_count is Some(count) && (count < 1 || count > 5) {
      raise InvalidModalFieldCount(custom_id~, count~)
    }
    if modal.limit_violations is [violation, ..] {
      raise ModalOutsideLimits(custom_id~, violation~)
    }
    for entry in modal.file_type_filters {
      validate_file_types("\{custom_id}/\{entry.0}", entry.1)
    }
    if modal.text_input_ids is Some(ids) {
      let seen : Set[String] = Set([])
      for field in ids {
        if seen.contains(field) {
          raise DuplicateModalTextInput(custom_id~, field~)
        }
        seen.add(field)
      }
    }
  }
}

///|
async fn spawn_limited(
  group : @async.TaskGroup[Unit],
  limiter : @async.Semaphore,
  intake : HandlerIntake,
  body : async () -> Unit,
) -> Unit {
  // Acquire before spawning: at capacity, the executor applies bounded
  // backpressure instead of allocating an unbounded list of pending tasks.
  guard intake.acquire(limiter) else { return }
  group.spawn_bg(allow_failure=true, () => {
    defer limiter.release()
    // Judged as the task starts, with nothing to wait for before `body`: the
    // intake may have closed since the permit was taken.
    if intake.is_open() {
      body()
    }
  })
}

///|
fn bounded_spawner(
  group : @async.TaskGroup[Unit],
  limiter : @async.Semaphore,
  intake : HandlerIntake,
) -> Spawner {
  body => spawn_limited(group, limiter, intake, body)
}

///|
/// Create a bounded task spawner for an executor-owned task group. Executors
/// use it for non-interaction work such as event handlers; interaction
/// handlers get their own budget inside `attach`. Once `intake` closes, a
/// call returns without spawning, a call waiting for capacity gives up, and a
/// task spawned but not yet started does not run its body.
pub fn App::spawner(
  self : App,
  group : @async.TaskGroup[Unit],
  intake~ : HandlerIntake = HandlerIntake(),
) -> Spawner {
  bounded_spawner(group, Semaphore(self.max_in_flight_), intake)
}

///|
fn failure_context(
  origin : FailureOrigin,
  raw : FailureRaw?,
  warn : (AppWarning) -> Unit,
) -> FailureCtx {
  { origin_: origin, raw_: raw, warn_: warn, }
}

///|
async fn App::run_registered_command(
  self : App,
  bot : AppCtx,
  waiter : ComponentWaiter?,
  command : RegisteredCommand,
  raw : @framework.CommandCtx,
  cooldown_store : &@cooldown.CooldownStore,
) -> Unit {
  let failure_raw = RawCommand(raw)
  let ctx = InteractionCtx::{
    target_: Command(name=command.name_),
    raw_: failure_raw,
  }
  self.run_middleware(ctx, 0, () => {
    command.run_checks_and_cooldown(
      bot,
      raw,
      cooldown_store,
      self.cooldowns_,
      self.warn_,
    )
    (command.dispatch)(bot, waiter, self.warn_, raw)
  }) catch {
    error if @async.is_being_cancelled() => raise error
    error =>
      invoke_error_policy(
        self.current_policy(),
        failure_context(
          Command(name=command.name_),
          Some(failure_raw),
          self.warn_,
        ),
        error,
      )
  }
}

///|
async fn App::run_registered_component(
  self : App,
  bot : AppCtx,
  waiter : ComponentWaiter?,
  entry : RegisteredComponent,
  raw : @framework.ComponentCtx,
) -> Unit {
  let failure_raw = RawComponent(raw)
  let ctx = InteractionCtx::{
    target_: Component(custom_id=raw.data.custom_id),
    raw_: failure_raw,
  }
  self.run_middleware(ctx, 0, () => {
    (entry.dispatch)(bot, waiter, self.warn_, raw)
  }) catch {
    error if @async.is_being_cancelled() => raise error
    error =>
      invoke_error_policy(
        self.current_policy(),
        failure_context(
          Component(custom_id=raw.data.custom_id),
          Some(failure_raw),
          self.warn_,
        ),
        error,
      )
  }
}

///|
async fn App::run_registered_modal(
  self : App,
  bot : AppCtx,
  waiter : ComponentWaiter?,
  entry : RegisteredModal,
  raw : @framework.ModalCtx,
) -> Unit {
  let failure_raw = RawModal(raw)
  let ctx = InteractionCtx::{
    target_: Modal(custom_id=raw.data.custom_id),
    raw_: failure_raw,
  }
  self.run_middleware(ctx, 0, () => {
    (entry.dispatch)(bot, waiter, self.warn_, raw)
  }) catch {
    error if @async.is_being_cancelled() => raise error
    error =>
      invoke_error_policy(
        self.current_policy(),
        failure_context(
          Modal(custom_id=raw.data.custom_id),
          Some(failure_raw),
          self.warn_,
        ),
        error,
      )
  }
}

///|
async fn App::run_registered_autocomplete(
  self : App,
  command : RegisteredCommand,
  raw : @framework.AutocompleteCtx,
) -> Unit {
  dispatch_suggest(command.suggest_routes_, self.warn_, raw) catch {
    error if @async.is_being_cancelled() => raise error
    error => {
      invoke_error_policy(
        self.current_policy(),
        failure_context(Autocomplete(name=command.name_), None, self.warn_),
        error,
      )
      // ErrorPolicy is transport-neutral and cannot construct an autocomplete
      // callback, so complete the interaction with a safe empty result.
      raw.suggest([])
    }
  }
}

///|
/// Wire every registered command, component, modal, and autocomplete
/// route into `framework` and return the `AppCtx` shared by handlers.
/// Called by the built-in executors (`Bot`, the HTTP endpoint); only
/// custom executors need it directly. Each call gets its own handler budget
/// of `max_in_flight`. Once `intake` closes, no interaction handler starts:
/// one waiting for capacity, or arriving later, is dropped with
/// `AppWarning::InteractionDroppedWhileStopping`.
pub fn App::attach(
  self : App,
  framework : @framework.Framework,
  client~ : @dhttp.Client,
  application_id~ : @model.ApplicationId,
  waiter? : ComponentWaiter,
  latency_ms? : () -> Int64?,
  intake~ : HandlerIntake = HandlerIntake(),
) -> AppCtx {
  let app_ctx = AppCtx::{
    client_: client,
    application_id_: application_id,
    latency_ms_: latency_ms,
  }
  let cooldown_store = self.cooldowns_.bind(framework.clock())
  let admission : Admission = {
    limiter: @async.Semaphore(self.max_in_flight_),
    pending: 0,
    max_pending: self.max_pending_,
    wait_limit_ms: self.deferred_wait_ms_,
    clock: framework.clock(),
    refusing: false,
    intake,
  }
  let warn = self.warn_
  // Refuse one more deferred interaction through the error policy while it is
  // still unanswered.
  let busy = (origin : FailureOrigin, raw : FailureRaw?) => {
    async fn() {
      invoke_error_policy(
        self.current_policy(),
        failure_context(origin, raw, self.warn_),
        HandlerError::Busy,
      )
    }
  }
  for command in self.commands_ {
    framework.command(command.definition, raw => {
      run_admitted(
        admission,
        command_admission(raw),
        (command.ack_)(raw),
        warn,
        "command:\{command.name_}",
        busy(Command(name=command.name_), Some(RawCommand(raw))),
        () => {
          self.run_registered_command(
            app_ctx, waiter, command, raw, cooldown_store,
          )
        },
      )
    })
    |> ignore
    if !command.suggest_routes_.is_empty() {
      framework.autocomplete(command.name_, raw => {
        run_admitted(
          admission,
          autocomplete_admission(raw),
          Respond,
          warn,
          "autocomplete:\{command.name_}",
          // Autocomplete never defers, so it is never refused as busy.
          () => (),
          () => self.run_registered_autocomplete(command, raw),
        )
      })
      |> ignore
    }
  }
  for entry in self.components_ {
    let dispatch = raw => {
      run_admitted(
        admission,
        component_admission(raw),
        entry.ack,
        warn,
        "component:\{raw.data.custom_id}",
        busy(Component(custom_id=raw.data.custom_id), Some(RawComponent(raw))),
        () => self.run_registered_component(app_ctx, waiter, entry, raw),
      )
    }
    match entry.pattern {
      Prefix(prefix) => framework.component(prefix, dispatch) |> ignore
      Id(id) => framework.component_id(id, dispatch) |> ignore
    }
  }
  for entry in self.modals_ {
    let dispatch = (raw : @framework.ModalCtx) => {
      // The preflight runs before admission, so it checks the intake first:
      // after shutdown, not even its error policy may answer.
      if !intake.is_open() {
        warn(
          InteractionDroppedWhileStopping(target="modal:\{raw.data.custom_id}"),
        )
        return
      }
      (entry.preflight)(raw) catch {
        error => {
          invoke_error_policy(
            self.current_policy(),
            failure_context(
              Modal(custom_id=raw.data.custom_id),
              Some(RawModal(raw)),
              self.warn_,
            ),
            error,
          )
          return
        }
      }
      run_admitted(
        admission,
        modal_admission(raw),
        (entry.ack)(raw),
        warn,
        "modal:\{raw.data.custom_id}",
        busy(Modal(custom_id=raw.data.custom_id), Some(RawModal(raw))),
        () => self.run_registered_modal(app_ctx, waiter, entry, raw),
      )
    }
    match entry.pattern {
      Prefix(prefix) => framework.modal(prefix, dispatch) |> ignore
      Id(id) => framework.modal_id(id, dispatch) |> ignore
    }
  }
  // Registered last so explicitly configured raw handlers win by name.
  for entry in self.autocompletes_ {
    framework.autocomplete(entry.name, raw => {
      run_admitted(
        admission,
        autocomplete_admission(raw),
        Respond,
        warn,
        "autocomplete:\{entry.name}",
        () => (),
        () => (entry.dispatch)(raw),
      )
    })
    |> ignore
  }
  framework.on_error((label, error) => {
    if @async.is_being_cancelled() {
      raise error
    }
    (self.warn_)(HandlerFailed(target=label, error~))
  })
  |> ignore
  app_ctx
}

///|
/// Reports for each synchronized scope, in the requested order.
pub(all) struct SyncReport {
  scopes : Array[@framework.ScopeSyncReport]
} derive(Debug)

///|
/// Planned synchronizations for each requested scope, in order. Inspect
/// `report()` before `apply`.
pub struct CommandPlan {
  plans : Array[@framework.CommandSyncPlan]
}

///|
/// What applying the plan would change, scope by scope.
pub fn CommandPlan::report(self : CommandPlan) -> SyncReport {
  { scopes: self.plans.map(plan => plan.report), }
}

///|
/// Apply every scope's plan in order and report what changed.
pub async fn CommandPlan::apply(
  self : CommandPlan,
  client : @dhttp.Client,
) -> SyncReport {
  let scopes = []
  for plan in self.plans {
    scopes.push(plan.apply(client))
  }
  { scopes, }
}

///|
/// Fetch each scope's registered commands and plan their synchronization with
/// the declared ones, without changing anything: a dry run of
/// `sync_commands`. Entry points are always preserved; `Keep` also preserves
/// other owners, while the default `Delete` plans to remove them, so check
/// `report().scopes[..].deleted` before applying in a shared application.
pub async fn App::plan_commands(
  self : App,
  client : @dhttp.Client,
  application_id : @model.ApplicationId,
  scope~ : CommandScope,
  unowned~ : @framework.UnownedCommands = Delete,
) -> CommandPlan {
  let specs = self.commands_.map(command => command.definition)
  let plans = []
  for guild_id in scope_guilds(scope) {
    plans.push(
      @framework.plan_command_scope(
        client,
        application_id,
        specs,
        unowned~,
        guild_id?,
      ),
    )
  }
  { plans, }
}

///|
/// Synchronize the declared commands and report each scope's changes,
/// planning and applying one scope at a time (see `plan_commands` for a dry
/// run).
/// Entry points are always preserved; `Keep` also preserves other owners.
pub async fn App::sync_commands(
  self : App,
  client : @dhttp.Client,
  application_id : @model.ApplicationId,
  scope~ : CommandScope,
  unowned~ : @framework.UnownedCommands = Delete,
) -> SyncReport {
  let specs = self.commands_.map(command => command.definition)
  let scopes = []
  // Plan each scope just before applying it, so no plan waits on another
  // scope's requests and goes stale.
  for guild_id in scope_guilds(scope) {
    scopes.push(
      @framework.plan_command_scope(
        client,
        application_id,
        specs,
        unowned~,
        guild_id?,
      ).apply(client),
    )
  }
  { scopes, }
}

///|
/// The scopes a `CommandScope` names, `None` for the global one.
fn scope_guilds(scope : CommandScope) -> Array[@model.GuildId?] {
  match scope {
    Global => [None]
    Guild(guild_id) => [Some(guild_id)]
    Guilds(guild_ids) => guild_ids.map(guild_id => Some(guild_id))
  }
}

///|
/// Run the error policy for a failure raised outside interaction
/// dispatch. Executors use this for event and service handlers; the
/// policy context has no response target in that case.
pub async fn App::report_failure(
  self : App,
  origin : FailureOrigin,
  error : Error,
) -> Unit {
  invoke_error_policy(
    self.current_policy(),
    failure_context(origin, None, self.warn_),
    error,
  )
}

///|
/// Emit a structured warning through the app's warning hook.
pub fn App::warn(self : App, warning : AppWarning) -> Unit {
  (self.warn_)(warning)
}