///|
/// Encode a string as a PO quoted literal, preserving Unicode code points.
pub fn quote_po(text : String) -> String {
  let parts : Array[String] = ["\""]
  for c in text.to_array() {
    let encoded = match c {
      '"' => "\\\""
      '\\' => "\\\\"
      '\n' => "\\n"
      '\r' => "\\r"
      '\t' => "\\t"
      '\u0008' => "\\b"
      '\u000C' => "\\f"
      _ => {
        let code = c.to_int()
        if code < 32 || code == 127 {
          "\\" +
          (code / 64).to_string() +
          (code / 8 % 8).to_string() +
          (code % 8).to_string()
        } else {
          c.to_string()
        }
      }
    }
    parts.push(encoded)
  }
  parts.push("\"")
  join_strings(parts, "")
}

///|
fn emit_po_field(
  lines : Array[String],
  prefix : String,
  name : String,
  value : String,
) -> Unit {
  if !value.contains("\n") {
    lines.push(prefix + name + " " + quote_po(value))
    return
  }
  lines.push(prefix + name + " \"\"")
  let chars = value.to_array()
  let mut start = 0
  for i, c in chars {
    if c == '\n' {
      lines.push(prefix + quote_po(char_slice(chars, start, i + 1)))
      start = i + 1
    }
  }
  if start < chars.length() {
    lines.push(prefix + quote_po(char_slice(chars, start, chars.length())))
  }
}

///|
fn serialized_entry(entry : Entry) -> Result[String, String] {
  if entry.context is Some(context) {
    if context.to_array().any(c => c == '\u0000') {
      return Err("Cannot normalize a context containing NUL")
    }
  }
  let seen : Map[Int32, Bool] = Map([])
  for translation in entry.translations {
    if translation.index < 0 || seen.get(translation.index) is Some(_) {
      return Err("Cannot normalize a duplicate or negative translation index")
    }
    seen.set(translation.index, true)
  }
  if entry.comments.any(c => c.text.contains("\n") || c.text.contains("\r")) {
    return Err("Comment data contains a raw newline")
  }
  if entry.translations.is_empty() {
    return Err("Cannot normalize an entry without a translation field")
  }
  if entry.plural is None &&
    (entry.translations.length() != 1 || entry.translations[0].index != 0) {
    return Err("Cannot normalize indexed translations on a singular entry")
  }
  let lines : Array[String] = []
  let prefix = if entry.obsolete { "#~ " } else { "" }
  for comment in entry.comments {
    if comment.kind == "flags" {
      continue
    }
    let marker = match comment.kind {
      "reference" => "#:"
      "extracted" => "#."
      "previous" => "#|"
      _ => "#"
    }
    lines.push(prefix + marker + " " + comment.text)
  }
  if !entry.flags.is_empty() {
    lines.push(prefix + "#, " + join_strings(entry.flags, ", "))
  }
  if entry.context is Some(context) {
    emit_po_field(lines, prefix, "msgctxt", context)
  }
  emit_po_field(lines, prefix, "msgid", entry.id)
  if entry.plural is Some(plural) {
    emit_po_field(lines, prefix, "msgid_plural", plural)
  }
  let order = entry.translations.map(t => t.index)
  order.sort()
  for index in order {
    let name = if entry.plural is Some(_) {
      "msgstr[" + index.to_string() + "]"
    } else {
      "msgstr"
    }
    emit_po_field(lines, prefix, name, entry.translation(index).unwrap_or(""))
  }
  Ok(join_strings(lines, "\n"))
}

///|
/// Canonical syntax only. Refuses parse errors; no automatic translation fixes.
pub fn serialize_catalog(catalog : Catalog) -> Result[String, String] {
  if !catalog.diagnostics.is_empty() {
    return Err("Cannot normalize a catalog with parse diagnostics")
  }
  let entries : Array[String] = []
  for entry in catalog.entries {
    match serialized_entry(entry) {
      Ok(value) => entries.push(value)
      Err(error) =>
        return Err(
          error + " at source line " + entry.id_span.start.line.to_string(),
        )
    }
  }
  if entries.is_empty() {
    Ok("")
  } else {
    Ok(join_strings(entries, "\n\n") + "\n")
  }
}