///|
/// The session state machine tracks the SMTP conversation progress.
pub enum SmtpState {
  Disconnected
  Connected
  EhloDone
  AuthDone
  MailFromDone
  RcptDone
  DataDone
  QuitDone
} derive(Eq, Debug)

///|
/// A stateful SMTP client session over a `SmtpTransport`. The session never
/// touches a socket itself; all I/O goes through the injected transport
/// (FakeTransport for deterministic tests, the async socket adapter on native).
pub struct SmtpSession[T] {
  transport : T
  config : SmtpConfig
  mut state : SmtpState
  mut caps : SmtpCaps
  trace : SmtpTrace
}

///|
pub fn[T] SmtpSession::new(
  transport : T,
  config : SmtpConfig,
) -> SmtpSession[T] {
  {
    transport,
    config,
    state: Disconnected,
    caps: SmtpCaps::empty(),
    trace: SmtpTrace::empty(),
  }
}

///|
pub fn[T] SmtpSession::state(self : SmtpSession[T]) -> SmtpState {
  self.state
}

///|
pub fn[T] SmtpSession::caps(self : SmtpSession[T]) -> SmtpCaps {
  self.caps
}

///|
pub fn[T] SmtpSession::trace(self : SmtpSession[T]) -> SmtpTrace {
  self.trace
}

///|
/// Read a complete (possibly multi-line) server reply.
async fn[T : SmtpTransport] SmtpSession::read_reply(
  self : SmtpSession[T],
) -> ServerReply raise MailFailure {
  let lines = []
  let mut more = true
  while more {
    let line = self.transport.read_line()
    lines.push(line)
    match parse_reply_line(line) {
      Some(r) => more = r.more
      None => {
        if lines.length() == 1 {
          raise MailFailure::smtp(MM_SMTP_001, "malformed SMTP reply: \{line}")
        }
        more = false
      }
    }
  }
  let reply = ServerReply::from_lines(lines)
  self.trace.final_code = reply.code
  reply
}

///|
/// Send one line and read the reply, recording the interaction in the trace.
async fn[T : SmtpTransport] SmtpSession::command(
  self : SmtpSession[T],
  line : String,
) -> ServerReply raise MailFailure {
  self.transport.write_line(line)
  let reply = self.read_reply()
  self.trace.add_step(line, reply, 0)
  reply
}

///|
/// Send a sensitive line (AUTH payload) that must not appear in the trace.
async fn[T : SmtpTransport] SmtpSession::sensitive_command(
  self : SmtpSession[T],
  line : String,
) -> ServerReply raise MailFailure {
  self.transport.write_line(line)
  let reply = self.read_reply()
  self.trace.add_step("[REDACTED]", reply, 0)
  reply
}

///|
/// Connect and read the server greeting (220).
pub async fn[T : SmtpTransport] SmtpSession::connect(
  self : SmtpSession[T],
) -> Unit raise MailFailure {
  self.transport.connect(self.config)
  let greeting = self.read_reply()
  self.trace.add_step("CONNECT", greeting, 0)
  guard greeting.is_positive() else {
    raise MailFailure::smtp(
      MM_SMTP_001,
      "server did not greet with 2xx: \{greeting.lines.join(" / ")}",
    )
  }
  self.state = Connected
}

///|
/// Negotiate with EHLO (falling back to HELO when the server rejects EHLO).
pub async fn[T : SmtpTransport] SmtpSession::ehlo(
  self : SmtpSession[T],
) -> Unit raise MailFailure {
  let reply = self.command(encode_ehlo(self.config.host))
  if reply.code == 250 {
    // every EHLO line may carry a capability, including the first line
    let caps_lines = reply.lines.map(fn(l) { strip_reply_code(l) })
    self.caps = parse_caps(caps_lines)
    self.trace.capabilities = self.caps
    self.state = EhloDone
    return
  }
  if reply.is_permanent() {
    let helo = self.command(encode_helo(self.config.host))
    guard helo.is_positive() else {
      raise MailFailure::smtp(
        MM_SMTP_001,
        "EHLO and HELO both rejected: \{helo.lines.join(" / ")}",
      )
    }
    self.trace.capabilities = self.caps
    self.state = EhloDone
    return
  }
  raise MailFailure::smtp(
    MM_SMTP_001,
    "EHLO failed: \{reply.lines.join(" / ")}",
  )
}

///|
/// Authenticate using the configured method (MM_AUTH_001 / MM_AUTH_002).
pub async fn[T : SmtpTransport] SmtpSession::auth(
  self : SmtpSession[T],
  auth : AuthMethod,
) -> Unit raise MailFailure {
  match auth {
    Plain(user, pass) => self.auth_plain(user, pass)
    Login(user, pass) => self.auth_login(user, pass)
    CramMd5(user, pass) => self.auth_cram_md5(user, pass)
  }
  self.state = AuthDone
}

///|
async fn[T : SmtpTransport] SmtpSession::auth_plain(
  self : SmtpSession[T],
  user : String,
  pass : String,
) -> Unit raise MailFailure {
  self.trace.auth_used = "PLAIN"
  let creds = auth_plain_credentials(user, pass)
  let reply = self.command(encode_auth_plain(creds))
  if reply.is_positive() {
    return
  }
  if reply.code == 334 {
    // server expects a separate empty response line
    let reply2 = self.sensitive_command("")
    guard reply2.is_positive() else {
      raise MailFailure::auth(
        "AUTH PLAIN rejected: \{reply2.lines.join(" / ")}",
      )
    }
    return
  }
  raise MailFailure::auth("AUTH PLAIN rejected: \{reply.lines.join(" / ")}")
}

///|
async fn[T : SmtpTransport] SmtpSession::auth_login(
  self : SmtpSession[T],
  user : String,
  pass : String,
) -> Unit raise MailFailure {
  self.trace.auth_used = "LOGIN"
  let reply = self.command(encode_auth_login())
  if reply.is_positive() {
    return
  }
  guard reply.code == 334 else {
    raise MailFailure::auth("AUTH LOGIN rejected: \{reply.lines.join(" / ")}")
  }
  let reply2 = self.sensitive_command(base64_encode_str(user))
  if reply2.is_positive() {
    return
  }
  guard reply2.code == 334 else {
    raise MailFailure::auth(
      "AUTH LOGIN username rejected: \{reply2.lines.join(" / ")}",
    )
  }
  let reply3 = self.sensitive_command(base64_encode_str(pass))
  guard reply3.is_positive() else {
    raise MailFailure::auth(
      "AUTH LOGIN password rejected: \{reply3.lines.join(" / ")}",
    )
  }
}

///|
async fn[T : SmtpTransport] SmtpSession::auth_cram_md5(
  self : SmtpSession[T],
  user : String,
  pass : String,
) -> Unit raise MailFailure {
  self.trace.auth_used = "CRAM-MD5"
  let reply = self.command(encode_auth_cram_md5())
  guard reply.code == 334 else {
    raise MailFailure::auth(
      "AUTH CRAM-MD5 rejected: \{reply.lines.join(" / ")}",
    )
  }
  let challenge = reply.text.trim().to_owned()
  let response = cram_md5_response(user, pass, challenge)
  let reply2 = self.sensitive_command(response)
  guard reply2.is_positive() else {
    raise MailFailure::auth(
      "AUTH CRAM-MD5 rejected: \{reply2.lines.join(" / ")}",
    )
  }
}

///|
/// Raise a classified error based on the reply code: permanent (5xx) failures
/// are `SmtpPermanent`, transient (4xx) failures are `SmtpTransient`, and
/// anything else is a generic protocol `Smtp` error (MM_SMTP_003).
fn raise_reply(reply : ServerReply, context : String) -> Unit raise MailFailure {
  if reply.is_permanent() {
    raise MailFailure::smtp_permanent("\{context}: \{reply.lines.join(" / ")}")
  } else if reply.is_transient() {
    raise MailFailure::smtp_transient("\{context}: \{reply.lines.join(" / ")}")
  } else {
    raise MailFailure::smtp(
      MM_SMTP_003,
      "\{context}: \{reply.lines.join(" / ")}",
    )
  }
}

///|
pub async fn[T : SmtpTransport] SmtpSession::mail_from(
  self : SmtpSession[T],
  reverse_path : String,
) -> Unit raise MailFailure {
  let reply = self.command(encode_mail_from(reverse_path))
  guard reply.is_positive() else {
    raise_reply(reply, "MAIL FROM \{reverse_path} rejected")
  }
  self.state = MailFromDone
}

///|
pub async fn[T : SmtpTransport] SmtpSession::rcpt_to(
  self : SmtpSession[T],
  forward_path : String,
) -> Unit raise MailFailure {
  let reply = self.command(encode_rcpt_to(forward_path))
  guard reply.is_positive() else {
    raise_reply(reply, "RCPT TO \{forward_path} rejected")
  }
  self.state = RcptDone
}

///|
/// Send the DATA command, the already dot-stuffed message lines, and the
/// terminating `.` (MM_SMTP_007).
pub async fn[T : SmtpTransport] SmtpSession::send_data(
  self : SmtpSession[T],
  lines : Array[String],
) -> Unit raise MailFailure {
  let reply = self.command("DATA")
  guard reply.is_intermediate() else { raise_reply(reply, "DATA not accepted") }
  for line in lines {
    self.transport.write_line(line)
  }
  let term = self.command(".")
  guard term.is_positive() else {
    raise_reply(term, "message rejected after DATA")
  }
  self.state = DataDone
}

///|
/// Send QUIT and close.
pub async fn[T : SmtpTransport] SmtpSession::quit(
  self : SmtpSession[T],
) -> Unit raise MailFailure {
  let reply = self.command("QUIT")
  self.state = QuitDone
  self.transport.close()
  let _ = reply
}

///|
pub fn[T : SmtpTransport] SmtpSession::close(self : SmtpSession[T]) -> Unit {
  self.transport.close()
  self.state = Disconnected
}