///|
/// A parsed email address: an optional display name plus the `local@domain`
/// addr-spec (RFC 5322 section 3.4).
pub struct MailAddress {
  display_name : String
  local_part : String
  domain : String
} derive(Eq, Debug)

///|
pub fn MailAddress::new(
  local_part : String,
  domain : String,
  display_name? : String = "",
) -> MailAddress {
  { display_name, local_part, domain }
}

///|
pub fn MailAddress::addr_spec(self : MailAddress) -> String {
  "\{self.local_part}@\{self.domain}"
}

///|
pub fn MailAddress::display_name(self : MailAddress) -> String {
  self.display_name
}

///|
pub fn MailAddress::local_part(self : MailAddress) -> String {
  self.local_part
}

///|
pub fn MailAddress::domain(self : MailAddress) -> String {
  self.domain
}

///|
/// Render the address back to a single RFC 5322 `mailbox`:
/// `Display Name ` when a display name is present,
/// otherwise the bare `addr-spec`.
pub fn MailAddress::to_string(self : MailAddress) -> String {
  if self.display_name.is_empty() {
    self.addr_spec()
  } else {
    "\{display_name_to_string(self.display_name)} <\{self.addr_spec()}>"
  }
}

///|
pub impl Show for MailAddress with fn output(self, logger) {
  logger.write_string(self.to_string())
}

///|
/// Quote the display name when it contains characters outside the safe atom set.
pub fn display_name_to_string(display_name : String) -> String {
  if is_atom(display_name) {
    display_name
  } else {
    let escaped = display_name
      .replace(old="\\", new="\\\\")
      .replace(old="\"", new="\\\"")
    "\"\{escaped}\""
  }
}

///|
/// `atext` check over a code point (RFC 5322 section 3.2.3).
fn is_atext_int(c : Int) -> Bool {
  if c >= 0x41 && c <= 0x5A {
    return true
  }
  if c >= 0x61 && c <= 0x7A {
    return true
  }
  if c >= 0x30 && c <= 0x39 {
    return true
  }
  c == 0x21 ||
  c == 0x23 ||
  c == 0x24 ||
  c == 0x25 ||
  c == 0x26 ||
  c == 0x27 ||
  c == 0x2A ||
  c == 0x2B ||
  c == 0x2D ||
  c == 0x2F ||
  c == 0x3D ||
  c == 0x3F ||
  c == 0x5E ||
  c == 0x5F ||
  c == 0x60 ||
  c == 0x7B ||
  c == 0x7C ||
  c == 0x7D ||
  c == 0x7E
}

///|
fn is_atext_byte(b : Byte) -> Bool {
  is_atext_int(b.to_int())
}

///|
/// An atom is a nonempty run of `atext`.
fn is_atom(s : String) -> Bool {
  if s.is_empty() {
    return false
  }
  for ch in s {
    if !is_atext_int(ch.to_int()) {
      return false
    }
  }
  true
}

///|
/// Validate a local-part / domain pair as an addr-spec (RFC 5322 3.4.1).
pub fn validate_addr_spec(
  local_part : String,
  domain : String,
) -> Unit raise MailFailure {
  if local_part.is_empty() {
    raise MailFailure::of(InvalidAddress, MM_ADDR_003, "empty local-part")
  }
  if domain.is_empty() {
    raise MailFailure::of(InvalidAddress, MM_ADDR_004, "empty domain")
  }
  let lp = string_to_bytes(local_part)
  if lp[0] == b'"' {
    return validate_quoted_local(local_part)
  }
  guard validate_dot_atom(local_part) else {
    raise MailFailure::of(
      InvalidAddress,
      MM_ADDR_005,
      "invalid local-part: \{local_part}",
    )
  }
  guard validate_domain(domain) else {
    raise MailFailure::of(
      InvalidAddress,
      MM_ADDR_006,
      "invalid domain: \{domain}",
    )
  }
}

///|
fn validate_quoted_local(local_part : String) -> Unit raise MailFailure {
  let bytes = string_to_bytes(local_part)
  if bytes.length() < 2 || bytes[bytes.length() - 1] != b'"' {
    raise MailFailure::of(
      InvalidAddress,
      MM_ADDR_007,
      "unclosed quoted-string local-part",
    )
  }
  let mut i = 1
  let mut escaped = false
  while i < bytes.length() - 1 {
    let b = bytes[i]
    if escaped {
      escaped = false
    } else if b == b'\\' {
      escaped = true
    } else if b == b'\r' || b == b'\n' || b == b'\x00' {
      raise MailFailure::of(
        InvalidAddress,
        MM_ADDR_005,
        "control byte in quoted local-part",
      )
    }
    i += 1
  }
  if escaped {
    raise MailFailure::of(
      InvalidAddress,
      MM_ADDR_007,
      "dangling escape in quoted local-part",
    )
  }
}

///|
/// Validate a dot-atom: `atext` runs separated by single dots, with no
/// leading or trailing dot.
fn validate_dot_atom(s : String) -> Bool {
  let bytes = string_to_bytes(s)
  if bytes.is_empty() {
    return false
  }
  if bytes[0] == b'.' || bytes[bytes.length() - 1] == b'.' {
    return false
  }
  let mut prev_dot = false
  for i = 0; i < bytes.length(); i = i + 1 {
    let b = bytes[i]
    if b == b'.' {
      if prev_dot {
        return false
      }
      prev_dot = true
      continue
    }
    prev_dot = false
    if !is_atext_byte(b) {
      return false
    }
  }
  true
}

///|
fn validate_domain(domain : String) -> Bool {
  let bytes = string_to_bytes(domain)
  if bytes.is_empty() {
    return false
  }
  if bytes[0] == b'[' {
    return bytes[bytes.length() - 1] == b']'
  }
  let mut label_start = true
  for i = 0; i < bytes.length(); i = i + 1 {
    let b = bytes[i]
    if b == b'.' {
      if label_start {
        return false
      }
      label_start = true
      continue
    }
    let c = b.to_int()
    let is_alnum = (c >= 0x61 && c <= 0x7A) ||
      (c >= 0x41 && c <= 0x5A) ||
      (c >= 0x30 && c <= 0x39)
    if !is_alnum && b != b'-' {
      return false
    }
    if b == b'-' && label_start {
      return false
    }
    label_start = false
  }
  // a trailing dot leaves the last label empty
  !label_start && bytes[bytes.length() - 1] != b'-'
}

///|
/// Find the index of `ch` not inside a quoted string, or `-1`.
fn find_unquoted(s : String, ch : Char) -> Int {
  find_unquoted_from(s, ch, 0)
}

///|
fn find_unquoted_from(s : String, ch : Char, from : Int) -> Int {
  let bytes = string_to_bytes(s)
  let mut in_quote = false
  let mut escaped = false
  let mut i = from
  while i < bytes.length() {
    let b = bytes[i]
    if escaped {
      escaped = false
    } else if b == b'\\' && in_quote {
      escaped = true
    } else if b == b'"' {
      in_quote = !in_quote
    } else if !in_quote && b == ch.to_int().to_byte() {
      return i
    }
    i += 1
  }
  -1
}

///|
/// Remove a single `( comment )` (outside quotes) if present.
fn strip_trailing_comment(s : String) -> String {
  let bytes = string_to_bytes(s)
  let mut depth = 0
  let mut in_quote = false
  let mut escaped = false
  let mut start = -1
  for i = 0; i < bytes.length(); i = i + 1 {
    let b = bytes[i]
    if escaped {
      escaped = false
    } else if b == b'\\' && in_quote {
      escaped = true
    } else if b == b'"' {
      in_quote = !in_quote
    } else if !in_quote {
      if b == b'(' {
        if depth == 0 {
          start = i
        }
        depth += 1
      } else if b == b')' {
        depth -= 1
        if depth == 0 {
          return s[0:start].to_owned() + s[i + 1:s.length()].to_owned()
        }
      }
    }
  }
  s
}

///|
fn unquote_display(display : String) -> String {
  let d = display.trim().to_owned()
  let bytes = string_to_bytes(d)
  if bytes.length() >= 2 &&
    bytes[0] == b'"' &&
    bytes[bytes.length() - 1] == b'"' {
    d[1:d.length() - 1]
    .to_owned()
    .replace(old="\\\"", new="\"")
    .replace(old="\\\\", new="\\")
  } else {
    d
  }
}

///|
/// Parse a single `mailbox`:
///   - `"Display Name" `
///   - `local@domain (comment)`
///   - `local@domain`
///   - `Display Name `
pub fn MailAddress::parse(input : String) -> MailAddress raise MailFailure {
  let s = strip_trailing_comment(input.trim().to_owned())
  if s.is_empty() {
    raise MailFailure::invalid_address("empty address")
  }
  let lt = find_unquoted(s, '<')
  if lt >= 0 {
    let display = s[0:lt].trim().to_owned()
    let gt = find_unquoted(s, '>')
    if gt < 0 {
      raise MailFailure::invalid_address("missing '>' in angle-addr")
    }
    let addr_spec = s[lt + 1:gt].trim().to_owned()
    guard addr_spec != "" else {
      raise MailFailure::invalid_address("empty angle-addr")
    }
    let (local_part, domain) = split_addr_spec(addr_spec)
    validate_addr_spec(local_part, domain)
    let name = unquote_display(display)
    return { display_name: name, local_part, domain }
  }
  let (local_part, domain) = split_addr_spec(s)
  validate_addr_spec(local_part, domain)
  { display_name: "", local_part, domain }
}

///|
/// Split `local@domain`, raising when `@` is absent or appears more than once
/// outside quotes.
fn split_addr_spec(s : String) -> (String, String) raise MailFailure {
  let at = find_unquoted(s, '@')
  if at < 0 {
    raise MailFailure::of(
      InvalidAddress,
      MM_ADDR_002,
      "missing '@' in address: \{s}",
    )
  }
  let second = find_unquoted_from(s, '@', at + 1)
  if second >= 0 {
    raise MailFailure::invalid_address("too many '@' in address: \{s}")
  }
  let local_part = s[0:at].trim().to_owned()
  let domain = s[at + 1:s.length()].trim().to_owned()
  (local_part, domain)
}

///|
/// Split an address list on commas, respecting quoted strings and comments.
pub fn split_address_list(input : String) -> Array[String] {
  let items = []
  let bytes = string_to_bytes(input)
  let mut start = 0
  let mut depth = 0
  let mut in_quote = false
  let mut escaped = false
  for i = 0; i < bytes.length(); i = i + 1 {
    let b = bytes[i]
    if escaped {
      escaped = false
    } else if b == b'\\' && in_quote {
      escaped = true
    } else if b == b'"' {
      in_quote = !in_quote
    } else if !in_quote {
      if b == b'(' {
        depth += 1
      } else if b == b')' {
        if depth > 0 {
          depth -= 1
        }
      } else if b == b',' && depth == 0 {
        items.push(input[start:i].trim().to_owned())
        start = i + 1
      }
    }
  }
  items.push(input[start:bytes.length()].trim().to_owned())
  items.filter(fn(s) { !s.is_empty() })
}

///|
/// Parse a comma separated list of addresses (e.g. a `To` header value).
pub fn parse_address_list(
  input : String,
) -> Array[MailAddress] raise MailFailure {
  let out = []
  for item in split_address_list(input) {
    let addr = MailAddress::parse(item)
    out.push(addr)
  }
  out
}