///|
fn Client::with_channel_allowed_mentions(
  self : Client,
  builder : @model.ObjBuilder,
  allowed_mentions : @model.AllowedMentions?,
) -> @model.ObjBuilder {
  builder.field(
    "allowed_mentions",
    allowed_mentions.unwrap_or(self.default_allowed_mentions),
  )
}

///|
/// Post a message to a channel. Labeled optional arguments replace the
/// builder pattern; at least one of `content`, `embeds`, `components`,
/// `sticker_ids`, `files`, `poll`, or `shared_client_theme` must be
/// provided — unless `message_reference` forwards another message, which
/// needs nothing else.
///
/// `reply_to` is shorthand for a same-channel reply; pass a full
/// `message_reference` instead for forwards, cross-channel references, or
/// `fail_if_not_exists`. The two are mutually exclusive.
///
/// Message-shaped endpoints switch to multipart automatically when `files`
/// is present (see `FileUpload` and `attachments_json` for the wire format).
/// The same `files` argument is available on interaction responses,
/// followups, and edit methods. On edit, the supplied files replace the
/// attachment list sent in that request unless `keep_attachments` lists the
/// ones to retain.
///
/// ```mbt check
/// test "upload a file alongside message content" {
///   async fn send_report(
///     client : @http.Client,
///     channel_id : @model.ChannelId,
///     report : Bytes,
///   ) -> @model.Message {
///     let file = @http.FileUpload(
///       "report.csv",
///       report,
///       content_type="text/csv",
///       description="Daily report",
///     )
///     client.create_message(channel_id, content="Report attached", files=[file])
///   }
///
///   ignore(send_report)
/// }
/// ```
pub async fn Client::create_message(
  self : Client,
  channel_id : @model.ChannelId,
  content? : String,
  embeds? : Array[@model.Embed],
  components? : Array[@model.Component],
  sticker_ids? : Array[@model.StickerId],
  files? : Array[FileUpload],
  reply_to? : @model.MessageId,
  tts? : Bool,
  flags? : @model.MessageFlags,
  allowed_mentions? : @model.AllowedMentions,
  nonce? : @model.Nonce,
  enforce_nonce? : Bool,
  poll? : @model.PollCreateRequest,
  message_reference? : @model.MessageReference,
  shared_client_theme? : @model.SharedClientTheme,
) -> @model.Message raise DiscordHttpError {
  validate_content(content)
  if reply_to is Some(_) && message_reference is Some(_) {
    raise Validation(
      message="reply_to and message_reference are mutually exclusive",
    )
  }
  if content is None &&
    embeds is None &&
    components is None &&
    sticker_ids is None &&
    files is None &&
    poll is None &&
    shared_client_theme is None &&
    message_reference is None {
    raise Validation(
      message="one of content/embeds/components/sticker_ids/files/poll/shared_client_theme is required",
    )
  }
  let flags = message_flags_with_components_v2(
    flags,
    content,
    embeds,
    components,
    sticker_ids?,
  )
  let body = self
    .with_channel_allowed_mentions(
      @model.ObjBuilder()
      .opt("content", content)
      .opt("embeds", embeds)
      .opt("components", components)
      .opt("sticker_ids", sticker_ids)
      .opt("tts", tts)
      .opt("flags", flags),
      allowed_mentions,
    )
    .opt("nonce", nonce)
    .opt("enforce_nonce", enforce_nonce)
    .opt("poll", poll)
    .opt("message_reference", message_reference)
    .opt("shared_client_theme", shared_client_theme)
  if reply_to is Some(message_id) {
    body.field("message_reference", {
      "message_id": Json::string(message_id.to_string()),
    })
    |> ignore
  }
  if files is Some(fs) && fs.length() > 0 {
    body.field("attachments", attachments_json(fs)) |> ignore
  }
  decode(self.request(CreateMessage(channel_id~), body=body.build(), files?))
}

///|
/// Edit a previously sent message. `keep_attachments` lists the existing
/// attachments to retain (optionally updating their description or spoiler
/// state); `keep_attachments=[]` with no files removes them all. Without
/// it, supplying `files` replaces the whole attachment list (Discord keeps
/// only the attachments named in the request).
pub async fn Client::edit_message(
  self : Client,
  channel_id : @model.ChannelId,
  message_id : @model.MessageId,
  content? : String,
  clear_content? : Bool = false,
  embeds? : Array[@model.Embed],
  components? : Array[@model.Component],
  flags? : @model.MessageFlags,
  allowed_mentions? : @model.AllowedMentions,
  files? : Array[FileUpload],
  keep_attachments? : Array[@model.AttachmentRequest],
) -> @model.Message raise DiscordHttpError {
  let builder = edit_attachments_field(
    self.with_channel_allowed_mentions(
      message_patch_builder(content, clear_content, embeds, components, flags),
      allowed_mentions,
    ),
    keep_attachments,
    files,
  )
  decode(
    self.request(
      EditMessage(channel_id~, message_id~),
      body=builder.build(),
      files?,
    ),
  )
}

///|
/// Delete a message.
pub async fn Client::delete_message(
  self : Client,
  channel_id : @model.ChannelId,
  message_id : @model.MessageId,
  audit_reason? : String,
) -> Unit raise DiscordHttpError {
  self.request(DeleteMessage(channel_id~, message_id~), audit_reason?) |> ignore
}

///|
fn validate_bulk_delete_messages(
  messages : Array[@model.MessageId],
) -> Unit raise DiscordHttpError {
  if messages.length() < 2 || messages.length() > 100 {
    raise Validation(message="bulk delete requires between 2 and 100 messages")
  }
}

///|
/// Delete between 2 and 100 messages in one request.
pub async fn Client::bulk_delete_messages(
  self : Client,
  channel_id : @model.ChannelId,
  messages : Array[@model.MessageId],
  audit_reason? : String,
) -> Unit raise DiscordHttpError {
  validate_bulk_delete_messages(messages)
  let body = @model.ObjBuilder().field("messages", messages).build()
  self.request(BulkDeleteMessages(channel_id~), body~, audit_reason?) |> ignore
}

///|
/// Fetch a single message.
pub async fn Client::get_message(
  self : Client,
  channel_id : @model.ChannelId,
  message_id : @model.MessageId,
) -> @model.Message raise DiscordHttpError {
  decode(self.request(GetChannelMessage(channel_id~, message_id~)))
}

///|
/// Publish a message from an announcement channel to its followers.
pub async fn Client::crosspost_message(
  self : Client,
  channel_id : @model.ChannelId,
  message_id : @model.MessageId,
) -> @model.Message raise DiscordHttpError {
  decode(self.request(CrosspostMessage(channel_id~, message_id~)))
}

///|
/// List messages in a channel. `around`/`before`/`after` are mutually
/// exclusive.
pub async fn Client::get_channel_messages(
  self : Client,
  channel_id : @model.ChannelId,
  limit? : Int,
  around? : @model.MessageId,
  before? : @model.MessageId,
  after? : @model.MessageId,
) -> Array[@model.Message] raise DiscordHttpError {
  validate_page_limit("message", limit, 100)
  let cursor_count = (if around is Some(_) { 1 } else { 0 }) +
    (if before is Some(_) { 1 } else { 0 }) +
    (if after is Some(_) { 1 } else { 0 })
  if cursor_count > 1 {
    raise Validation(
      message="message pagination accepts only one of around, before, or after",
    )
  }
  let params : Array[String] = []
  if limit is Some(n) {
    params.push("limit=\{n}")
  }
  if around is Some(id) {
    params.push("around=\{id}")
  }
  if before is Some(id) {
    params.push("before=\{id}")
  }
  if after is Some(id) {
    params.push("after=\{id}")
  }
  let query = if params.length() == 0 { "" } else { "?" + params.join("&") }
  decode(self.request(GetChannelMessages(channel_id~, query~)))
}

///|
fn percent_encode_query_text(value : String) -> String {
  let builder = StringBuilder(size_hint=value.length())
  for byte in @utf8.encode(value).iter() {
    let value = byte.to_int()
    let unreserved = (value >= 65 && value <= 90) ||
      (value >= 97 && value <= 122) ||
      (value >= 48 && value <= 57) ||
      value == 45 ||
      value == 46 ||
      value == 95 ||
      value == 126
    if unreserved {
      builder.write_char(byte.to_char())
    } else {
      builder.write_char('%')
      builder.write_string(byte.to_hex().to_upper())
    }
  }
  builder.to_string()
}

///|
fn validate_search_array_count(
  name : String,
  values_length : Int,
  maximum : Int,
) -> Unit raise DiscordHttpError {
  if values_length > maximum {
    raise Validation(message="\{name} exceeds \{maximum} entries")
  }
}

///|
fn search_guild_messages_query(
  limit? : Int,
  offset? : Int,
  slop? : Int,
  content? : String,
  mention_everyone? : Bool,
  pinned? : Bool,
  include_nsfw? : Bool,
  sort_by? : String,
  sort_order? : String,
  max_id? : @model.MessageId,
  min_id? : @model.MessageId,
  channel_id? : Array[@model.ChannelId],
  author_type? : Array[String],
  author_id? : Array[@model.UserId],
  mentions? : Array[@model.UserId],
  mentions_role_id? : Array[@model.RoleId],
  replied_to_user_id? : Array[@model.UserId],
  replied_to_message_id? : Array[@model.MessageId],
  has? : Array[String],
  embed_type? : Array[String],
  embed_provider? : Array[String],
  link_hostname? : Array[String],
  attachment_filename? : Array[String],
  attachment_extension? : Array[String],
) -> String raise DiscordHttpError {
  validate_range("search limit", limit, min=1, max=25)
  validate_range("search offset", offset, min=0, max=9975)
  validate_range("search slop", slop, min=0, max=100)
  validate_length("search content", content, max=1024)
  if channel_id is Some(values) {
    validate_search_array_count("channel_id", values.length(), 500)
  }
  if author_id is Some(values) {
    validate_search_array_count("author_id", values.length(), 100)
  }
  if mentions is Some(values) {
    validate_search_array_count("mentions", values.length(), 100)
  }
  if mentions_role_id is Some(values) {
    validate_search_array_count("mentions_role_id", values.length(), 100)
  }
  if replied_to_user_id is Some(values) {
    validate_search_array_count("replied_to_user_id", values.length(), 100)
  }
  if replied_to_message_id is Some(values) {
    validate_search_array_count("replied_to_message_id", values.length(), 100)
  }
  if embed_provider is Some(values) {
    validate_search_array_count("embed_provider", values.length(), 100)
  }
  if link_hostname is Some(values) {
    validate_search_array_count("link_hostname", values.length(), 100)
  }
  if attachment_filename is Some(values) {
    validate_search_array_count("attachment_filename", values.length(), 100)
  }
  if attachment_extension is Some(values) {
    validate_search_array_count("attachment_extension", values.length(), 100)
  }
  let params : Array[String] = []
  if limit is Some(value) {
    params.push("limit=\{value}")
  }
  if offset is Some(value) {
    params.push("offset=\{value}")
  }
  if slop is Some(value) {
    params.push("slop=\{value}")
  }
  if content is Some(value) {
    params.push("content=" + percent_encode_query_text(value))
  }
  if mention_everyone is Some(value) {
    params.push("mention_everyone=\{value}")
  }
  if pinned is Some(value) {
    params.push("pinned=\{value}")
  }
  if include_nsfw is Some(value) {
    params.push("include_nsfw=\{value}")
  }
  if sort_by is Some(value) {
    params.push("sort_by=" + value)
  }
  if sort_order is Some(value) {
    params.push("sort_order=" + value)
  }
  if max_id is Some(value) {
    params.push("max_id=\{value}")
  }
  if min_id is Some(value) {
    params.push("min_id=\{value}")
  }
  if channel_id is Some(values) {
    for value in values {
      params.push("channel_id=\{value}")
    }
  }
  if author_type is Some(values) {
    for value in values {
      params.push("author_type=" + value)
    }
  }
  if author_id is Some(values) {
    for value in values {
      params.push("author_id=\{value}")
    }
  }
  if mentions is Some(values) {
    for value in values {
      params.push("mentions=\{value}")
    }
  }
  if mentions_role_id is Some(values) {
    for value in values {
      params.push("mentions_role_id=\{value}")
    }
  }
  if replied_to_user_id is Some(values) {
    for value in values {
      params.push("replied_to_user_id=\{value}")
    }
  }
  if replied_to_message_id is Some(values) {
    for value in values {
      params.push("replied_to_message_id=\{value}")
    }
  }
  if has is Some(values) {
    for value in values {
      params.push("has=" + value)
    }
  }
  if embed_type is Some(values) {
    for value in values {
      params.push("embed_type=" + value)
    }
  }
  if embed_provider is Some(values) {
    for value in values {
      params.push("embed_provider=" + percent_encode_query_text(value))
    }
  }
  if link_hostname is Some(values) {
    for value in values {
      params.push("link_hostname=" + percent_encode_query_text(value))
    }
  }
  if attachment_filename is Some(values) {
    for value in values {
      params.push("attachment_filename=" + percent_encode_query_text(value))
    }
  }
  if attachment_extension is Some(values) {
    for value in values {
      params.push("attachment_extension=" + percent_encode_query_text(value))
    }
  }
  if params.length() == 0 {
    ""
  } else {
    "?" + params.join("&")
  }
}

///|
/// Search messages in a guild. Results that contain message content require
/// the MESSAGE_CONTENT privileged intent, and the caller must have the
/// READ_MESSAGE_HISTORY permission in the channels being searched.
pub async fn Client::search_guild_messages(
  self : Client,
  guild_id : @model.GuildId,
  limit? : Int,
  offset? : Int,
  slop? : Int,
  content? : String,
  mention_everyone? : Bool,
  pinned? : Bool,
  include_nsfw? : Bool,
  sort_by? : String,
  sort_order? : String,
  max_id? : @model.MessageId,
  min_id? : @model.MessageId,
  channel_id? : Array[@model.ChannelId],
  author_type? : Array[String],
  author_id? : Array[@model.UserId],
  mentions? : Array[@model.UserId],
  mentions_role_id? : Array[@model.RoleId],
  replied_to_user_id? : Array[@model.UserId],
  replied_to_message_id? : Array[@model.MessageId],
  has? : Array[String],
  embed_type? : Array[String],
  embed_provider? : Array[String],
  link_hostname? : Array[String],
  attachment_filename? : Array[String],
  attachment_extension? : Array[String],
) -> @model.GuildMessageSearchResults raise DiscordHttpError {
  let query = search_guild_messages_query(
    limit?,
    offset?,
    slop?,
    content?,
    mention_everyone?,
    pinned?,
    include_nsfw?,
    sort_by?,
    sort_order?,
    max_id?,
    min_id?,
    channel_id?,
    author_type?,
    author_id?,
    mentions?,
    mentions_role_id?,
    replied_to_user_id?,
    replied_to_message_id?,
    has?,
    embed_type?,
    embed_provider?,
    link_hostname?,
    attachment_filename?,
    attachment_extension?,
  )
  decode(self.request(SearchGuildMessages(guild_id~, query~)))
}