///|
priv struct Writer {
  out : Array[Byte]
  binary : Bool
  little : Bool
  width : Int
}

///|
fn Writer::text(w : Writer, s : String) -> Unit raise {
  let b = @utf8.encode(s)
  if b.length() > 134217728 - w.out.length() {
    bad("output exceeds 128 MiB")
  }
  for x in b {
    w.out.push(x)
  }
}

///|
fn Writer::bits(w : Writer, v : UInt64, n : Int) -> Unit raise {
  if n > 134217728 - w.out.length() {
    bad("output exceeds 128 MiB")
  }
  for i in 0..> shift).to_byte())
  }
}

///|
fn Writer::integer(w : Writer, v : Int) -> Unit raise {
  if w.binary {
    w.bits(v.reinterpret_as_uint().to_uint64(), 4)
  } else {
    w.text("\{v} ")
  }
}

///|
fn Writer::size(w : Writer, v : Int) -> Unit raise {
  if v < 0 {
    bad("negative size/tag")
  }
  if w.binary {
    w.bits(v.to_uint64(), w.width)
  } else {
    w.text("\{v} ")
  }
}

///|
fn Writer::real(w : Writer, v : Double) -> Unit raise {
  if w.binary {
    w.bits(v.reinterpret_as_uint64(), 8)
  } else {
    w.text("\{v} ")
  }
}

///|
fn Writer::row(w : Writer) -> Unit raise {
  if !w.binary {
    w.text("\n")
  }
}

///|
fn Writer::end(w : Writer, name : String, payload : Bool) -> Unit raise {
  if w.binary && payload {
    w.text("\n")
  }
  w.text("$End\{name}\n")
}

///|
fn tag_range(tags : Array[Int]) -> (Int, Int) {
  let mut lo = 2147483647
  let mut hi = 0
  for t in tags {
    if t < lo {
      lo = t
    }
    if t > hi {
      hi = t
    }
  }
  (if tags.is_empty() { 0 } else { lo }, hi)
}

///| Canonical classification for legacy files: preserve positive elementary

///| tags; allocate tags for unclassified elements by dimension+physical set.

///| Assign each node to its highest-dimensional incident entity; isolated nodes

///|
/// get point entities. This is inferred mesh classification, not CAD recovery.
fn Mesh::classified(self : Mesh) -> Mesh raise {
  if !self.ents.is_empty() {
    return self
  }
  let es = self.elements()
  let ns = self.nodes()
  let next = [1, 1, 1, 1]
  for e in es {
    let (d, _) = element_shape(e.kind)
    if e.entity == 2147483647 {
      bad("cannot allocate inferred entities above max tag")
    }
    if e.entity >= next[d] {
      next[d] = e.entity + 1
    }
  }
  let allocated : Map[String, Int] = Map([])
  let groups : Map[String, Array[Int]] = Map([])
  let classification : Map[Int, (Int, Int)] = Map([])
  for i, e in es {
    let (dim, _) = element_shape(e.kind)
    let entity = if e.entity > 0 {
      e.entity
    } else {
      let key = "\{dim}:" + id_key(e.physical)
      match allocated.get(key) {
        Some(t) => t
        None => {
          if next[dim] == 2147483647 {
            bad("entity allocation overflow")
          }
          let t = next[dim]
          next[dim] = t + 1
          allocated[key] = t
          t
        }
      }
    }
    let key = "\{dim}:\{entity}"
    match groups.get(key) {
      Some(p) =>
        if p != e.physical {
          bad("legacy entity has inconsistent physical groups")
        }
      None => groups[key] = e.physical
    }
    es[i] = { ..e, entity, }
    for tag in e.nodes {
      match classification.get(tag) {
        Some((d, _)) => if dim > d { classification[tag] = (dim, entity) }
        None => classification[tag] = (dim, entity)
      }
    }
  }
  for i, n in ns {
    let (dim, entity) = match classification.get(n.tag) {
      Some(pair) => pair
      None => {
        if next[0] == 2147483647 {
          bad("point entity allocation overflow")
        }
        let t = next[0]
        next[0] = t + 1
        (0, t)
      }
    }
    ns[i] = { ..n, dim, entity, }
  }
  let ents = []
  synthesize_entities(ns, es, ents)
  mesh(
    ns,
    es,
    entities=ents,
    names=self.names,
    fields=self.fs,
    unknown=self.unknown,
    version=self.version,
  )
}

///| Loss is opt-in: 4.1 entity BRep/classification and parametric coordinates have

///| no 2.2 equivalent; multiple physical groups collapse to the first only when

///| allow_loss=true. Unknown text requires explicit without_unknown() to change

///|
/// version or emit binary. Legacy extra tags similarly require permission for 4.1.
pub fn Mesh::encode(
  self : Mesh,
  version? : String = self.version,
  binary? : Bool = false,
  little? : Bool = true,
  size_width? : Int = 8,
  allow_loss? : Bool = false,
) -> Bytes raise {
  if version != "2.2" && version != "4.1" {
    bad("write version must be 2.2 or 4.1")
  }
  if size_width != 8 && !(version == "4.1" && size_width == 4) {
    bad("invalid size_t width")
  }
  if !self.unknown.is_empty() && (version != self.version || binary) {
    bad("drop unknown sections explicitly before version/binary conversion")
  }
  if !allow_loss {
    if version == "2.2" &&
      (
        !self.ents.is_empty() ||
        self.ns.any(n => !n.parameters.is_empty()) ||
        self.es.any(e => e.physical.length() > 1)
      ) {
      bad(
        "2.2 would lose entity/parametric/multiple-group metadata; require allow_loss",
      )
    }
    if version == "4.1" && self.es.any(e => !e.extra.is_empty()) {
      bad("4.1 would lose legacy partition/extra tags; require allow_loss")
    }
  }
  let m = if version == "4.1" { self.classified() } else { self }
  let w : Writer = { out: [], binary, little, width: size_width, }
  w.text("$MeshFormat\n\{version} \{if binary {1} else {0}} \{size_width}\n")
  if binary {
    w.bits(1UL, 4)
  }
  w.end("MeshFormat", binary)
  if !m.names.is_empty() {
    w.text("$PhysicalNames\n\{m.names.length()}\n")
    for p in m.names {
      w.text("\{p.dim} \{p.tag} \"\{p.name}\"\n")
    }
    w.end("PhysicalNames", false)
  }
  if version == "4.1" {
    w.text("$Entities\n")
    for d in 0..<4 {
      w.size(m.ents.filter(e => e.dim == d).length())
    }
    w.row()
    for d in 0..<4 {
      for e in m.ents {
        if e.dim != d {
          continue
        }
        w.integer(e.tag)
        for x in e.bounds {
          w.real(x)
        }
        w.size(e.physical.length())
        for p in e.physical {
          w.integer(p)
        }
        if d > 0 {
          w.size(e.boundary.length())
          for b in e.boundary {
            w.integer(b)
          }
        }
        w.row()
      }
    }
    w.end("Entities", true)
  }
  w.text("$Nodes\n")
  if version == "2.2" {
    w.text("\{m.ns.length()}\n")
    for n in m.ns {
      w.integer(n.tag)
      for x in n.xyz {
        w.real(x)
      }
      w.row()
    }
    w.end("Nodes", !m.ns.is_empty())
  } else {
    let groups : Map[String, Array[Node]] = Map([])
    let order = []
    for n in m.ns {
      let key = "\{n.dim}:\{n.entity}:\{!n.parameters.is_empty()}"
      match groups.get(key) {
        Some(a) => a.push(n)
        None => {
          groups[key] = [n]
          order.push(key)
        }
      }
    }
    let (lo, hi) = tag_range(m.ns.map(n => n.tag))
    w.size(order.length())
    w.size(m.ns.length())
    w.size(lo)
    w.size(hi)
    w.row()
    for key in order {
      let a = groups[key]
      let n = a[0]
      w.integer(n.dim)
      w.integer(n.entity)
      w.integer(if n.parameters.is_empty() { 0 } else { 1 })
      w.size(a.length())
      w.row()
      for n in a {
        w.size(n.tag)
        w.row()
      }
      for n in a {
        for x in n.xyz {
          w.real(x)
        }
        for x in n.parameters {
          w.real(x)
        }
        w.row()
      }
    }
    w.end("Nodes", true)
  }
  w.text("$Elements\n")
  if version == "2.2" {
    w.text("\{m.es.length()}\n")
    // Single-element binary blocks keep arbitrary legacy tags exactly, including
    // heterogeneous partition metadata. Readers must not assume one block/type.
    for e in m.es {
      let nt = 2 + e.extra.length()
      if binary {
        w.integer(e.kind)
        w.integer(1)
        w.integer(nt)
        w.integer(e.tag)
      } else {
        w.integer(e.tag)
        w.integer(e.kind)
        w.integer(nt)
      }
      w.integer(if e.physical.is_empty() { 0 } else { e.physical[0] })
      w.integer(e.entity)
      for t in e.extra {
        w.integer(t)
      }
      for t in e.nodes {
        w.integer(t)
      }
      w.row()
    }
    w.end("Elements", !m.es.is_empty())
  } else {
    let groups : Map[String, Array[Element]] = Map([])
    let order = []
    for e in m.es {
      let key = "\{e.kind}:\{e.entity}"
      match groups.get(key) {
        Some(a) => a.push(e)
        None => {
          groups[key] = [e]
          order.push(key)
        }
      }
    }
    let (lo, hi) = tag_range(m.es.map(e => e.tag))
    w.size(order.length())
    w.size(m.es.length())
    w.size(lo)
    w.size(hi)
    w.row()
    for key in order {
      let a = groups[key]
      let e = a[0]
      let (dim, _) = element_shape(e.kind)
      w.integer(dim)
      w.integer(e.entity)
      w.integer(e.kind)
      w.size(a.length())
      w.row()
      for e in a {
        w.size(e.tag)
        for n in e.nodes {
          w.size(n)
        }
        w.row()
      }
    }
    w.end("Elements", true)
  }
  for f in m.fs {
    w.text("$\{f.location}\n\{f.strings.length()}\n")
    for s in f.strings {
      w.text("\"\{s}\"\n")
    }
    w.text("\{f.reals.length()}\n")
    for x in f.reals {
      w.text("\{x}\n")
    }
    w.text("\{f.integers.length()}\n")
    for x in f.integers {
      w.text("\{x}\n")
    }
    for d in f.entries {
      w.integer(d.tag)
      for x in d.values {
        w.real(x)
      }
      w.row()
    }
    w.end(f.location, !f.entries.is_empty())
  }
  for s in m.unknown {
    w.text("$\{s.name}\n")
    w.text(s.body)
    if !s.body.is_empty() && !s.body.has_suffix("\n") {
      w.text("\n")
    }
    w.end(s.name, false)
  }
  Bytes::from_array(w.out)
}