///|
/// Browsing a mailbox: listing folders, listing messages and reading one.
///
/// This is the read side of the CLI, the counterpart of `watch`. It uses the
/// same `ImapClient`, so password, OAuth2 and TLS behaviour stay identical; the
/// only new protocol pieces are `LIST` and a header-only `UID FETCH`.
///
/// Nothing here runs a rule or writes a cursor: `ls` and `read` are for a human
/// at a terminal (or a script), and must not change what `watch` will do next.

///|
pub struct Folder {
  name : String
  delimiter : String
  attributes : Array[String]
}

///|
/// One row of `mails ls`: everything the envelope list shows, decoded.
pub struct Envelope {
  uid : String
  date : String
  from : String
  subject : String
  size : Int32
  seen : Bool
  flagged : Bool
}

///|
/// Parse one `LIST` line:
///
/// ```text
/// * LIST (\HasNoChildren) "/" "INBOX"
/// * LIST (\Noselect) "/" "[Gmail]/All Mail"
/// ```
pub fn parse_list_line(line : String) -> Folder? {
  let text = trim(line)
  if !text.has_prefix("* LIST") {
    return None
  }
  let rest = text[7:].to_owned()
  let mut index = 0
  let chars = rest.to_array()
  // Attributes: a parenthesised list, possibly empty.
  while index < chars.length() && chars[index] == ' ' {
    index = index + 1
  }
  let attributes : Array[String] = []
  if index < chars.length() && chars[index] == '(' {
    index = index + 1
    let current = StringBuilder()
    while index < chars.length() && chars[index] != ')' {
      if chars[index] == ' ' {
        if current.to_string() != "" {
          attributes.push(current.to_string())
          current.reset()
        }
      } else {
        current.write_char(chars[index])
      }
      index = index + 1
    }
    if current.to_string() != "" {
      attributes.push(current.to_string())
    }
    index = index + 1
  }
  // Delimiter: a quoted string or NIL.
  while index < chars.length() && chars[index] == ' ' {
    index = index + 1
  }
  let mut delimiter = ""
  if index < chars.length() && chars[index] == '"' {
    index = index + 1
    let value = StringBuilder()
    while index < chars.length() && chars[index] != '"' {
      if chars[index] == '\\' && index + 1 < chars.length() {
        index = index + 1
      }
      value.write_char(chars[index])
      index = index + 1
    }
    index = index + 1
    delimiter = value.to_string()
  } else {
    while index < chars.length() && chars[index] != ' ' {
      index = index + 1
    }
  }
  // Name: a quoted string or an atom.
  while index < chars.length() && chars[index] == ' ' {
    index = index + 1
  }
  if index >= chars.length() {
    return None
  }
  let name = if chars[index] == '"' {
    index = index + 1
    let value = StringBuilder()
    while index < chars.length() && chars[index] != '"' {
      if chars[index] == '\\' && index + 1 < chars.length() {
        index = index + 1
      }
      value.write_char(chars[index])
      index = index + 1
    }
    value.to_string()
  } else {
    let value = StringBuilder()
    while index < chars.length() {
      value.write_char(chars[index])
      index = index + 1
    }
    // An atom cannot contain spaces, so any trailing blanks are framing.
    trim(value.to_string())
  }
  Some({ name, delimiter, attributes, })
}

///|
/// Parse a `UID FETCH` reply that carries only a header literal.
///
/// The IMAP server returns one `* n FETCH (...)` line per message, with the
/// flags and the header as a literal.
pub fn parse_header_fetch(line : LogicalLine) -> Envelope? {
  if !line.text.to_upper().contains("FETCH") {
    return None
  }
  let uid = match token_after(line.text, "UID") {
    Some(value) => value
    None => return None
  }
  let size = match token_int(line.text, "RFC822.SIZE") {
    Some(value) => value
    None => 0
  }
  let lower = line.text.to_upper()
  let seen = lower.contains("\\SEEN")
  let flagged = lower.contains("\\FLAGGED")
  let header = match line.literals {
    [] => Bytes::new(0)
    [first, ..] => first
  }
  let headers = parse_headers(bytes_to_string_lossy(header))
  Some({
    uid,
    date: header_value(headers, "date"),
    from: decode_encoded_words(header_value(headers, "from")),
    subject: decode_encoded_words(header_value(headers, "subject")),
    size,
    seen,
    flagged,
  })
}

///|
/// `2026-10-27 10:02` from an RFC 5322 `Date:` value, or the raw value when it
/// cannot be understood.
///
/// The terminal is narrow, so a compact local-looking stamp beats the full
/// header. Only the `dd Mon yyyy hh:mm` shape Gmail, Outlook and friends emit is
/// understood; anything else is passed through so nothing is lost.
pub fn short_date(value : String) -> String {
  let text = trim(value)
  if text == "" {
    return ""
  }
  // Drop the leading weekday: "Mon, 27 Oct 2026 10:02:19 +0000"
  let mut rest = text
  match rest.find(",") {
    Some(index) => if index <= 4 { rest = trim(rest[index + 1:].to_owned()) }
    None => ()
  }
  let parts : Array[String] = []
  for piece in rest.split(" ") {
    let trimmed = trim(piece)
    if trimmed != "" {
      parts.push(trimmed)
    }
  }
  if parts.length() < 4 {
    return text
  }
  let day = parts[0]
  let month = parts[1]
  let year = parts[2]
  let time = parts[3]
  let hhmm = if time.length() >= 5 { time[0:5].to_owned() } else { time }
  let month_number = match month.to_lower() {
    "jan" => "01"
    "feb" => "02"
    "mar" => "03"
    "apr" => "04"
    "may" => "05"
    "jun" => "06"
    "jul" => "07"
    "aug" => "08"
    "sep" => "09"
    "oct" => "10"
    "nov" => "11"
    "dec" => "12"
    _ => ""
  }
  if month_number == "" {
    return text
  }
  let padded_day = if day.length() == 1 { "0" + day } else { day }
  year + "-" + month_number + "-" + padded_day + " " + hhmm
}

///|
/// Terminal columns taken by one character: 2 for East Asian wide and
/// fullwidth characters, 0 for combining marks, 1 otherwise.
fn char_columns(c : Char) -> Int32 {
  let code = c.to_int()
  if (code >= 0x0300 && code <= 0x036F) || (code >= 0x200B && code <= 0x200F) {
    0
  } else if (code >= 0x1100 && code <= 0x115F) ||
    (code >= 0x2E80 && code <= 0x303E) ||
    (code >= 0x3041 && code <= 0x33FF) ||
    (code >= 0x3400 && code <= 0x4DBF) ||
    (code >= 0x4E00 && code <= 0x9FFF) ||
    (code >= 0xA000 && code <= 0xA4CF) ||
    (code >= 0xAC00 && code <= 0xD7A3) ||
    (code >= 0xF900 && code <= 0xFAFF) ||
    (code >= 0xFE30 && code <= 0xFE4F) ||
    (code >= 0xFF00 && code <= 0xFF60) ||
    (code >= 0xFFE0 && code <= 0xFFE6) {
    2
  } else {
    1
  }
}

///|
/// The number of terminal columns `text` occupies.
pub fn display_width(text : String) -> Int32 {
  let mut width = 0
  for c in text {
    width = width + char_columns(c)
  }
  width
}

///|
/// Shorten `text` to at most `width` terminal columns, with an ellipsis.
pub fn fit_width(text : String, width : Int32) -> String {
  let collapsed = collapse_whitespace(text)
  if width <= 0 {
    return ""
  }
  if display_width(collapsed) <= width {
    return collapsed
  }
  if width == 1 {
    return "…"
  }
  let out = StringBuilder()
  let mut used = 0
  for c in collapsed {
    let columns = char_columns(c)
    // Leave one column for the ellipsis; a wide character that would fill that
    // column by itself is dropped rather than overflowing the column.
    if used + columns > width - 1 {
      break
    }
    out.write_char(c)
    used = used + columns
  }
  out.write_string("…")
  out.to_string()
}

///|
/// Pad `text` to `width` terminal columns. Counts columns rather than
/// characters, so a CJK subject lines up with an ASCII one.
pub fn pad_to(text : String, width : Int32) -> String {
  let out = StringBuilder()
  out.write_string(text)
  let mut used = display_width(text)
  while used < width {
    out.write_char(' ')
    used = used + 1
  }
  out.to_string()
}

///|
/// The header row of `mails ls`.
pub fn envelope_table_header(
  uid_width : Int32,
  from_width : Int32,
  subject_width : Int32,
) -> String {
  pad_to("UID", uid_width) +
  "  " +
  pad_to("DATE", 16) +
  "  " +
  pad_to("FROM", from_width) +
  "  " +
  // The marker column of `envelope_table_row`.
  "  " +
  pad_to("SUBJECT", subject_width)
}

///|
/// One row of `mails ls`.
pub fn envelope_table_row(
  envelope : Envelope,
  uid_width : Int32,
  from_width : Int32,
  subject_width : Int32,
) -> String {
  let mark = if envelope.seen { " " } else { "*" }
  pad_to(envelope.uid, uid_width) +
  "  " +
  pad_to(short_date(envelope.date), 16) +
  "  " +
  pad_to(fit_width(envelope.from, from_width), from_width) +
  "  " +
  mark +
  " " +
  fit_width(envelope.subject, subject_width)
}

///|
/// Render the whole table, newest first, which is the order readers expect.
fn trim_trailing_spaces(text : String) -> String {
  let chars = text.to_array()
  let mut end = chars.length()
  while end > 0 && chars[end - 1] == ' ' {
    end = end - 1
  }
  let out = StringBuilder()
  for index in 0.. String {
  if envelopes.length() == 0 {
    return ""
  }
  let mut uid_width = 3
  for envelope in envelopes {
    if envelope.uid.length() > uid_width {
      uid_width = envelope.uid.length()
    }
  }
  let from_width = 24
  let subject_width = 60
  let lines : Array[String] = [
    envelope_table_header(uid_width, from_width, subject_width),
  ]
  for envelope in envelopes {
    lines.push(
      envelope_table_row(envelope, uid_width, from_width, subject_width),
    )
  }
  // The last column is not padded, so drop the padding the header carries:
  // trailing spaces only make `mails ls` noisy in a pipe.
  let cleaned : Array[String] = []
  for line in lines {
    cleaned.push(trim_trailing_spaces(line))
  }
  cleaned.join("\n")
}

///|
/// `LIST "" "*"`: every folder the server knows about, with modified UTF-7
/// names decoded so `[Gmail]/&XfJT0ZAB-` prints as `[Gmail]/Sent Mail`.
pub async fn ImapClient::list_folders(client : ImapClient) -> Array[Folder] {
  let reply = execute(client, "LIST \"\" \"*\"")
  let out : Array[Folder] = []
  for line in reply.lines {
    match parse_list_line(line.text) {
      Some(folder) =>
        out.push({ ..folder, name: decode_modified_utf7(folder.name), })
      None => ()
    }
  }
  out
}

///|
/// Sort UID strings numerically and keep the newest `limit`.
fn newest_uids(uids : Array[String], limit : Int32) -> Array[String] {
  let values : Array[Int32] = []
  for raw in uids {
    let value = @string.parse_int(raw) catch { _ => -1 }
    if value > 0 {
      values.push(value)
    }
  }
  // A big mailbox has one UID per message, and a hand-written selection sort
  // is quadratic: on a 100k-message mailbox `mails ls` looked hung.
  values.sort()
  let out : Array[String] = []
  let mut position = values.length() - 1
  while position >= 0 && out.length() < limit {
    out.push(values[position].to_string())
    position = position - 1
  }
  out
}

///|
/// The messages of the selected mailbox, newest first.
///
/// One `UID SEARCH` to learn the UIDs, then one header-only `UID FETCH` per
/// batch of 50. Only `Date`, `From` and `Subject` are requested, so listing a
/// large mailbox stays cheap.
pub async fn ImapClient::list_envelopes(
  client : ImapClient,
  limit : Int32,
  unread_only : Bool,
) -> Array[Envelope] {
  let all = if unread_only {
    let reply = execute(client, "UID SEARCH UNSEEN")
    let out : Array[String] = []
    for line in reply_texts(reply) {
      if line.to_upper().has_prefix("* SEARCH") {
        let parts = tokens(line)
        let mut index = 2
        while index < parts.length() {
          out.push(parts[index])
          index = index + 1
        }
      }
    }
    out
  } else {
    client.search_uids(1)
  }
  let wanted = newest_uids(all, limit)
  let out : Array[Envelope] = []
  let by_uid : Map[String, Envelope] = Map([])
  let mut start = 0
  while start < wanted.length() {
    let end = if start + 50 < wanted.length() {
      start + 50
    } else {
      wanted.length()
    }
    let batch = wanted[start:end]
    let reply = execute(
      client,
      "UID FETCH " +
      batch.join(",") +
      " (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (DATE FROM SUBJECT)])",
    )
    for line in reply.lines {
      match parse_header_fetch(line) {
        Some(envelope) => by_uid.set(envelope.uid, envelope)
        None => ()
      }
    }
    start = end
  }
  // Servers answer a FETCH in mailbox (ascending UID) order, not in the order
  // the UIDs were asked for, so emit them newest first as `ls` promises.
  for uid in wanted {
    match by_uid.get(uid) {
      Some(envelope) => out.push(envelope)
      None => ()
    }
  }
  out
}

///|
/// Mailbox names servers use for sent mail when they do not advertise the
/// RFC 6154 `\Sent` flag. Chinese providers are common here: QQ Mail, 163 and
/// Tencent Exmail file sent copies under `已发送`.
let sent_mailbox_names : Array[String] = [
  "sent", "sent items", "sent mail", "sent messages", "已发送", "已发送邮件",
  "寄件备份", "寄件備份",
]

///|
/// Pick the Sent mailbox out of a `LIST` result.
///
/// The server's own answer wins: RFC 6154 gives the mailbox carrying the
/// `\Sent` special-use attribute, so a localized name such as `[Gmail]/已发送邮件`
/// needs no guessing. Servers without special-use (many Chinese providers,
/// older Dovecot setups) fall back to a case-insensitive name match.
fn sent_mailbox_from_folders(folders : Array[Folder]) -> String? {
  for folder in folders {
    if folder_is_noselect(folder) {
      continue
    }
    for attribute in folder.attributes {
      if attribute.to_upper() == "\\SENT" {
        return Some(folder.name)
      }
    }
  }
  for wanted in sent_mailbox_names {
    for folder in folders {
      if folder_is_noselect(folder) {
        continue
      }
      if folder.name.to_lower() == wanted {
        return Some(folder.name)
      }
    }
  }
  None
}

///|
/// The mailbox a copy of a sent message belongs in, or `None` when the server
/// does not say and no conventional name matches.
pub async fn ImapClient::find_sent_mailbox(client : ImapClient) -> String? {
  sent_mailbox_from_folders(client.list_folders())
}

///|
/// The raw message, headers included, for `mails read --raw` and for piping to
/// another tool.
pub async fn ImapClient::fetch_raw(client : ImapClient, uid : String) -> Bytes? {
  let reply = execute(client, "UID FETCH " + uid + " (BODY.PEEK[])")
  for line in reply.lines {
    if line.text.to_upper().contains("FETCH") && line.literals.length() > 0 {
      return Some(line.literals[0])
    }
  }
  None
}