///|
/// An error raised when an interaction callback cannot be accepted.
pub(all) suberror ResponseGateError {
  AlreadyResponded
  Expired
  InvalidCallback(
    kind~ : @model.InteractionType,
    typ~ : @model.InteractionResponseType
  )
} derive(Debug)

///|
/// What has happened to an interaction's single initial callback.
pub(all) enum ResponseState {
  Pending
  Sent(@model.InteractionResponseType)
  /// The sink raised; Discord may or may not have accepted the callback.
  Unconfirmed(@model.InteractionResponseType)
  /// The executor gave up the callback window.
  Expired
} derive(Debug, Eq)

///|
/// How a failed callback delivery left the interaction's callback window.
#non_exhaustive
pub(all) enum DeliveryFailure {
  /// The callback was definitively refused, by Discord or by client-side
  /// validation; the window is still unused.
  Rejected
  /// The callback never reached Discord, so Discord cannot have accepted
  /// it; the window is still unused and the same callback may be sent again
  /// while it is open.
  Unsent
  /// Discord no longer accepts any initial callback for this interaction.
  Expired
  /// Discord may or may not have accepted the callback.
  Uncertain
} derive(Debug, Eq)

///|
/// A single-use interaction callback gate.
pub struct ResponseGate {
  priv kind : @model.InteractionType
  priv mut state : ResponseState
  priv sink : async (@model.InteractionResponse, Array[@dhttp.FileUpload]?) -> Unit
  priv classify : (Error) -> DeliveryFailure
  priv deadline_at : Int64?
  // When the executor received the interaction: construction time.
  priv received_at : Int64
  priv mut placeholder_open : Bool
  priv mut in_flight : Bool
  priv settled : @cond_var.Cond
  priv reply_lock : @async.Mutex
  priv clock : &@hclock.Clock
}

///|
/// A callback plus any multipart files captured by an in-memory gate.
pub(all) struct CapturedResponse {
  response : @model.InteractionResponse
  files : Array[@dhttp.FileUpload]?
}

///|
/// The receiving side of a capture gate.
pub struct ResponseCapture {
  priv queue : @aqueue.Queue[CapturedResponse]
}

///|
/// The default initial-response budget in milliseconds, measured from the
/// moment an executor receives an interaction. Discord invalidates the
/// interaction token three seconds after sending it; the remaining 500 ms
/// absorb delivery latency.
pub const DEFAULT_INTERACTION_DEADLINE_MS : Int32 = 2500

///|
/// How long an interaction token stays valid after Discord sends the
/// interaction: every followup, original-response edit, and deletion must
/// happen within it.
pub const INTERACTION_TOKEN_LIFETIME_MS : Int32 = 900_000

///|
/// Construct a gate around an injected callback sink.
///
/// `classify` decides how a failed delivery leaves the callback window; it
/// defaults to `Uncertain`. Cancellation is always `Uncertain`, regardless of
/// the classifier. With `deadline_ms`, the window closes that many
/// milliseconds after construction: a later `send` expires the gate instead
/// of contacting Discord. `clock` times the deadline and the token lifetime.
pub fn ResponseGate::ResponseGate(
  kind : @model.InteractionType,
  sink : async (@model.InteractionResponse, Array[@dhttp.FileUpload]?) -> Unit,
  classify~ : (Error) -> DeliveryFailure = _ => Uncertain,
  deadline_ms? : Int32,
  clock~ : &@hclock.Clock = @transport.AsyncClock::new(),
) -> ResponseGate {
  let now = clock.now_unix_ms()
  {
    kind,
    state: Pending,
    sink,
    classify,
    deadline_at: deadline_ms.map(ms => now + ms.to_int64()),
    received_at: now,
    placeholder_open: false,
    in_flight: false,
    settled: @cond_var.Cond(),
    reply_lock: @async.Mutex(),
    clock,
  }
}

///|
/// Classify a failed `POST /interactions/{id}/{token}/callback`.
///
/// Unknown Interaction (10062) closes the window. A transport failure or
/// timeout that proves nothing was sent (`DiscordHttpError::is_unsent`) is
/// `Unsent`; the client has already resent it as often as `max_retries`
/// allows. Already Acknowledged (40060), rate limits, server errors, and
/// other transport failures and timeouts are uncertain. Other client errors
/// are definitive refusals of that payload.
pub fn classify_rest_callback_failure(error : Error) -> DeliveryFailure {
  match error {
    @dhttp.DiscordHttpError::Validation(..) => Rejected
    // `DiscordHttpError::is_unsent` for the failures it covers beyond
    // `Validation`.
    @dhttp.DiscordHttpError::Transport(phase=Pool | Dial, ..)
    | @dhttp.DiscordHttpError::Timeout(phase=Pool | Dial, ..) => Unsent
    @dhttp.DiscordHttpError::Api(status~, error~) =>
      if error.code == 10062 {
        Expired
      } else if status >= 400 &&
        status < 500 &&
        status != 429 &&
        error.code != 40060 {
        Rejected
      } else {
        Uncertain
      }
    _ => Uncertain
  }
}

///|
/// Construct a gate that delivers its callback through Discord's REST API.
/// The deadline is measured from this call, so construct the gate as soon as
/// the interaction arrives.
pub fn ResponseGate::rest(
  client : @dhttp.Client,
  interaction : @model.Interaction,
  deadline_ms~ : Int32 = DEFAULT_INTERACTION_DEADLINE_MS,
  clock~ : &@hclock.Clock = @transport.AsyncClock::new(),
) -> ResponseGate {
  ResponseGate(
    interaction.typ,
    (response, files) => {
      client.create_interaction_response(
        interaction.id,
        interaction.token,
        response,
        files?,
      )
    },
    classify=classify_rest_callback_failure,
    deadline_ms~,
    clock~,
  )
}

///|
/// Construct an in-memory gate and a receiver for the first callback value.
/// The deadline is measured from this call.
pub fn ResponseGate::capture(
  interaction : @model.Interaction,
  deadline_ms~ : Int32 = DEFAULT_INTERACTION_DEADLINE_MS,
  clock~ : &@hclock.Clock = @transport.AsyncClock::new(),
) -> (ResponseGate, ResponseCapture) {
  let queue : @aqueue.Queue[CapturedResponse] = Queue(kind=Blocking(1))
  let capture = ResponseCapture::{ queue, }
  let gate = ResponseGate(
    interaction.typ,
    (response, files) => queue.put({ response, files, }),
    deadline_ms~,
    clock~,
  )
  (gate, capture)
}

///|
fn callback_allowed(
  kind : @model.InteractionType,
  typ : @model.InteractionResponseType,
) -> Bool {
  match kind {
    Ping => typ == Pong
    ApplicationCommand =>
      typ == ChannelMessageWithSource ||
      typ == DeferredChannelMessageWithSource ||
      typ == Modal ||
      typ == LaunchActivity
    MessageComponent =>
      typ == ChannelMessageWithSource ||
      typ == DeferredChannelMessageWithSource ||
      typ == DeferredUpdateMessage ||
      typ == UpdateMessage ||
      typ == Modal ||
      typ == LaunchActivity
    ApplicationCommandAutocomplete => typ == Autocomplete
    ModalSubmit =>
      typ == ChannelMessageWithSource ||
      typ == DeferredChannelMessageWithSource ||
      typ == DeferredUpdateMessage ||
      typ == UpdateMessage
    Unknown(_) | @model.InteractionType::.. => true
  }
}

///|
/// Validate and deliver the one allowed initial interaction callback.
/// A call made while another callback is being delivered waits for that
/// delivery to settle, then observes its outcome.
pub async fn ResponseGate::send(
  self : ResponseGate,
  response : @model.InteractionResponse,
  files? : Array[@dhttp.FileUpload],
) -> Unit {
  self.wait_settled()
  match self.state() {
    Pending => ()
    Expired => raise ResponseGateError::Expired
    Sent(_) | Unconfirmed(_) => raise ResponseGateError::AlreadyResponded
  }
  if !callback_allowed(self.kind, response.typ) {
    raise ResponseGateError::InvalidCallback(kind=self.kind, typ=response.typ)
  }
  self.state = Sent(response.typ)
  // Mark the placeholder before the sink runs so a concurrent `reply` never
  // mistakes an in-flight deferral for a plain message.
  self.placeholder_open = response.typ == DeferredChannelMessageWithSource
  self.in_flight = true
  defer {
    self.in_flight = false
    self.settled.broadcast()
  }
  errdefer (if self.state is Sent(_) { self.state = Unconfirmed(response.typ) })
  (self.sink)(response, files) catch {
    error if @async.is_being_cancelled() => raise error
    error => {
      match (self.classify)(error) {
        Rejected | Unsent => {
          self.state = Pending
          self.placeholder_open = false
        }
        Expired => {
          self.state = Expired
          self.placeholder_open = false
        }
        Uncertain => ()
      }
      raise error
    }
  }
}

///|
/// Run one follow-up reply at a time. A reply decides between editing the
/// placeholder and sending a followup, then performs the request; without
/// this, two concurrent replies could both see the placeholder open and the
/// second edit would overwrite the first reply.
async fn[T] ResponseGate::exclusive_reply(
  self : ResponseGate,
  body : async () -> T,
) -> T {
  self.reply_lock.acquire()
  defer self.reply_lock.release()
  body()
}

///|
/// Wait until no initial callback is being delivered. Follow-up operations
/// call this so they never race an in-flight callback.
async fn ResponseGate::wait_settled(self : ResponseGate) -> Unit {
  while self.in_flight {
    self.settled.wait()
  }
}

///|
/// Milliseconds from `now` until `deadline`: zero once it has passed, and at
/// most the largest `Int32`.
fn ms_until(deadline : Int64, now : Int64) -> Int32 {
  let left = deadline - now
  if left <= 0L {
    0
  } else if left >= 2147483647L {
    2147483647
  } else {
    left.to_int()
  }
}

///|
/// Milliseconds left before the callback window closes, clamped at zero, or
/// `None` when this gate has no deadline.
pub fn ResponseGate::remaining_ms(self : ResponseGate) -> Int32? {
  self.deadline_at.map(deadline => ms_until(deadline, self.clock.now_unix_ms()))
}

///|
/// Milliseconds left before the interaction token expires, counted from this
/// gate's construction (when the executor received the interaction). Zero once
/// it has expired.
pub fn ResponseGate::token_remaining_ms(self : ResponseGate) -> Int32 {
  ms_until(
    self.received_at + INTERACTION_TOKEN_LIFETIME_MS.to_int64(),
    self.clock.now_unix_ms(),
  )
}

///|
/// Whether a deferred message's loading placeholder is still showing: the
/// callback was `DeferredChannelMessageWithSource` and the original response
/// has not been edited, deleted, or answered with a followup since.
pub fn ResponseGate::placeholder_open(self : ResponseGate) -> Bool {
  self.placeholder_open
}

///|
/// Record that the original response no longer shows the deferred
/// placeholder. Response helpers call this after editing or deleting the
/// original response, or after sending a followup.
pub fn ResponseGate::close_placeholder(self : ResponseGate) -> Unit {
  self.placeholder_open = false
}

///|
/// The current state of this interaction's initial callback. A pending
/// callback whose deadline has passed reads as `Expired`.
pub fn ResponseGate::state(self : ResponseGate) -> ResponseState {
  if self.state == Pending && self.remaining_ms() is Some(0) {
    self.state = Expired
  }
  self.state
}

///|
/// Close an unused callback window. Other states remain unchanged.
///
/// ```mbt check
/// test "expire an unused callback window" {
///   let gate = @framework.ResponseGate(ApplicationCommand, (_, _) => ())
///   assert_eq(gate.state(), Pending)
///   gate.expire()
///   assert_eq(gate.state(), Expired)
///   assert_false(gate.is_pending())
/// }
/// ```
pub fn ResponseGate::expire(self : ResponseGate) -> Unit {
  if self.state == Pending {
    self.state = Expired
  }
}

///|
/// Whether the initial callback can still be sent.
pub fn ResponseGate::is_pending(self : ResponseGate) -> Bool {
  self.state() == Pending
}

///|
/// Wait for and remove the captured callback.
pub async fn ResponseCapture::get(self : ResponseCapture) -> CapturedResponse {
  self.queue.get()
}

///|
/// Remove the captured callback without waiting, if one is available.
pub fn ResponseCapture::try_get(
  self : ResponseCapture,
) -> CapturedResponse? raise {
  self.queue.try_get()
}