///|
/// One file to upload in a `multipart/form-data` request, next to the
/// `payload_json` part. Construct with `FileUpload(...)`.
pub struct FileUpload {
  priv filename : String
  priv content : Bytes
  priv content_type : String
  priv description : String?
  priv title : String?
  priv duration_secs : Double?
  priv waveform : String?
  priv is_spoiler : Bool?
}

///|
/// Keep uploaded bytes out of diagnostics while allowing captured interaction
/// outcomes to derive Debug.
pub impl Debug for FileUpload with fn to_repr(self) {
  Repr(
    (self.filename, self.content.length(), self.content_type, self.description),
  )
}

///|
/// Describe one file to upload. `description` becomes the attachment's
/// alt text where the endpoint supports it; `title`, `duration_secs`,
/// `waveform`, and `is_spoiler` fill the matching attachment-request fields
/// (the latter two are required for voice messages).
///
/// ```mbt check
/// test "describe attachments in payload_json" {
///   let files = [
///     @http.FileUpload(
///       "cat.png",
///       b"PNG",
///       content_type="image/png",
///       description="a cat",
///       is_spoiler=true,
///     ),
///   ]
///   json_inspect(@http.attachments_json(files), content=[
///     {
///       "id": 0,
///       "filename": "cat.png",
///       "description": "a cat",
///       "is_spoiler": true,
///     },
///   ])
/// }
/// ```
pub fn FileUpload::FileUpload(
  filename : String,
  content : Bytes,
  content_type? : String = "application/octet-stream",
  description? : String,
  title? : String,
  duration_secs? : Double,
  waveform? : String,
  is_spoiler? : Bool,
) -> FileUpload {
  {
    filename,
    content,
    content_type,
    description,
    title,
    duration_secs,
    waveform,
    is_spoiler,
  }
}

///|
/// Reject upload metadata that could break out of a multipart part header.
fn validate_files(files : Array[FileUpload]) -> Unit raise DiscordHttpError {
  for file in files {
    if file.filename.is_empty() {
      raise Validation(message="filename must not be empty")
    }
    if file.filename.contains("\r") ||
      file.filename.contains("\n") ||
      file.filename.contains("\"") {
      raise Validation(
        message="filename must not contain CR, LF, or double quotes",
      )
    }
    if file.content_type.contains("\r") || file.content_type.contains("\n") {
      raise Validation(message="content_type must not contain CR or LF")
    }
  }
}

///|
/// The `attachments` array announcing uploaded files inside `payload_json`:
/// `[{ "id": n, "filename": ..., "description"? : ... }]`. The typed
/// wrappers add this automatically; use it when building an
/// `InteractionResponse` with files by hand.
pub fn attachments_json(files : Array[FileUpload]) -> Json {
  let items : Array[Json] = []
  for i, file in files {
    items.push(
      @model.ObjBuilder()
      .field("id", i)
      .field("filename", file.filename)
      .opt("description", file.description)
      .opt("title", file.title)
      .opt("duration_secs", file.duration_secs)
      .opt("waveform", file.waveform)
      .opt("is_spoiler", file.is_spoiler)
      .build(),
    )
  }
  Json::array(items)
}

///|
/// The `attachments` array for an edit: retained attachments first (by
/// snowflake id), then one entry per new upload with the numeric ids the
/// `files[n]` multipart parts require. `Some([])` with no files clears every
/// attachment; `None` leaves the retained set implicit (new files replace
/// the whole list, no files leaves it untouched).
fn merged_attachments_json(
  keep : Array[@model.AttachmentRequest],
  files : Array[FileUpload],
) -> Json {
  let items : Array[Json] = []
  for request in keep {
    items.push(request.to_json())
  }
  match attachments_json(files) {
    Array(uploads) => items.append(uploads)
    _ => ()
  }
  Json::array(items)
}

///|
/// Attach the edit-time `attachments` field: an explicit `keep` list is
/// always sent (merged with any new uploads, `[]` clears), while without it
/// the field only announces fresh uploads, matching the create path.
fn edit_attachments_field(
  body : @model.ObjBuilder,
  keep : Array[@model.AttachmentRequest]?,
  files : Array[FileUpload]?,
) -> @model.ObjBuilder {
  match (keep, files) {
    (Some(kept), _) =>
      body.field(
        "attachments",
        merged_attachments_json(kept, files.unwrap_or([])),
      )
    (None, Some(fs)) if fs.length() > 0 =>
      body.field("attachments", attachments_json(fs))
    _ => body
  }
}

///|
fn ascii_bytes(text : String) -> Bytes {
  let out : Array[Byte] = Array::new(capacity=text.length())
  for ch in text {
    out.push(ch.to_int().to_byte())
  }
  Bytes::from_array(out)
}

///|
fn bytes_contains(haystack : Bytes, needle : Bytes) -> Bool {
  let n = needle.length()
  if n == 0 {
    return true
  }
  for start in 0..<=(haystack.length() - n) {
    for j in 0.. Bool {
  // the candidate is pure ASCII, so a byte-level match in UTF-8 content
  // implies (and is implied by) a character-level match
  if payload.contains(candidate) {
    return true
  }
  let needle = ascii_bytes(candidate)
  for file in files {
    if bytes_contains(file.content, needle) {
      return true
    }
  }
  false
}

///|
/// Pick a boundary that provably occurs in none of the parts. Uniqueness
/// comes from checking, not from entropy, so a weak seed is fine.
fn fresh_boundary(
  seed : String,
  payload : String,
  files : Array[FileUpload],
) -> String {
  for k = 0; ; k = k + 1 {
    let candidate = "discordmbt-\{seed}-\{k}"
    if !boundary_taken(candidate, payload, files) {
      return candidate
    }
  }
}

///|
/// One chunk of a multipart body, written to the wire in order. File
/// contents stay as their original `Bytes` (no copying or concatenation).
pub enum MultipartChunk {
  Text(String)
  Blob(Bytes)
}

///|
/// Render the multipart body: the `payload_json` part first, then one
/// `files[n]` part per file, then the closing delimiter.
fn multipart_parts(
  boundary : String,
  payload : String,
  files : Array[FileUpload],
) -> Array[MultipartChunk] {
  let parts : Array[MultipartChunk] = []
  parts.push(
    Text(
      "--\{boundary}\r\n" +
      "content-disposition: form-data; name=\"payload_json\"\r\n" +
      "content-type: application/json\r\n\r\n" +
      payload +
      "\r\n",
    ),
  )
  for i, file in files {
    parts.push(
      Text(
        "--\{boundary}\r\n" +
        "content-disposition: form-data; name=\"files[\{i}]\"; filename=\"\{file.filename}\"\r\n" +
        "content-type: \{file.content_type}\r\n\r\n",
      ),
    )
    parts.push(Blob(file.content))
    parts.push(Text("\r\n"))
  }
  parts.push(Text("--\{boundary}--\r\n"))
  parts
}

///|
/// Render a multipart body with the `payload_json` part followed by one file
/// part under a custom field name. The invite target-users upload uses this
/// shape (`target_users_file` next to the JSON params).
fn multipart_payload_with_named_file(
  boundary : String,
  payload : String,
  file_field : String,
  file : FileUpload,
) -> Array[MultipartChunk] {
  let parts : Array[MultipartChunk] = []
  parts.push(
    Text(
      "--\{boundary}\r\n" +
      "content-disposition: form-data; name=\"payload_json\"\r\n" +
      "content-type: application/json\r\n\r\n" +
      payload +
      "\r\n",
    ),
  )
  parts.push(
    Text(
      "--\{boundary}\r\n" +
      "content-disposition: form-data; name=\"\{file_field}\"; filename=\"\{file.filename}\"\r\n" +
      "content-type: \{file.content_type}\r\n\r\n",
    ),
  )
  parts.push(Blob(file.content))
  parts.push(Text("\r\n--\{boundary}--\r\n"))
  parts
}

///|
/// Render a conventional multipart form with text fields and one named file.
/// Discord's guild-sticker and invite target-user endpoints use this shape
/// instead of payload_json plus files[n].
fn multipart_form_parts(
  boundary : String,
  fields : Array[(String, String)],
  file_field : String,
  file : FileUpload,
) -> Array[MultipartChunk] {
  let parts : Array[MultipartChunk] = []
  for field in fields {
    let (name, value) = field
    parts.push(
      Text(
        "--\{boundary}\r\n" +
        "content-disposition: form-data; name=\"\{name}\"\r\n\r\n" +
        value +
        "\r\n",
      ),
    )
  }
  parts.push(
    Text(
      "--\{boundary}\r\n" +
      "content-disposition: form-data; name=\"\{file_field}\"; filename=\"\{file.filename}\"\r\n" +
      "content-type: \{file.content_type}\r\n\r\n",
    ),
  )
  parts.push(Blob(file.content))
  parts.push(Text("\r\n--\{boundary}--\r\n"))
  parts
}

///|
let multipart_seq : Ref[Int] = Ref(0)

///|
/// A per-process sequence number so two requests in the same millisecond
/// still start from different boundary seeds.
fn next_multipart_seq() -> Int {
  multipart_seq.val += 1
  multipart_seq.val
}

///|
/// Encode an interaction-callback `multipart/form-data` body: the
/// `payload_json` part followed by one `files[n]` part per file, in the same
/// wire format as the REST upload path. Returns the `Content-Type` value
/// (including the boundary) and the ordered wire chunks. Filenames are
/// validated like the REST upload path.
///
/// ```mbt check
/// test "keep uploaded bytes as a multipart blob chunk" {
///   let (content_type, chunks) = @http.encode_multipart_body("{\"type\":4}", [
///     FileUpload("hello.txt", b"hello", content_type="text/plain"),
///   ])
///   assert_true(content_type.has_prefix("multipart/form-data; boundary="))
///   match chunks {
///     [Text(payload), Text(file_header), Blob(content), ..] => {
///       assert_true(payload.contains("payload_json"))
///       assert_true(file_header.contains("files[0]"))
///       assert_eq(content, b"hello")
///     }
///     _ => fail("unexpected multipart chunk layout")
///   }
/// }
/// ```
pub fn encode_multipart_body(
  payload_json : String,
  files : Array[FileUpload],
) -> (String, Array[MultipartChunk]) raise DiscordHttpError {
  validate_files(files)
  let seed = "\{@clock.now_ms()}-\{next_multipart_seq()}"
  let boundary = fresh_boundary(seed, payload_json, files)
  (
    "multipart/form-data; boundary=\{boundary}",
    multipart_parts(boundary, payload_json, files),
  )
}