// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// Which multipart type a written body announces.
///
/// RFC 2046 §5.1 is the framing either way; the subtype says what the parts
/// mean. `FormData` is the one a form upload carries (RFC 7578), `Mixed` the
/// one an email with an attachment carries (RFC 2046 §5.1.3).
#derive(Eq, Debug)
pub(all) enum MultipartKind {
  FormData
  Mixed
}

///|
pub extend MultipartKind with Eq::{equal, not_equal}

///|
pub extend MultipartKind with Debug::{to_repr}

///|
/// The subtype as it is written on the wire: the half that follows
/// `multipart/`.
pub fn MultipartKind::code(self : MultipartKind) -> String {
  match self {
    MultipartKind::FormData => "form-data"
    MultipartKind::Mixed => "mixed"
  }
}

///|
/// A multipart body on its way out: the octets, the boundary that delimits
/// them, and the subtype the two have to agree on.
///
/// The octets are what a `Content-Type` of `content_type()` describes and what
/// `multipart` reads back with the same `boundary`.
#derive(Eq, Debug)
pub(all) struct Multipart {
  kind : MultipartKind
  boundary : String
  body : Bytes
}

///|
pub extend Multipart with Eq::{equal, not_equal}

///|
pub extend Multipart with Debug::{to_repr}

///|
/// Frame `parts` as a multipart body (RFC 2046 §5.1).
///
/// A part's headers go out in the order the part holds them, with a lowercased
/// name — the shape a parse hands back — restored to `Content-Type` casing. A
/// part with no headers at all is still a part: it is delimited like any other
/// and its body is read back as it stands.
///
/// The boundary is generated when the caller gives none, from a fixed stem and
/// a single pass over the parts, so the same parts always come out as the same
/// octets and the boundary is proven absent from them rather than merely
/// unlikely. A caller that gives one keeps control of what the receiver sees —
/// the dash-stem browsers use, or a value it has already named elsewhere.
///
/// ```mbt check
/// test {
///   let form = @moonmime.Form::new(fields=[{ name: "title", value: "a report", }])
///   let body = form.encode(boundary="----edge")
///   inspect(body.content_type(), content="multipart/form-data; boundary=----edge")
///   let read = @moonmime.formdata(body.body[:], body.boundary)
///   inspect(read.field("title").unwrap_or(""), content="a report")
/// }
/// ```
pub fn Multipart::new(
  parts : Array[Part],
  boundary? : StringView,
  kind~ : MultipartKind = Mixed,
) -> Multipart raise Refused {
  let blocks : Array[Bytes] = parts.map(part_block)
  let boundary = match boundary {
    Some(given) => {
      let given = given.to_owned()
      guard is_boundary(given[:]) else { raise Boundary(got=given) }
      guard !collides(blocks, given[:]) else { raise Boundary(got=given) }
      given
    }
    None => free_boundary(blocks)
  }
  let out : Array[Byte] = []
  for i = 0; i < blocks.length(); i = i + 1 {
    if i == 0 {
      push_text(out, "--" + boundary + "\r\n")
    } else {
      push_text(out, "\r\n--" + boundary + "\r\n")
    }
    push_bytes(out, blocks[i][:])
  }
  // A body with no parts is the closing delimiter alone: an opening one would
  // leave a blank line a reader could take for a part.
  if blocks.length() == 0 {
    push_text(out, "--" + boundary + "--\r\n")
  } else {
    push_text(out, "\r\n--" + boundary + "--\r\n")
  }
  { kind, boundary, body: Bytes::from_array(out), }
}

///|
/// The `Content-Type` that goes with the body: the subtype, and the boundary
/// the receiver needs to split it.
///
/// Written as a header value, so a boundary with a space in it — RFC 2046
/// allows the space, not as the last character — comes out quoted.
pub fn Multipart::content_type(self : Multipart) -> String {
  MediaType::{
    essence: "multipart/" + self.kind.code(),
    params: [("boundary", self.boundary)],
  }.to_string()
}

///|
/// Write the form as a `multipart/form-data` body (RFC 7578).
///
/// Fields go out first, then files, each in the order the form holds them: a
/// `Form` keeps the two apart, so it cannot reproduce the interleaving of the
/// body it was read from. A file whose name leaves ASCII is named with
/// `filename*` (RFC 5987), the one form a receiver decodes back to the same
/// characters.
pub fn Form::encode(
  self : Form,
  boundary? : StringView,
) -> Multipart raise Refused {
  Multipart::new(form_parts(self), boundary?, kind=FormData)
}

// ------------------------------------------------------------- the form's parts

///|
fn form_parts(form : Form) -> Array[Part] {
  let parts : Array[Part] = []
  for field in form.fields {
    parts.push({
      headers: [
        ("content-disposition", "form-data; name=" + quoted(field.name)),
      ],
      body: @utf8.encode(field.value),
    })
  }
  for upload in form.files {
    let mut disposition = "form-data; name=" + quoted(upload.name)
    if upload.filename != "" {
      disposition = disposition + "; " + filename_param(upload.filename)
    }
    let headers : Array[(String, String)] = [
      ("content-disposition", disposition),
    ]
    if upload.content_type != "" {
      headers.push(("content-type", upload.content_type))
    }
    for pair in upload.headers {
      // These two are decided by the upload's own fields above; a second copy
      // would be the receiver's guess at which one is meant.
      if pair.0 != "content-disposition" && pair.0 != "content-type" {
        headers.push(pair)
      }
    }
    parts.push({ headers, body: upload.content, })
  }
  parts
}

///|
/// `name="..."` for a parameter of a form part. The quotes are the whole of the
/// framing: a name is text a person typed, so the only characters that cannot
/// stand inside are the ones that end the string.
fn quoted(value : StringView) -> String {
  let out = StringBuilder()
  out.write_char('"')
  for i in 0.. Bytes {
  let out : Array[Byte] = []
  for pair in part.headers {
    push_text(out, canonical(pair.0))
    push_text(out, ": ")
    push_text(out, pair.1)
    push_text(out, "\r\n")
  }
  push_text(out, "\r\n")
  push_bytes(out, part.body[:])
  Bytes::from_array(out)
}

///|
/// A field name for the wire. A name that came back from a parse is lowercased,
/// and goes out the way every writer spells it: each word capitalised.
fn canonical(name : String) -> String {
  for i in 0..= 0x41 && c <= 0x5A {
      return name
    }
  }
  let out = StringBuilder()
  let mut at_word = true
  for i = 0; i < name.length(); i = i + 1 {
    let c = name.at(i).to_int()
    if at_word && c >= 0x61 && c <= 0x7A {
      out.write_char((c - 0x20).unsafe_to_char())
    } else {
      out.write_view(name[i:i + 1])
    }
    at_word = c == 0x2D
  }
  out.to_string()
}

///|
fn push_text(out : Array[Byte], text : String) -> Unit {
  push_bytes(out, @utf8.encode(text)[:])
}

///|
fn push_bytes(out : Array[Byte], bytes : BytesView) -> Unit {
  for i = 0; i < bytes.length(); i = i + 1 {
    out.push(bytes[i])
  }
}

// ------------------------------------------------------------- the boundary

///|
/// A boundary proven absent from the parts, so it can only ever be a delimiter.
///
/// The stem is fixed and the suffix counts attempts, which makes the choice a
/// function of the parts: no source of randomness, and the same parts always
/// frame the same way. A part that already holds the candidate — a nested body
/// written by this library, most often — moves the count on.
fn free_boundary(blocks : Array[Bytes]) -> String {
  let mut n = 0
  let mut candidate = "----moonmime"
  while collides(blocks, candidate[:]) {
    n = n + 1
    candidate = "----moonmime" + n.to_string()
  }
  candidate
}

///|
/// Whether `--boundary` already stands inside one of the parts.
fn collides(blocks : Array[Bytes], boundary : StringView) -> Bool {
  let needle = ascii("--" + boundary.to_owned())
  for block in blocks {
    if index_of(block[:], needle[:], 0) >= 0 {
      return true
    }
  }
  false
}

///|
/// Whether a value can delimit a body: RFC 2046 §5.1 allows up to 70 octets of
/// bchars, and a space anywhere but the end.
fn is_boundary(s : StringView) -> Bool {
  let n = s.length()
  if n == 0 || n > 70 || s.at(n - 1).to_int() == 0x20 {
    return false
  }
  for i = 0; i < n; i = i + 1 {
    match s.at(i).to_int() {
      '0'..<='9'
      | 'A'..<='Z'
      | 'a'..<='z'
      | '\''
      | '('
      | ')'
      | '+'
      | '_'
      | ','
      | '-'
      | '.'
      | '/'
      | ':'
      | '='
      | '?'
      | ' ' => ()
      _ => return false
    }
  }
  true
}