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

///|
/// One mailbox: an address and the display name in front of it, decoded.
/// `address` is the addr-spec, its user part quoted only where a dot-atom
/// cannot carry it.
pub(all) struct Mailbox {
  name : String?
  address : String
} derive(Eq, Debug)

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

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

///|
pub fn Mailbox::new(address : String, name? : String) -> Mailbox {
  { name, address, }
}

///|
/// One entry of an address list: a mailbox, or a named group of them, which
/// may be empty (`undisclosed-recipients:;`).
pub(all) enum Address {
  Mailbox(Mailbox)
  Group(name~ : String, members~ : Array[Mailbox])
} derive(Eq, Debug)

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

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

///|
/// The mailboxes, a group's members in place of the group.
pub fn Address::mailboxes(self : Address) -> Array[Mailbox] {
  match self {
    Mailbox(m) => [m]
    Group(members~, ..) => members
  }
}

///|
/// Read one address (RFC 5322 section 3.4): a mailbox, with or without a
/// display name, or a group. The obsolete forms of section 4.4 are read too:
/// a phrase with periods, a route in the angle brackets, a user part or
/// domain with comments between its pieces. Encoded words in a display name
/// are decoded (RFC 2047).
pub fn parse_address(text : StringView) -> Address raise Refused {
  let lex = Lexer::new(text)
  let address = lex.address()
  lex.cfws() |> ignore
  if !lex.at_end() {
    raise Address(at=lex.at)
  }
  address
}

///|
/// Read an address list, as `To` and `Cc` carry; empty entries between commas
/// are skipped, as RFC 5322 section 4.4 allows.
pub fn parse_addresses(text : StringView) -> Array[Address] raise Refused {
  let lex = Lexer::new(text)
  let out = []
  while true {
    lex.cfws() |> ignore
    if lex.at_end() {
      break
    }
    if lex.eat(',') {
      continue
    }
    out.push(lex.address())
    lex.cfws() |> ignore
    if lex.at_end() {
      break
    }
    if !lex.eat(',') {
      raise Address(at=lex.at)
    }
  }
  out
}

///|
/// The mailbox as a header carries it. A display name that is not a run of
/// atoms is quoted; one that is not ASCII is written as encoded words, or as
/// UTF-8 when `utf8` is set (RFC 6532).
pub fn Mailbox::encode(self : Mailbox, utf8~ : Bool = false) -> String {
  match self.name {
    None | Some("") => self.address
    Some(name) => phrase(name, utf8) + " <" + self.address + ">"
  }
}

///|
/// The address as a header carries it; see `Mailbox::encode`.
pub fn Address::encode(self : Address, utf8~ : Bool = false) -> String {
  match self {
    Mailbox(m) => m.encode(utf8~)
    Group(name~, members~) =>
      phrase(name, utf8) +
      ":" +
      (if members.is_empty() { "" } else { " " }) +
      members.map(m => m.encode(utf8~)).join(", ") +
      ";"
  }
}

///|
/// An address list as a header carries it.
pub fn encode_addresses(
  addresses : ArrayView[Address],
  utf8~ : Bool = false,
) -> String {
  addresses.map(a => a.encode(utf8~)).join(", ")
}

///|
fn phrase(name : String, utf8 : Bool) -> String {
  let ascii = name.iter().all(c => c.to_int() < 0x80)
  if !ascii && !utf8 {
    return @moonmime.encode_words(name) catch { _ => quote(name) }
  }
  let words = name.split(" ").to_array()
  if words.iter().all(w => !w.is_empty() && w.iter().all(is_atext)) {
    name
  } else {
    quote(name)
  }
}

///|
fn quote(text : String) -> String {
  let out = StringBuilder()
  out.write_char('"')
  for c in text {
    if c == '"' || c == '\\' {
      out.write_char('\\')
    }
    out.write_char(c)
  }
  out.write_char('"')
  out.to_string()
}

///|
/// RFC 5322 section 3.2.3 `atext`, with the non-ASCII characters RFC 6532 adds.
fn is_atext(c : Char) -> Bool {
  (c >= 'a' && c <= 'z') ||
  (c >= 'A' && c <= 'Z') ||
  (c >= '0' && c <= '9') ||
  "!#$%&'*+-/=?^_`{|}~".contains_char(c) ||
  c.to_int() >= 0x80
}

///|
/// A piece of a phrase, and whether white space or a comment stood before it.
priv struct Piece {
  text : String
  spaced : Bool
  quoted : Bool
}

///|
priv struct Lexer {
  text : Array[Char]
  mut at : Int32
}

///|
fn Lexer::new(text : StringView) -> Lexer {
  { text: text.to_array(), at: 0, }
}

///|
fn Lexer::at_end(self : Lexer) -> Bool {
  self.at >= self.text.length()
}

///|
fn Lexer::peek(self : Lexer) -> Char? {
  self.text.get(self.at)
}

///|
fn Lexer::eat(self : Lexer, c : Char) -> Bool {
  if self.peek() == Some(c) {
    self.at += 1
    true
  } else {
    false
  }
}

///|
fn Lexer::expect(self : Lexer, c : Char) -> Unit raise Refused {
  if !self.eat(c) {
    raise Address(at=self.at)
  }
}

///|
/// Skip folding white space and comments; whether there was any.
fn Lexer::cfws(self : Lexer) -> Bool raise Refused {
  let start = self.at
  while self.peek() is Some(c) {
    if c == ' ' || c == '\t' || c == '\r' || c == '\n' {
      self.at += 1
    } else if c == '(' {
      self.comment()
    } else {
      break
    }
  }
  self.at > start
}

///|
/// A comment, nested ones and quoted pairs included.
fn Lexer::comment(self : Lexer) -> Unit raise Refused {
  let start = self.at
  self.at += 1
  let mut depth = 1
  while depth > 0 {
    guard self.peek() is Some(c) else { raise Address(at=start) }
    self.at += 1
    if c == '\\' {
      self.at += 1
    } else if c == '(' {
      depth += 1
    } else if c == ')' {
      depth -= 1
    }
  }
}

///|
/// A quoted string's content, the quoted pairs resolved.
fn Lexer::quoted(self : Lexer) -> String raise Refused {
  let start = self.at
  self.at += 1
  let out = StringBuilder()
  while true {
    guard self.peek() is Some(c) else { raise Address(at=start) }
    self.at += 1
    match c {
      '"' => break
      '\\' => {
        guard self.peek() is Some(next) else { raise Address(at=start) }
        out.write_char(next)
        self.at += 1
      }
      '\r' | '\n' => ()
      _ => out.write_char(c)
    }
  }
  out.to_string()
}

///|
fn Lexer::atom(self : Lexer) -> String? {
  let start = self.at
  while self.peek() is Some(c) && is_atext(c) {
    self.at += 1
  }
  if self.at == start {
    None
  } else {
    Some(String::from_array(self.text[start:self.at]))
  }
}

///|
/// A word: an atom or a quoted string.
fn Lexer::word(self : Lexer) -> (String, Bool)? raise Refused {
  match self.peek() {
    Some('"') => Some((self.quoted(), true))
    _ => self.atom().map(a => (a, false))
  }
}

///|
/// The words of a phrase, with the periods of RFC 5322 section 4.1 allowed
/// between them.
fn Lexer::phrase(self : Lexer) -> Array[Piece] raise Refused {
  let pieces = []
  while true {
    let spaced = self.cfws()
    if self.peek() == Some('.') && pieces.length() > 0 {
      self.at += 1
      pieces.push({ text: ".", spaced, quoted: false, })
      continue
    }
    match self.word() {
      Some((text, quoted)) => pieces.push({ text, spaced, quoted, })
      None => break
    }
  }
  pieces
}

///|
fn Lexer::address(self : Lexer) -> Address raise Refused {
  let start = self.at
  let pieces = self.phrase()
  self.cfws() |> ignore
  match self.peek() {
    Some(':') if pieces.length() > 0 => {
      self.at += 1
      let members = []
      while true {
        self.cfws() |> ignore
        if self.eat(';') {
          break
        }
        if self.eat(',') {
          continue
        }
        members.push(self.mailbox())
        self.cfws() |> ignore
        if self.peek() is Some(c) && c != ',' && c != ';' {
          raise Address(at=self.at)
        }
      }
      Group(name=display(pieces), members~)
    }
    _ => {
      self.at = start
      Mailbox(self.mailbox())
    }
  }
}

///|
fn Lexer::mailbox(self : Lexer) -> Mailbox raise Refused {
  self.cfws() |> ignore
  let start = self.at
  let pieces = self.phrase()
  self.cfws() |> ignore
  if self.peek() == Some('<') {
    let address = self.angle()
    let name = display(pieces)
    return { name: if name.is_empty() { None } else { Some(name) }, address, }
  }
  self.at = start
  let address = self.addr_spec()
  // RFC 5322 section 4.4 still lets a comment-only trailer stand after it.
  self.cfws() |> ignore
  { name: None, address, }
}

///|
/// `<` [route] addr-spec `>`; an obsolete route (`@a,@b:`) is read and dropped.
fn Lexer::angle(self : Lexer) -> String raise Refused {
  self.expect('<')
  self.cfws() |> ignore
  if self.peek() == Some('@') {
    while self.eat('@') || self.eat(',') {
      self.cfws() |> ignore
      if self.peek() != Some('@') && self.peek() != Some(',') {
        let _ = self.domain()
      }
      self.cfws() |> ignore
    }
    self.expect(':')
  }
  let address = self.addr_spec()
  self.cfws() |> ignore
  self.expect('>')
  address
}

///|
fn Lexer::addr_spec(self : Lexer) -> String raise Refused {
  self.cfws() |> ignore
  let words = []
  let mut quoted = false
  while true {
    self.cfws() |> ignore
    guard self.word() is Some((text, q)) else { raise Address(at=self.at) }
    words.push(text)
    quoted = quoted || q
    self.cfws() |> ignore
    if !self.eat('.') {
      break
    }
  }
  self.cfws() |> ignore
  self.expect('@')
  let user = words.join(".")
  let dot_atom = user
    .split(".")
    .all(w => !w.is_empty() && w.iter().all(is_atext))
  (if dot_atom { user } else { quote(user) }) + "@" + self.domain()
}

///|
/// A dot-atom domain, an obsolete one with comments between its atoms, or a
/// domain literal.
fn Lexer::domain(self : Lexer) -> String raise Refused {
  self.cfws() |> ignore
  if self.peek() == Some('[') {
    let start = self.at
    while self.peek() is Some(c) && c != ']' {
      if c == '\\' {
        self.at += 1
      }
      self.at += 1
    }
    self.expect(']')
    return String::from_array(self.text[start:self.at])
  }
  let atoms = []
  while true {
    self.cfws() |> ignore
    guard self.atom() is Some(atom) else { raise Address(at=self.at) }
    atoms.push(atom)
    let mark = self.at
    self.cfws() |> ignore
    if !self.eat('.') {
      self.at = mark
      break
    }
  }
  atoms.join(".")
}

///|
/// A display name from its pieces: one space where any white space or
/// comment stood, and encoded words decoded.
fn display(pieces : Array[Piece]) -> String {
  let out = StringBuilder()
  for i, piece in pieces {
    if i > 0 && piece.spaced {
      out.write_char(' ')
    }
    out.write_string(piece.text)
  }
  let joined = out.to_string()
  if pieces.iter().any(p => !p.quoted && p.text.contains("=?")) {
    @moonmime.decode_words(joined)
  } else {
    joined
  }
}