///|
pub suberror WriteError {
  InvalidDocument(String)
} derive(Debug)

///|
fn folded(out : StringBuilder, line : String) -> Unit {
  // Every generated line is ASCII, including Base64, so character boundaries
  // are byte boundaries and a continuation never splits UTF-8.
  let mut start = 0
  let mut width = 76
  while line.length() - start > width {
    out.write_string(line[start:start + width].to_owned())
    out.write_string("\n ")
    start = start + width
    width = 75
  }
  out.write_string(line[start:].to_owned())
  out.write_string("\n")
}

///|
fn value_suffix(value : Value) -> String raise WriteError {
  match value {
    External(_) =>
      raise InvalidDocument(
        "Unresolved external values cannot be written by the safe writer.",
      )
    Inline(b) => {
      if b.is_empty() {
        return ":"
      }
      let encode = b[0] == 32 ||
        b[0] == 58 ||
        b[0] == 60 ||
        b[b.length() - 1] == 32 ||
        b.iter().any(v => v < 32 || v >= 127)
      if encode {
        ":: " + @base64.encode(b[:])
      } else {
        let text = @utf8.decode(b[:]) catch {
          _ => raise InvalidDocument("Unexpected non-UTF-8 plain value.")
        }
        ": " + text
      }
    }
  }
}

///|
fn write_attribute(
  out : StringBuilder,
  attr : Attribute,
) -> Unit raise WriteError {
  if !valid_attribute(attr.name) {
    raise InvalidDocument("Invalid attribute description.")
  }
  folded(out, attr.name + value_suffix(attr.value))
}

///|
fn write_text(
  out : StringBuilder,
  name : String,
  text : String,
) -> Unit raise WriteError {
  folded(out, name + value_suffix(Inline(@utf8.encode(text[:]))))
}

///|
fn same_attributes(a : Array[Attribute], b : Array[Attribute]) -> Bool {
  a.length() == b.length() &&
  a
  .iter()
  .zip(b.iter())
  .all(pair => pair.0.name == pair.1.name && pair.0.value == pair.1.value)
}

///|
// Compare the contract directly, omitting only physical spans. Serializing two
// complete JSON trees here duplicates binary/text values and inflates memory.
fn same_document(a : Document, b : Document) -> Bool {
  if a.mode != b.mode || a.records.length() != b.records.length() {
    return false
  }
  for i in 0.. same_attributes(p, q)
      (Delete, Delete) => true
      (Rename(p, flag, parent), Rename(q, other_flag, other_parent)) =>
        p == q && flag == other_flag && parent == other_parent
      (Modify(p), Modify(q)) =>
        p.length() == q.length() &&
        p
        .iter()
        .zip(q.iter())
        .all(pair => {
          let m = pair.0
          let n = pair.1
          m.operation == n.operation &&
          m.attribute == n.attribute &&
          same_attributes(m.values, n.values)
        })
      _ => false
    }
    if !body_equal {
      return false
    }
  }
  true
}

///|
/// Deterministic semantic serialization. Comments, casing of changetype
/// aliases, original folding and source locations are not preserved.
/// Refuses models that do not round-trip through this supported profile.
pub fn write(document : Document) -> String raise WriteError {
  let out = StringBuilder()
  out.write_string("version: 1\n\n")
  for r in document.records {
    write_text(out, "dn", r.dn)
    for c in r.controls {
      if !valid_oid(c.oid) {
        raise InvalidDocument("Invalid control OID.")
      }
      let prefix = "control: " +
        c.oid +
        (if c.critical { " true" } else { " false" })
      let line = match c.value {
        None => prefix
        // Empty Base64 is equivalent to an empty plain value, and avoids an
        // out-of-bounds read in UnboundID 7.0.5's control parser for "false:".
        Some(Inline(b)) if b.is_empty() => prefix + "::"
        Some(v) => prefix + value_suffix(v)
      }
      folded(out, line)
    }
    match r.body {
      Entry(a) => {
        if !r.controls.is_empty() {
          raise InvalidDocument(
            "Content records cannot have operation controls.",
          )
        }
        for attr in a {
          write_attribute(out, attr)
        }
      }
      Add(a) => {
        out.write_string("changetype: add\n")
        for attr in a {
          write_attribute(out, attr)
        }
      }
      Delete => out.write_string("changetype: delete\n")
      Modify(mods) => {
        out.write_string("changetype: modify\n")
        for m in mods {
          if !(m.operation == "add" ||
            m.operation == "delete" ||
            m.operation == "replace") ||
            !valid_attribute(m.attribute) {
            raise InvalidDocument(
              "Unsupported modification operation or invalid attribute.",
            )
          }
          folded(out, m.operation + ": " + m.attribute)
          for a in m.values {
            write_attribute(out, a)
          }
          out.write_string("-\n")
        }
      }
      Rename(rdn, delete_old, superior) => {
        out.write_string("changetype: moddn\n")
        write_text(out, "newrdn", rdn)
        out.write_string(
          if delete_old {
            "deleteoldrdn: 1\n"
          } else {
            "deleteoldrdn: 0\n"
          },
        )
        match superior {
          Some(s) => write_text(out, "newsuperior", s)
          None => ()
        }
      }
    }
    out.write_string("\n")
  }
  let result = out.to_string()
  let verified = parse_text(result)
  if verified.exit_code() != 0 || !same_document(verified.document, document) {
    raise InvalidDocument(
      "Serialized model does not round-trip within the supported profile.",
    )
  }
  result
}

///|
pub fn Report::format(self : Report) -> String raise WriteError {
  if self.exit_code() != 0 {
    raise InvalidDocument(
      "Cannot format an invalid, incomplete or policy-blocked report.",
    )
  }
  write(self.document)
}