// Email adapter: SMTP send plus RFC 5322 parsing.
//
// Mirrors Hermes `plugins/platforms/email/adapter.py` in spirit: outbound mail
// is composed as a UTF-8 `text/plain` message (base64 "encoded-word" subject
// when needed) and delivered over SMTP, with implicit TLS (465), STARTTLS
// (587) or plain (25) transports. Inbound mail is parsed from a raw RFC 5322
// message — hand a copy from IMAP (or a mail-to-webhook relay) to
// `email_parse_message`.

///|
/// SMTP settings resolved from `[email.extra]` (Hermes `EMAIL_*` keys).
pub(all) struct SmtpConfig {
  host : String
  port : Int32
  user : String
  password : String
  /// `ssl` (implicit TLS) | `starttls` | `none`
  tls : String
  from : String
  from_name : String
} derive(Debug)

///|
/// Resolve SMTP settings, inferring the TLS mode from the port when unset.
pub fn email_smtp_config(cfg : PlatformConfig) -> SmtpConfig {
  let port = cfg.extra_int("smtp_port", 587)
  let user = cfg.extra_str("smtp_user", "")
  let password = match cfg.extra_first(["smtp_password"]) {
    Some(p) => p
    None => cfg.token_of().unwrap_or("")
  }
  let tls = match cfg.extra_first(["smtp_tls", "smtp_security"]) {
    Some(t) => t
    None =>
      if port == 465 {
        "ssl"
      } else if port == 587 || port == 25 {
        "starttls"
      } else {
        "none"
      }
  }
  {
    host: cfg.extra_str("smtp_host", ""),
    port,
    user,
    password,
    tls: tls.to_lower(),
    from: cfg.extra_str("from_address", user),
    from_name: cfg.extra_str("from_name", ""),
  }
}

///|
/// Encode a header value: ASCII passes through, anything else becomes a
/// base64 "encoded-word" (RFC 2047).
pub fn email_header_encode(value : String) -> String {
  let mut ascii = true
  for c in value {
    if c.to_int() > 126 {
      ascii = false
    }
  }
  if ascii {
    value
  } else {
    "=?UTF-8?B?\{@base64.encode(@utf8.encode(value)[:])}?="
  }
}

///|
/// Civil date (year, month, day) from a Unix day number (Howard Hinnant's
/// `civil_from_days`).
fn civil_from_days(days : Int64) -> (Int64, Int64, Int64) {
  let z = days + 719468L
  let era = (if z >= 0L { z } else { z - 146096L }) / 146097L
  let doe = z - era * 146097L
  let yoe = (doe - doe / 1460L + doe / 36524L - doe / 146096L) / 365L
  let y0 = yoe + era * 400L
  let doy = doe - (365L * yoe + yoe / 4L - yoe / 100L)
  let mp = (5L * doy + 2L) / 153L
  let d = doy - (153L * mp + 2L) / 5L + 1L
  let m = if mp < 10L { mp + 3L } else { mp - 9L }
  let y = if m <= 2L { y0 + 1L } else { y0 }
  (y, m, d)
}

///|
/// RFC 5322 `Date:` header for an epoch-millisecond timestamp.
pub fn format_rfc5322_date(ms : Int64) -> String {
  let seconds = if ms < 0L { 0L } else { ms / 1000L }
  let mut days = seconds / 86400L
  let mut rem = seconds % 86400L
  if rem < 0L {
    rem = rem + 86400L
    days = days - 1L
  }
  let (y, m, d) = civil_from_days(days)
  let weekday = ((days + 4L) % 7L + 7L) % 7L
  let names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
  let months = [
    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
    "Dec",
  ]
  let hh = rem / 3600L
  let mm = rem % 3600L / 60L
  let ss = rem % 60L
  let wd = names[weekday.to_int()]
  let mon = months[(m - 1L).to_int()]
  "\{wd}, \{pad2(d)} \{mon} \{y} \{pad2(hh)}:\{pad2(mm)}:\{pad2(ss)} +0000"
}

///|
/// Zero-pad a number to two digits.
fn pad2(n : Int64) -> String {
  if n < 10L {
    "0\{n}"
  } else {
    n.to_string()
  }
}

///|
/// Compose an RFC 5322 message.
pub fn email_build_message(
  from : String,
  from_name : String,
  to : String,
  subject : String,
  body : String,
  in_reply_to? : String,
  references? : String,
  date_ms? : Int64,
) -> String {
  let sb = StringBuilder()
  if from_name.is_empty() {
    sb.write_string("From: \{from}\r\n")
  } else {
    sb.write_string("From: \{email_header_encode(from_name)} <\{from}>\r\n")
  }
  sb.write_string("To: \{to}\r\n")
  sb.write_string("Subject: \{email_header_encode(subject)}\r\n")
  let stamp = match date_ms {
    Some(ms) => ms
    None => now_ms()
  }
  sb.write_string("Date: \{format_rfc5322_date(stamp)}\r\n")
  let domain = match from.split_once("@") {
    Some((_, d)) => d.to_owned()
    None => "imrelay"
  }
  sb.write_string("Message-ID: <\{random_hex(20)}@\{domain}>\r\n")
  match in_reply_to {
    Some(id) => sb.write_string("In-Reply-To: <\{id}>\r\n")
    None => ()
  }
  match references {
    Some(r) => sb.write_string("References: <\{r}>\r\n")
    None => ()
  }
  sb.write_string("MIME-Version: 1.0\r\n")
  sb.write_string("Content-Type: text/plain; charset=UTF-8\r\n")
  sb.write_string("Content-Transfer-Encoding: 8bit\r\n")
  sb.write_string("\r\n")
  sb.write_string(body.replace_all(old="\n", new="\r\n"))
  sb.to_string()
}

///|
/// Escape a message body for SMTP `DATA` (dot-stuffing).
pub fn smtp_dot_stuff(message : String) -> String {
  let out = message.replace_all(old="\r\n.", new="\r\n..")
  if out.has_prefix(".") {
    ".\{out}"
  } else {
    out
  }
}

///|
/// Read a (possibly multi-line) SMTP reply.
async fn[W : @io.Reader] smtp_read_reply(conn : W) -> String {
  let sb = StringBuilder()
  let mut done = false
  while !done {
    match @io.Reader::read_until(conn, "\r\n") {
      None => done = true
      Some(line) => {
        sb.write_string(line)
        sb.write_string("\n")
        if line.length() < 4 {
          done = true
        } else {
          let sep = line[3:4].to_owned()
          if sep != "-" {
            done = true
          }
        }
      }
    }
  }
  sb.to_string()
}

///|
/// First three characters of an SMTP reply (the status code).
fn smtp_code(reply : String) -> String {
  if reply.length() >= 3 {
    reply[:3].to_owned()
  } else {
    reply
  }
}

///|
/// Write a command and read its reply.
async fn[W : @io.Reader + @io.Writer] smtp_cmd(
  conn : W,
  cmd : String,
) -> String {
  @io.Writer::write(conn, "\{cmd}\r\n")
  smtp_read_reply(conn)
}

///|
/// Run the SMTP dialogue after the transport is ready. ``greeting`` is the
/// server banner (empty for the STARTTLS continuation, which re-EHLOs).
async fn[W : @io.Reader + @io.Writer] smtp_converse(
  conn : W,
  smtp : SmtpConfig,
  to : String,
  message : String,
) -> Result[Unit, String] {
  let mut reply = smtp_cmd(conn, "EHLO imrelay")
  if smtp_code(reply) != "250" {
    return Err("EHLO failed: \{reply}")
  }
  if !smtp.user.is_empty() {
    reply = smtp_cmd(conn, "AUTH LOGIN")
    if smtp_code(reply) == "334" {
      ignore(reply)
      reply = smtp_cmd(conn, @base64.encode(@utf8.encode(smtp.user)[:]))
      if smtp_code(reply) != "334" {
        return Err("AUTH user rejected: \{reply}")
      }
      reply = smtp_cmd(conn, @base64.encode(@utf8.encode(smtp.password)[:]))
      if smtp_code(reply) != "235" {
        return Err("AUTH password rejected: \{reply}")
      }
    } else {
      return Err("AUTH LOGIN unsupported: \{reply}")
    }
  }
  reply = smtp_cmd(conn, "MAIL FROM:<\{smtp.from}>")
  if smtp_code(reply) != "250" {
    return Err("MAIL FROM rejected: \{reply}")
  }
  reply = smtp_cmd(conn, "RCPT TO:<\{to}>")
  if smtp_code(reply) != "250" && smtp_code(reply) != "251" {
    return Err("RCPT TO rejected: \{reply}")
  }
  reply = smtp_cmd(conn, "DATA")
  if smtp_code(reply) != "354" {
    return Err("DATA rejected: \{reply}")
  }
  @io.Writer::write(conn, smtp_dot_stuff(message))
  @io.Writer::write(conn, "\r\n.\r\n")
  reply = smtp_read_reply(conn)
  if smtp_code(reply) != "250" {
    return Err("message rejected: \{reply}")
  }
  ignore(smtp_cmd(conn, "QUIT"))
  Ok(())
}

///|
/// Deliver ``body`` as an email to ``to``. ``subject`` defaults to
/// ``[email.extra] default_subject``.
pub async fn email_send(
  cfg : PlatformConfig,
  to : String,
  subject : String,
  body : String,
  in_reply_to? : String,
) -> SendResult {
  let smtp = email_smtp_config(cfg)
  if smtp.host.is_empty() {
    return missing_config(Email, "EMAIL_SMTP_HOST")
  }
  if to.is_empty() {
    return SendResult::failed("email: missing recipient", kind=NotFound)
  }
  let message = email_build_message(
    smtp.from,
    smtp.from_name,
    to,
    subject,
    body,
    in_reply_to?,
  )
  match smtp_deliver(smtp, to, message) {
    Ok(_) => SendResult::ok()
    Err(e) =>
      SendResult::failed(
        "email: \{e}",
        retryable=true,
        kind=classify_send_error(e),
      )
  }
}

///|
/// Open the transport and run the SMTP dialogue.
async fn smtp_deliver(
  smtp : SmtpConfig,
  to : String,
  message : String,
) -> Result[Unit, String] {
  let tcp = @socket.Tcp::connect_to_host(smtp.host, port=smtp.port) catch {
    e => return Err("connect failed: \{e}")
  }
  if smtp.tls == "ssl" {
    let tls = @tls.Tls::client(tcp, host=smtp.host) catch {
      e => return Err("TLS failed: \{e}")
    }
    let greeting = smtp_read_reply(tls)
    if smtp_code(greeting) != "220" {
      return Err("bad banner: \{greeting}")
    }
    return smtp_converse(tls, smtp, to, message)
  }
  let greeting = smtp_read_reply(tcp)
  if smtp_code(greeting) != "220" {
    return Err("bad banner: \{greeting}")
  }
  if smtp.tls == "starttls" {
    let ehlo = smtp_cmd(tcp, "EHLO imrelay")
    if smtp_code(ehlo) != "250" {
      return Err("EHLO failed: \{ehlo}")
    }
    let reply = smtp_cmd(tcp, "STARTTLS")
    if smtp_code(reply) != "220" {
      return Err("STARTTLS rejected: \{reply}")
    }
    let tls = @tls.Tls::client(tcp, host=smtp.host) catch {
      e => return Err("TLS failed: \{e}")
    }
    return smtp_converse(tls, smtp, to, message)
  }
  smtp_converse(tcp, smtp, to, message)
}

///|
/// Extract the bare address from a `From:` header value.
fn extract_email(value : String) -> String {
  match value.split_once("<") {
    Some((_, rest)) =>
      match rest.split_once(">") {
        Some((addr, _)) => addr.trim().to_owned()
        None => rest.trim().to_owned()
      }
    None => value.trim().to_owned()
  }
}

///|
/// Display name from a `Name `-style header; empty when there is none.
fn extract_email_name(value : String) -> String {
  match value.split_once("<") {
    Some((name, _)) => name.trim().to_owned()
    None => ""
  }
}

///|
/// Strip `<` `>` around a message id.
fn strip_angle(v : String) -> String {
  let t = if v.has_prefix("<") { v[1:].to_owned() } else { v }
  if t.has_suffix(">") {
    t[:t.length() - 1].to_owned()
  } else {
    t
  }
}

///|
/// Parse a raw RFC 5322 message into a `MessageEvent` (``From`` becomes the
/// chat id, so replies go back to the sender). ``Reply-To`` wins over ``From``
/// when present, matching normal mailing-list behaviour.
pub fn email_parse_message(raw : String) -> MessageEvent? {
  let (head, body) = match raw.find("\r\n\r\n") {
    Some(i) => (raw[:i].to_owned(), raw[i + 4:].to_owned())
    None =>
      match raw.find("\n\n") {
        Some(i) => (raw[:i].to_owned(), raw[i + 2:].to_owned())
        None => (raw, "")
      }
  }
  let headers : Map[String, String] = Map([])
  let mut last_key = ""
  for line in head.split("\n") {
    let l = line.to_owned()
    let clean = if l.has_suffix("\r") {
      l[:l.length() - 1].to_owned()
    } else {
      l
    }
    if clean.has_prefix(" ") || clean.has_prefix("\t") {
      if !last_key.is_empty() {
        let prev = headers.get(last_key).unwrap_or("")
        headers.set(last_key, "\{prev} \{clean.trim()}")
      }
    } else {
      match clean.split_once(":") {
        Some((k, v)) => {
          let key = k.trim().to_owned().to_lower()
          headers.set(key, v.trim().to_owned())
          last_key = key
        }
        None => ()
      }
    }
  }
  let sender = or_opt(headers.get("reply-to"), headers.get("from"))
  let from = match sender {
    Some(f) => extract_email(f)
    None => return None
  }
  if from.is_empty() {
    return None
  }
  let subject = headers.get("subject").unwrap_or("")
  let message_id = headers.get("message-id").map(strip_angle)
  let in_reply_to = headers.get("in-reply-to").map(strip_angle)
  let mut text = body.trim().to_owned()
  if text.is_empty() {
    text = subject
  }
  let mtype = if text.has_prefix("/") { Command } else { Text }
  let user_name = match sender {
    Some(s) => {
      let n = extract_email_name(s)
      if n.is_empty() {
        None
      } else {
        Some(n)
      }
    }
    None => None
  }
  Some(
    MessageEvent::new(
      text,
      mtype,
      Email,
      from,
      user_id=from,
      user_name?,
      message_id?,
      reply_to_message_id?=in_reply_to,
      media=[],
      timestamp=now_ms(),
    ),
  )
}