///|
/// A single attachment or inline image attached to a message.
pub struct Attachment {
  data : Bytes
  filename : String
  mut content_type : String
  cid : String
} derive(Eq, Debug)

///|
pub fn Attachment::file(data : Bytes, filename : String) -> Attachment {
  { data, filename, content_type: "", cid: "" }
}

///|
pub fn Attachment::inline_image(
  data : Bytes,
  filename : String,
  cid : String,
) -> Attachment {
  { data, filename, content_type: "image/*", cid }
}

///|
/// Fluent builder for `MailMessage`. Addresses and header values are validated
/// (including header-injection checks) as they are added, so a built message
/// is always safe to render.
pub struct MessageBuilder {
  mut from : MailAddress?
  to : Array[MailAddress]
  cc : Array[MailAddress]
  bcc : Array[MailAddress]
  reply_to : Array[MailAddress]
  mut subject : String
  mut text_part : String?
  mut html_part : String?
  attachments : Array[Attachment]
  inline_images : Array[Attachment]
  custom_headers : Array[(String, String)]
  mut date : String
  mut message_id : String
  mut boundary_seed : String
}

///|
pub fn MessageBuilder::new() -> MessageBuilder {
  {
    from: None,
    to: [],
    cc: [],
    bcc: [],
    reply_to: [],
    subject: "",
    text_part: None,
    html_part: None,
    attachments: [],
    inline_images: [],
    custom_headers: [],
    date: "",
    message_id: "",
    boundary_seed: "moonmail",
  }
}

///|
pub fn MessageBuilder::from(
  self : MessageBuilder,
  addr : String,
) -> MessageBuilder raise MailFailure {
  self.from = Some(MailAddress::parse(addr))
  self
}

///|
pub fn MessageBuilder::from_addr(
  self : MessageBuilder,
  addr : MailAddress,
) -> MessageBuilder {
  self.from = Some(addr)
  self
}

///|
pub fn MessageBuilder::to(
  self : MessageBuilder,
  list : String,
) -> MessageBuilder raise MailFailure {
  for a in parse_address_list(list) {
    self.to.push(a)
  }
  self
}

///|
pub fn MessageBuilder::cc(
  self : MessageBuilder,
  list : String,
) -> MessageBuilder raise MailFailure {
  for a in parse_address_list(list) {
    self.cc.push(a)
  }
  self
}

///|
pub fn MessageBuilder::bcc(
  self : MessageBuilder,
  list : String,
) -> MessageBuilder raise MailFailure {
  for a in parse_address_list(list) {
    self.bcc.push(a)
  }
  self
}

///|
pub fn MessageBuilder::reply_to(
  self : MessageBuilder,
  list : String,
) -> MessageBuilder raise MailFailure {
  for a in parse_address_list(list) {
    self.reply_to.push(a)
  }
  self
}

///|
pub fn MessageBuilder::subject(
  self : MessageBuilder,
  subject : String,
) -> MessageBuilder raise MailFailure {
  reject_crlf(subject, "subject")
  self.subject = subject
  self
}

///|
pub fn MessageBuilder::text(
  self : MessageBuilder,
  content : String,
) -> MessageBuilder {
  self.text_part = Some(content)
  self
}

///|
pub fn MessageBuilder::html(
  self : MessageBuilder,
  content : String,
) -> MessageBuilder {
  self.html_part = Some(content)
  self
}

///|
/// Add a binary attachment with an optional explicit Content-Type.
pub fn MessageBuilder::attach(
  self : MessageBuilder,
  data : Bytes,
  filename : String,
  content_type? : String = "",
) -> MessageBuilder {
  let a = Attachment::file(data, filename)
  a.content_type = content_type
  self.attachments.push(a)
  self
} ///|
/// Add an inline image referenced from HTML via `cid:`.

///|
pub fn MessageBuilder::inline_image(
  self : MessageBuilder,
  data : Bytes,
  filename : String,
  cid : String,
) -> MessageBuilder {
  self.inline_images.push(Attachment::inline_image(data, filename, cid))
  self
}

///|
/// Add a custom header. The value is checked for CR/LF injection immediately.
pub fn MessageBuilder::header(
  self : MessageBuilder,
  name : String,
  value : String,
) -> MessageBuilder raise MailFailure {
  guard is_valid_field_name(name) else {
    raise MailFailure::of(
      InvalidHeader,
      MM_HEADER_004,
      "invalid header field name: \{name}",
    )
  }
  reject_crlf(value, "header '\{name}'")
  self.custom_headers.push((name, value))
  self
}

///|
pub fn MessageBuilder::date(
  self : MessageBuilder,
  date : String,
) -> MessageBuilder {
  self.date = date
  self
}

///|
pub fn MessageBuilder::message_id(
  self : MessageBuilder,
  message_id : String,
) -> MessageBuilder {
  self.message_id = message_id
  self
}

///|
pub fn MessageBuilder::boundary_seed(
  self : MessageBuilder,
  seed : String,
) -> MessageBuilder {
  self.boundary_seed = seed
  self
}

///|
/// The envelope derived from the builder: the sender plus To/Cc/Bcc recipients.
/// The sender must be set before calling this.
pub fn MessageBuilder::envelope(
  self : MessageBuilder,
) -> Envelope raise MailFailure {
  let from = match self.from {
    Some(f) => f
    None =>
      raise MailFailure::config(
        "no sender: call from() before building the envelope",
      )
  }
  let recipients = self.to + self.cc + self.bcc
  Envelope::new(from, recipients)
}

///|
/// Assemble the final message tree plus headers.
pub fn MessageBuilder::build(
  self : MessageBuilder,
) -> MailMessage raise MailFailure {
  let headers = []
  let from = match self.from {
    Some(f) => f
    None => raise MailFailure::config("no sender: call from() before build()")
  }
  headers.push(("From", from.to_string()))
  if !self.to.is_empty() {
    headers.push(("To", format_addresses(self.to)))
  }
  if !self.cc.is_empty() {
    headers.push(("Cc", format_addresses(self.cc)))
  }
  if !self.reply_to.is_empty() {
    headers.push(("Reply-To", format_addresses(self.reply_to)))
  }
  if !self.subject.is_empty() {
    reject_crlf(self.subject, "subject")
    headers.push(("Subject", self.subject))
  }
  for pair in self.custom_headers {
    let (name, value) = pair
    headers.push((name, value))
  }
  if self.date != "" {
    headers.push(("Date", self.date))
  }
  if self.message_id != "" {
    headers.push(("Message-ID", self.message_id))
  }
  let body = self.build_body()
  { headers, body }
}

///|
fn format_addresses(addrs : Array[MailAddress]) -> String {
  addrs.map(fn(a) { a.to_string() }).join(", ")
}

///|
/// Build the MIME body tree:
///   - text+html+inline images -> multipart/alternative [text, multipart/related [html, images]]
///   - html+inline images      -> multipart/related [html, images]
///   - text+html               -> multipart/alternative
///   - attachments              -> multipart/mixed wrapper around the above
fn MessageBuilder::build_body(
  self : MessageBuilder,
) -> MimeBody raise MailFailure {
  let text_part = match self.text_part {
    Some(t) =>
      Some(
        (
          {
            headers: [("Content-Type", "text/plain; charset=utf-8")],
            body: Text(t),
          } : Part),
      )
    None => None
  }
  let html_part = match self.html_part {
    Some(h) =>
      Some(
        (
          {
            headers: [("Content-Type", "text/html; charset=utf-8")],
            body: Html(h),
          } : Part),
      )
    None => None
  }
  let images = self.inline_images.map(build_image_part)
  let attachments = self.attachments.map(build_attachment_part)

  // html goes into a related container when there are inline images
  let html_body = match (html_part, images) {
    (Some(html), imgs) if !imgs.is_empty() => {
      let related : Array[Part] = [{ headers: [], body: html.body }] + imgs
      MultiPart(related, Related)
    }
    (Some(html), _) => html.body
    (None, imgs) if !imgs.is_empty() =>
      raise MailFailure::mime("inline images require an html part")
    (None, _) => Text("")
  }

  // text + html alternative
  let body_body = match (text_part, self.html_part) {
    (Some(text), Some(_)) =>
      MultiPart([text, ({ headers: [], body: html_body } : Part)], Alternative)
    (Some(text), None) => text.body
    (None, Some(_)) => html_body
    (None, None) => Text("")
  }

  if attachments.is_empty() {
    body_body
  } else {
    let children : Array[Part] = [{ headers: [], body: body_body }] +
      attachments
    MultiPart(children, Mixed)
  }
}

///|
fn build_attachment_part(a : Attachment) -> Part raise MailFailure {
  let headers = []
  if a.content_type != "" {
    headers.push(("Content-Type", a.content_type))
  }
  let disp = make_disposition(Attachment, a.filename)
  headers.push(("Content-Disposition", disp))
  if a.cid != "" {
    headers.push(("Content-ID", "<\{a.cid}>"))
  }
  { headers, body: Binary(a.data, a.filename) }
}

///|
fn build_image_part(a : Attachment) -> Part raise MailFailure {
  let headers = []
  if a.content_type != "" {
    headers.push(("Content-Type", a.content_type))
  }
  headers.push(("Content-Disposition", make_disposition(Inline, a.filename)))
  if a.cid != "" {
    headers.push(("Content-ID", "<\{a.cid}>"))
  }
  { headers, body: Binary(a.data, a.filename) }
}