///|
/// Encode an owned BSON document using the default safety limits.
pub fn encode(document : Document) -> Bytes raise BsonError {
  encode_with_options(document, EncodeOptions::new())
}

///|
pub fn encode_with_options(
  document : Document,
  options : EncodeOptions,
) -> Bytes raise BsonError {
  encode_document(document, options, 0, "$")
}

///|
pub fn Document::to_bytes(self : Document) -> Bytes raise BsonError {
  encode(self)
}

///|
/// Append this document to an existing official MoonBit Buffer.
pub fn Document::write_to(
  self : Document,
  buffer : @buffer.Buffer,
) -> Unit raise BsonError {
  buffer.write_bytes(encode(self))
}

///|
pub fn encode_to(
  document : Document,
  buffer : @buffer.Buffer,
) -> Unit raise BsonError {
  document.write_to(buffer)
}

///|
pub fn encode_to_with_options(
  document : Document,
  buffer : @buffer.Buffer,
  options : EncodeOptions,
) -> Unit raise BsonError {
  buffer.write_bytes(encode_with_options(document, options))
}

///|
fn encode_document(
  document : Document,
  options : EncodeOptions,
  depth : Int,
  path : String,
) -> Bytes raise BsonError {
  if depth > options.max_depth {
    raise bson_error(
      DepthLimit,
      -1,
      path,
      "maximum BSON nesting depth exceeded",
    )
  }
  let writer = @buffer.Buffer(size_hint=128)
  writer.write_int_le(0)
  for entry in document.to_array() {
    let (key, value) = entry
    let value_path = field_path(path, key)
    let payload = @buffer.Buffer(size_hint=32)
    let type_code = write_element(payload, value, options, depth, value_path)
    let element = @buffer.Buffer(size_hint=payload.length() + key.length() + 2)
    element.write_byte(type_code)
    write_cstring(element, key, value_path)
    element.write_bytes(payload.to_bytes())
    writer.write_bytes(element.to_bytes())
    if writer.length() + 1 > options.max_size {
      raise bson_error(SizeLimit, -1, path, "BSON document exceeds max_size")
    }
  }
  writer.write_byte(0x00)
  finish_sized_value(writer, options, path)
}

///|
fn write_element(
  writer : @buffer.Buffer,
  value : Bson,
  options : EncodeOptions,
  depth : Int,
  path : String,
) -> Byte raise BsonError {
  match value {
    Double(value) => {
      writer.write_double_le(value)
      0x01
    }
    String(value) => {
      write_string(writer, value, options, path)
      0x02
    }
    Document(value) => {
      writer.write_bytes(encode_document(value, options, depth + 1, path))
      0x03
    }
    Array(value) => {
      writer.write_bytes(encode_array(value, options, depth + 1, path))
      0x04
    }
    Binary(value) => {
      write_binary(writer, value, options, path)
      0x05
    }
    Undefined => 0x06
    ObjectId(value) => {
      writer.write_bytes(value.bytes())
      0x07
    }
    Boolean(value) => {
      writer.write_byte(if value { 1 } else { 0 })
      0x08
    }
    DateTime(value) => {
      writer.write_int64_le(value.to_millis())
      0x09
    }
    Null => 0x0A
    Regex(value) => {
      write_regex(writer, value, path)
      0x0B
    }
    DbPointer(value) => {
      write_string(writer, value.collection(), options, path)
      writer.write_bytes(value.id().bytes())
      0x0C
    }
    JavaScript(value) => {
      write_string(writer, value, options, path)
      0x0D
    }
    Symbol(value) => {
      write_string(writer, value, options, path)
      0x0E
    }
    JavaScriptWithScope(value) => {
      write_javascript_with_scope(writer, value, options, depth + 1, path)
      0x0F
    }
    Int32(value) => {
      writer.write_int_le(value)
      0x10
    }
    Timestamp(value) => {
      writer.write_uint_le(value.increment())
      writer.write_uint_le(value.time())
      0x11
    }
    Int64(value) => {
      writer.write_int64_le(value)
      0x12
    }
    Decimal128(value) => {
      writer.write_bytes(value.bytes())
      0x13
    }
    MinKey => 0xFF
    MaxKey => 0x7F
  }
}

///|
fn encode_array(
  values : Array[Bson],
  options : EncodeOptions,
  depth : Int,
  path : String,
) -> Bytes raise BsonError {
  if depth > options.max_depth {
    raise bson_error(
      DepthLimit,
      -1,
      path,
      "maximum BSON nesting depth exceeded",
    )
  }
  let writer = @buffer.Buffer(size_hint=128)
  writer.write_int_le(0)
  for index, value in values {
    let key = index.to_string()
    let value_path = index_path(path, index)
    let payload = @buffer.Buffer(size_hint=32)
    let type_code = write_element(payload, value, options, depth, value_path)
    writer.write_byte(type_code)
    write_cstring(writer, key, value_path)
    writer.write_bytes(payload.to_bytes())
    if writer.length() + 1 > options.max_size {
      raise bson_error(SizeLimit, -1, path, "BSON array exceeds max_size")
    }
  }
  writer.write_byte(0x00)
  finish_sized_value(writer, options, path)
}

///|
fn write_binary(
  writer : @buffer.Buffer,
  value : Binary,
  options : EncodeOptions,
  path : String,
) -> Unit raise BsonError {
  let bytes = value.bytes()
  let extra = if value.subtype() == BinaryOld { 4 } else { 0 }
  if bytes.length() > options.max_size - extra {
    raise bson_error(SizeLimit, -1, path, "BSON binary value exceeds max_size")
  }
  writer.write_int_le(bytes.length() + extra)
  writer.write_byte(value.subtype().to_byte())
  if value.subtype() == BinaryOld {
    writer.write_int_le(bytes.length())
  }
  writer.write_bytes(bytes)
}

///|
fn write_regex(
  writer : @buffer.Buffer,
  value : Regex,
  path : String,
) -> Unit raise BsonError {
  if !regex_is_valid(value) {
    raise bson_error(
      InvalidRegex,
      -1,
      path,
      "regex pattern/options contain NUL or options are not sorted",
    )
  }
  write_cstring(writer, value.pattern(), path)
  write_cstring(writer, value.options(), path)
}

///|
fn write_javascript_with_scope(
  writer : @buffer.Buffer,
  value : JavaScriptWithScope,
  options : EncodeOptions,
  depth : Int,
  path : String,
) -> Unit raise BsonError {
  let scoped = @buffer.Buffer(size_hint=64)
  scoped.write_int_le(0)
  write_string(scoped, value.code(), options, path)
  scoped.write_bytes(encode_document(value.scope(), options, depth, path))
  writer.write_bytes(finish_sized_value(scoped, options, path))
}

///|
fn write_cstring(
  writer : @buffer.Buffer,
  value : String,
  path : String,
) -> Unit raise BsonError {
  if value.contains_code_unit(0) {
    raise bson_error(
      InvalidCString,
      -1,
      path,
      "CString contains a NUL code unit",
    )
  }
  writer.write_bytes(@utf8.encode(value))
  writer.write_byte(0x00)
}

///|
fn write_string(
  writer : @buffer.Buffer,
  value : String,
  options : EncodeOptions,
  path : String,
) -> Unit raise BsonError {
  let bytes = @utf8.encode(value)
  if bytes.length() >= options.max_size {
    raise bson_error(SizeLimit, -1, path, "BSON string exceeds max_size")
  }
  writer.write_int_le(bytes.length() + 1)
  writer.write_bytes(bytes)
  writer.write_byte(0x00)
}

///|
fn finish_sized_value(
  writer : @buffer.Buffer,
  options : EncodeOptions,
  path : String,
) -> Bytes raise BsonError {
  let bytes = writer.to_bytes().to_array()
  if bytes.length() > options.max_size {
    raise bson_error(SizeLimit, -1, path, "BSON value exceeds max_size")
  }
  let length = bytes.length()
  bytes[0] = (length & 0xFF).to_byte()
  bytes[1] = ((length >> 8) & 0xFF).to_byte()
  bytes[2] = ((length >> 16) & 0xFF).to_byte()
  bytes[3] = ((length >> 24) & 0xFF).to_byte()
  Bytes::from_array(bytes)
}

///|
fn regex_is_valid(value : Regex) -> Bool {
  if value.pattern().contains_code_unit(0) ||
    value.options().contains_code_unit(0) {
    return false
  }
  let mut previous : UInt16 = 0
  for index, option in value.options().code_units() {
    let supported = match option {
      'i' | 'l' | 'm' | 's' | 'u' | 'x' => true
      _ => false
    }
    if !supported || (index > 0 && option <= previous) {
      return false
    }
    previous = option
  }
  true
}

///|
fn normalize_regex_options(options : String) -> String? {
  if options.contains_code_unit(0) {
    return None
  }
  let seen : Map[UInt16, Unit] = Map([])
  for option in options.code_units() {
    if !(option is ('i' | 'l' | 'm' | 's' | 'u' | 'x')) || seen.contains(option) {
      return None
    }
    seen.set(option, ())
  }
  let normalized = StringBuilder()
  for option in "ilmsux".code_units() {
    if seen.contains(option) {
      normalized.write_string(option.to_char().unwrap().to_string())
    }
  }
  Some(normalized.to_string())
}