///|
/// The result of dispatching one HTTP-delivered interaction.
#non_exhaustive
pub(all) enum InteractionOutcome {
  Reply(
    response~ : @model.InteractionResponse,
    files~ : Array[@dhttp.FileUpload]?
  )
  NoRoute
  NoResponse
  TimedOut
} derive(Debug)

///|
/// A gateway-free interaction dispatcher backed by a caller-owned task group.
pub struct InteractionEndpoint {
  priv framework : @framework.Framework
  priv group : @async.TaskGroup[Unit]
  priv warn : (AppWarning) -> Unit
}

///|
priv enum InteractionRace {
  Captured(@framework.CapturedResponse)
  Completed(Bool)
}

///|
/// How an executor reaches Discord's REST API: with a bot token, from which it
/// builds and owns a client, or through an existing client the caller owns.
pub(all) enum RestAccess {
  /// Build a client from this bot token. The executor closes it when done.
  Token(String)
  /// Use this client. The caller keeps ownership and closes it.
  Client(@dhttp.Client)
}

///|
/// Build a gateway-free dispatcher.
///
/// With `access=Token(token)` the endpoint owns a client built from the token
/// and closes it when `group` finishes; with `access=Client(client)` it uses
/// the caller's client. When `application_id` is omitted, this method resolves
/// it through `GET /applications/@me`. Supplying a client and the id avoids
/// startup HTTP requests. `clock` times callback deadlines, handler
/// admission and the App's default cooldown store, and drives the client
/// built from a token. Raises `AppConfigError::EmptyToken` for an empty token.
///
/// Command registration is a deploy-time step. Call `app.sync_commands(...)`
/// from a one-shot program (see `examples/workers_echo/register`), never
/// per request.
pub async fn App::serve(
  self : App,
  group : @async.TaskGroup[Unit],
  access~ : RestAccess,
  application_id? : @model.ApplicationId,
  clock? : &@hclock.Clock,
) -> InteractionEndpoint {
  let (client, owns_client) = match access {
    Client(client) => (client, false)
    Token(token) => {
      guard !token.is_empty() else { raise AppConfigError::EmptyToken }
      (@dhttp.Client(token, clock?), true)
    }
  }
  let application_id = match application_id {
    Some(id) => id
    None => {
      // A failed lookup must not leave an owned client open until the
      // caller's (possibly long-lived) group ends.
      errdefer (if owns_client { client.close() })
      client.get_current_application().id
    }
  }
  if owns_client {
    group.add_defer(() => client.close())
  }
  let framework = @framework.Framework(client, application_id, clock?)
  self.attach(framework, client~, application_id~) |> ignore
  { framework, group, warn: self.warn_, }
}

///|
/// Dispatch one interaction and wait only for its initial response deadline.
/// `deadline_ms` is measured from this call: routing starts immediately, and
/// time spent waiting for handler capacity counts against it. A timed-out
/// handler remains attached to the app's task group and continues in the
/// background.
pub async fn InteractionEndpoint::handle_interaction(
  self : InteractionEndpoint,
  interaction : @model.Interaction,
  deadline_ms~ : Int32 = 2500,
) -> InteractionOutcome {
  if interaction.typ == Ping {
    return Reply(response={ typ: Pong, data: None, }, files=None)
  }
  let clock = self.framework.clock()
  let (gate, capture) = @framework.ResponseGate::capture(
    interaction,
    deadline_ms~,
    clock~,
  )
  let race : @aqueue.Queue[InteractionRace] = Queue(kind=Unbounded)
  self.group.spawn_bg(allow_failure=true, () => {
    let routed = self.framework.process_with(interaction, gate~) catch {
      error if @async.is_being_cancelled() => raise error
      error => {
        (self.warn)(InteractionProcessingFailed(error~))
        true
      }
    }
    if gate.is_pending() {
      race.put(Completed(routed))
    }
  })
  @async.with_task_group(group => {
    let capture_task = group.spawn(no_wait=true, allow_failure=true, () => {
      let captured = capture.get()
      race.put(Captured(captured))
      captured
    })
    let result = match
      @transport.with_timeout_opt(clock, deadline_ms, () => race.get()) {
      Some(Captured(captured)) =>
        Reply(response=captured.response, files=captured.files)
      Some(Completed(true)) => NoResponse
      Some(Completed(false)) => NoRoute
      None => {
        gate.expire()
        // The timeout may win after race.get() consumed a captured reply.
        // Retain it in the task so a callback that left Pending is not lost.
        if gate.state() is Sent(_) {
          let captured = capture_task.wait()
          Reply(response=captured.response, files=captured.files)
        } else {
          TimedOut
        }
      }
    }
    capture_task.cancel()
    result
  })
}

///|
/// Decode and dispatch an interaction JSON body.
///
/// Returns callback JSON only for `Reply`. Multipart files cannot be expressed
/// by this thin JSON API; callers that accept uploads should use
/// `handle_interaction` and inspect `InteractionOutcome::Reply.files`.
pub async fn InteractionEndpoint::handle(
  self : InteractionEndpoint,
  body : Json,
  deadline_ms~ : Int32 = 2500,
) -> Json? {
  let interaction : @model.Interaction = @json.from_json(body)
  match self.handle_interaction(interaction, deadline_ms~) {
    Reply(response~, files=_) => Some(response.to_json())
    NoRoute | NoResponse | TimedOut => None
  }
}