///|
fn detect_mo_endian(bytes : Bytes) -> Endian raise GettextError {
  if bytes.length() < 4 {
    raise MoFormat(offset=0, message="MO file is shorter than its magic word")
  }
  if bytes[0] == b'\xde' &&
    bytes[1] == b'\x12' &&
    bytes[2] == b'\x04' &&
    bytes[3] == b'\x95' {
    Little
  } else if bytes[0] == b'\x95' &&
    bytes[1] == b'\x04' &&
    bytes[2] == b'\x12' &&
    bytes[3] == b'\xde' {
    Big
  } else {
    raise MoFormat(offset=0, message="invalid GNU MO magic word")
  }
}

///|
/// Detect byte order from a GNU MO magic word.
pub fn mo_endian(bytes : Bytes) -> Endian raise GettextError {
  detect_mo_endian(bytes)
}

///|
fn read_u32_at(
  bytes : Bytes,
  offset : Int,
  endian : Endian,
) -> Int raise GettextError {
  if offset < 0 || offset > bytes.length() - 4 {
    raise MoFormat(offset~, message="truncated 32-bit MO field")
  }
  let first = bytes[offset].to_int()
  let second = bytes[offset + 1].to_int()
  let third = bytes[offset + 2].to_int()
  let fourth = bytes[offset + 3].to_int()
  let high = match endian {
    Little => fourth
    Big => first
  }
  if high >= 128 {
    raise MoFormat(
      offset~,
      message="32-bit MO field exceeds the supported signed Int range",
    )
  }
  match endian {
    Little => first | (second << 8) | (third << 16) | (fourth << 24)
    Big => fourth | (third << 8) | (second << 16) | (first << 24)
  }
}

///|
fn checked_mo_range(
  offset : Int,
  length : Int,
  total : Int,
  field_offset : Int,
) -> Unit raise GettextError {
  if offset < 0 || length < 0 || offset > total || length > total - offset {
    raise MoFormat(
      offset=field_offset,
      message="MO string or table range is outside the file",
    )
  }
}

///|
fn read_mo_string(
  bytes : Bytes,
  offset : Int,
  length : Int,
  field_offset : Int,
) -> String raise GettextError {
  checked_mo_range(offset, length, bytes.length(), field_offset)
  if offset + length >= bytes.length() || bytes[offset + length] != b'\x00' {
    raise MoFormat(
      offset=field_offset,
      message="MO string is not followed by a NUL terminator",
    )
  }
  try @utf8.decode(bytes[offset:offset + length]) catch {
    _ => raise MoFormat(offset~, message="MO string is not valid UTF-8")
  } noraise {
    value => value
  }
}

///|
fn split_mo_original(
  original : String,
  field_offset : Int,
) -> (String?, String, String?) raise GettextError {
  let forms = original.split("\u{0000}").to_array()
  if forms.length() > 2 {
    raise MoFormat(
      offset=field_offset,
      message="MO original contains more than one plural separator",
    )
  }
  let singular_with_context = forms[0].to_owned()
  let plural = if forms.length() == 2 {
    Some(forms[1].to_owned())
  } else {
    None
  }
  match singular_with_context.split_once("\u{0004}") {
    Some((context, singular)) =>
      (Some(context.to_owned()), singular.to_owned(), plural)
    None => (None, singular_with_context, plural)
  }
}

///|
fn split_mo_translation(translation : String) -> Array[String] {
  translation.split("\u{0000}").map(part => part.to_owned()).to_array()
}

///|
/// Parse a GNU MO revision 0 file into the shared catalog model.
///
/// Both little- and big-endian files are accepted. Header/table bounds, NUL
/// terminators, UTF-8 strings, duplicate keys, context separators, and plural
/// separators are checked before a document is returned.
pub fn parse_mo(bytes : Bytes) -> PoFile raise GettextError {
  if bytes.length() < 28 {
    raise MoFormat(
      offset=0,
      message="MO file is shorter than its 28-byte header",
    )
  }
  let endian = detect_mo_endian(bytes)
  let revision = read_u32_at(bytes, 4, endian)
  if revision != 0 {
    raise MoFormat(
      offset=4,
      message="unsupported MO revision \{revision}; only revision 0 is supported",
    )
  }
  let count = read_u32_at(bytes, 8, endian)
  let original_table = read_u32_at(bytes, 12, endian)
  let translation_table = read_u32_at(bytes, 16, endian)
  if count > 2147483647 / 8 {
    raise MoFormat(offset=8, message="MO string count is too large")
  }
  let table_size = count * 8
  checked_mo_range(original_table, table_size, bytes.length(), 12)
  checked_mo_range(translation_table, table_size, bytes.length(), 16)

  let entries : Array[PoEntry] = []
  let seen : Map[String, Unit] = Map([])
  for index in 0.. 1 {
      raise MoFormat(
        offset=translation_slot,
        message="singular MO entry contains plural translations",
      )
    }
    entries.push({
      comments: [],
      context,
      msgid,
      msgid_plural,
      translations,
      obsolete: false,
    })
  }
  PoFile::new(entries)
}