///|
#derive(Eq, Debug)
pub(all) enum TransportError {
  ClosedTransport
  IoFailure(String)
}

///|
#derive(Eq, Debug)
pub(all) enum FailureClass {
  Transient
  Permanent
}

///|
#derive(Eq, Debug)
pub struct DeliveryOutcome {
  accepted : Array[@core.Mailbox]
  rejected : Array[@core.Mailbox]
  unsent : Array[@core.Mailbox]
  response : String
  failure_class : FailureClass?
}

///|
#derive(Eq, Debug)
pub struct DeliveryRecord {
  envelope : @core.Envelope
  encoded_message : String
}

///|
#derive(Eq, Debug)
pub struct FileArtifact {
  path : String
  content : String
}

///|
#derive(Eq, Debug)
pub struct SmtpAttempt {
  envelope : @core.Envelope
  encoded_message : String
  connection_id : Int32
  trace : Array[String]
}

///|
#derive(Debug)
pub struct FakeTransport {
  records : Array[DeliveryRecord]
  mut closed : Bool
}

///|
#derive(Debug)
pub struct FileTransport {
  root_dir : String
  artifacts : Array[FileArtifact]
  mut next_index : Int32
  mut closed : Bool
}

///|
#derive(Debug)
pub struct StubSmtpTransport {
  pool_size : Int32
  planned_outcomes : Array[DeliveryOutcome]
  mut next_plan_index : Int32
  attempts : Array[SmtpAttempt]
  mut send_count : Int32
  mut closed : Bool
}

///|
pub fn DeliveryOutcome::all_accepted(
  recipients : Array[@core.Mailbox],
  response~ : String = "250 OK",
) -> DeliveryOutcome {
  {
    accepted: recipients,
    rejected: [],
    unsent: [],
    response,
    failure_class: None,
  }
}

///|
pub fn DeliveryOutcome::partial(
  accepted : Array[@core.Mailbox],
  rejected : Array[@core.Mailbox],
  unsent : Array[@core.Mailbox],
  response : String,
) -> DeliveryOutcome {
  let failure_class = derive_failure_class(accepted, rejected, unsent, response)
  { accepted, rejected, unsent, response, failure_class, }
}

///|
pub fn FakeTransport::new() -> FakeTransport {
  { records: [], closed: false, }
}

///|
pub fn FakeTransport::send(
  self : FakeTransport,
  envelope : @core.Envelope,
  encoded_message : String,
) -> Result[DeliveryOutcome, TransportError] {
  if self.closed {
    return Err(ClosedTransport)
  }
  self.records.push({ envelope, encoded_message, })
  Ok(DeliveryOutcome::all_accepted(envelope.rcpt_to))
}

///|
pub fn FakeTransport::send_message(
  self : FakeTransport,
  envelope : @core.Envelope,
  message : @core.Message,
) -> Result[DeliveryOutcome, TransportError] {
  self.send(envelope, @codec.to_rfc5322(message))
}

///|
pub fn FakeTransport::close(self : FakeTransport) -> Unit {
  self.closed = true
}

///|
pub fn FakeTransport::records(self : FakeTransport) -> Array[DeliveryRecord] {
  self.records
}

///|
pub fn FileTransport::new(root_dir : String) -> FileTransport {
  { root_dir, artifacts: [], next_index: 0, closed: false, }
}

///|
priv struct FileMimeWriter {
  file : @fs.File
}

///|
impl @codec.MimeWriter for FileMimeWriter with fn write_text(self, text) {
  self.file.write(text)
}

///|
pub async fn FileTransport::send(
  self : FileTransport,
  envelope : @core.Envelope,
  encoded_message : String,
) -> Result[DeliveryOutcome, TransportError] {
  if self.closed {
    return Err(ClosedTransport)
  }
  let artifact_path = self.root_dir +
    "/" +
    format_artifact_name(self.next_index)
  try {
    if !@fs.exists(self.root_dir) {
      @fs.mkdir(self.root_dir, recursive=true)
    }
    @fs.write_file(artifact_path, encoded_message, create_mode=CreateOrTruncate)
  } catch {
    err => return Err(IoFailure(err.to_string()))
  }
  let artifact = { path: artifact_path, content: encoded_message, }
  self.next_index += 1
  self.artifacts.push(artifact)
  Ok(DeliveryOutcome::all_accepted(envelope.rcpt_to))
}

///|
pub async fn FileTransport::send_message(
  self : FileTransport,
  envelope : @core.Envelope,
  message : @core.Message,
) -> Result[DeliveryOutcome, TransportError] {
  if self.closed {
    return Err(ClosedTransport)
  }
  try {
    if !@fs.exists(self.root_dir) {
      @fs.mkdir(self.root_dir, recursive=true)
    }
    let artifact_path = self.root_dir +
      "/" +
      format_artifact_name(self.next_index)
    let file = @fs.open(
      artifact_path,
      mode=WriteOnly,
      create_mode=CreateOrTruncate,
    )
    defer file.close()
    @codec.write_to(FileMimeWriter::{ file, }, message)
    let encoded = @fs.read_file(artifact_path).text()
    self.next_index += 1
    self.artifacts.push({ path: artifact_path, content: encoded, })
    Ok(DeliveryOutcome::all_accepted(envelope.rcpt_to))
  } catch {
    err => Err(IoFailure(err.to_string()))
  }
}

///|
pub fn FileTransport::close(self : FileTransport) -> Unit {
  self.closed = true
}

///|
pub fn FileTransport::artifacts(self : FileTransport) -> Array[FileArtifact] {
  self.artifacts
}

///|
pub fn StubSmtpTransport::new(
  pool_size~ : Int32 = 1,
  planned_outcomes~ : Array[DeliveryOutcome] = [],
) -> StubSmtpTransport {
  let normalized_pool = if pool_size < 1 { 1 } else { pool_size }
  {
    pool_size: normalized_pool,
    planned_outcomes,
    next_plan_index: 0,
    attempts: [],
    send_count: 0,
    closed: false,
  }
}

///|
pub fn StubSmtpTransport::send(
  self : StubSmtpTransport,
  envelope : @core.Envelope,
  encoded_message : String,
) -> Result[DeliveryOutcome, TransportError] {
  if self.closed {
    return Err(ClosedTransport)
  }
  let connection_id = assign_connection_id(self.send_count, self.pool_size)
  let trace = build_smtp_trace(envelope)
  self.attempts.push({ envelope, encoded_message, connection_id, trace, })
  self.send_count += 1
  Ok(self.next_outcome(envelope))
}

///|
pub fn StubSmtpTransport::send_message(
  self : StubSmtpTransport,
  envelope : @core.Envelope,
  message : @core.Message,
) -> Result[DeliveryOutcome, TransportError] {
  self.send(envelope, @codec.to_rfc5322(message))
}

///|
pub fn StubSmtpTransport::close(self : StubSmtpTransport) -> Unit {
  self.closed = true
}

///|
pub fn StubSmtpTransport::attempts(
  self : StubSmtpTransport,
) -> Array[SmtpAttempt] {
  self.attempts
}

///|
fn StubSmtpTransport::next_outcome(
  self : StubSmtpTransport,
  envelope : @core.Envelope,
) -> DeliveryOutcome {
  match self.planned_outcomes.get(self.next_plan_index) {
    Some(outcome) => {
      self.next_plan_index += 1
      outcome
    }
    None => DeliveryOutcome::all_accepted(envelope.rcpt_to)
  }
}

///|
fn format_artifact_name(index : Int32) -> String {
  let next = index + 1
  if next < 10 {
    "message-000" + next.to_string() + ".eml"
  } else if next < 100 {
    "message-00" + next.to_string() + ".eml"
  } else if next < 1000 {
    "message-0" + next.to_string() + ".eml"
  } else {
    "message-" + next.to_string() + ".eml"
  }
}

///|
fn build_smtp_trace(envelope : @core.Envelope) -> Array[String] {
  [
    "connect",
    "ehlo",
    "mail-from:" + envelope.mail_from.address,
    "rcpt-count:" + envelope.rcpt_to.length().to_string(),
    "data",
    "quit",
  ]
}

///|
fn classify_smtp_status(status : Int32) -> FailureClass {
  if status >= 400 && status < 500 {
    Transient
  } else {
    Permanent
  }
}

///|
fn derive_failure_class(
  accepted : Array[@core.Mailbox],
  rejected : Array[@core.Mailbox],
  unsent : Array[@core.Mailbox],
  response : String,
) -> FailureClass? {
  if rejected.is_empty() && unsent.is_empty() {
    None
  } else {
    let _ = accepted
    match parse_status_code(response) {
      Some(status) => Some(classify_smtp_status(status))
      None => Some(Permanent)
    }
  }
}

///|
fn parse_status_code(response : String) -> Int32? {
  if response.length() < 3 {
    return None
  }
  let c0 = response.code_unit_at(0).to_int()
  let c1 = response.code_unit_at(1).to_int()
  let c2 = response.code_unit_at(2).to_int()
  if is_ascii_digit_unit(c0) &&
    is_ascii_digit_unit(c1) &&
    is_ascii_digit_unit(c2) {
    Some(
      digit_value_unit(c0) * 100 +
      digit_value_unit(c1) * 10 +
      digit_value_unit(c2),
    )
  } else {
    None
  }
}

///|
fn is_ascii_digit_unit(code_unit : Int32) -> Bool {
  code_unit >= '0'.to_int() && code_unit <= '9'.to_int()
}

///|
fn digit_value_unit(code_unit : Int32) -> Int32 {
  code_unit - '0'.to_int()
}

///|
fn assign_connection_id(send_index : Int32, pool_size : Int32) -> Int32 {
  send_index % pool_size
}