///|
priv struct WriteCursor {
  mut offset : Int
  mut node : Int
}

///|
/// Ownership transfer: buffers are freshly allocated privately, never escape to
/// callers while mutable, and are never modified after this final conversion.
fn finish_owned_buffer(buffer : FixedArray[Byte]) -> Bytes {
  buffer.unsafe_reinterpret_as_bytes()
}

///|
fn fixed_u32(
  out : FixedArray[Byte],
  cursor : WriteCursor,
  value : UInt64,
) -> Unit {
  for i = 0; i < 4; i = i + 1 {
    out[cursor.offset + i] = ((value >> (i * 8)) & 255UL).to_byte()
  }
  cursor.offset = cursor.offset + 4
}

///|
fn fixed_tag(
  out : FixedArray[Byte],
  cursor : WriteCursor,
  kind : Int,
  size : Int,
) -> Unit {
  fixed_u32(out, cursor, kind.to_uint64())
  fixed_u32(out, cursor, size.to_uint64())
}

///|
fn fixed_bytes(
  out : FixedArray[Byte],
  cursor : WriteCursor,
  kind : Int,
  bytes : Bytes,
) -> Unit {
  fixed_tag(out, cursor, kind, bytes.length())
  out.blit_from_bytes(cursor.offset, bytes, 0, bytes.length())
  cursor.offset = cursor.offset +
    bytes.length() +
    (if kind == 15 { 0 } else { (8 - bytes.length() % 8) % 8 })
}

///|
fn fixed_ints(
  out : FixedArray[Byte],
  cursor : WriteCursor,
  values : Array[Int],
) -> Unit {
  fixed_tag(out, cursor, 5, values.length() * 4)
  for value in values {
    fixed_u32(out, cursor, value.reinterpret_as_uint().to_uint64())
  }
  cursor.offset = cursor.offset + (if values.length() % 2 == 0 { 0 } else { 4 })
}

///|
fn canonical_header() -> Bytes {
  let out = FixedArray::make(128, b' ')
  let text = @utf8.encode(
    ("MATLAB 5.0 MAT-file, Created by MoonMatBridge v" +
    version() +
    " (MoonBit)")[:],
  )
  out.blit_from_bytes(0, text, 0, text.length())
  for i = 116; i < 126; i = i + 1 {
    out[i] = b'\x00'
  }
  out[125] = b'\x01'
  out[126] = b'I'
  out[127] = b'M'
  finish_owned_buffer(out)
}

///|
fn fixed_node(
  out : FixedArray[Byte],
  cursor : WriteCursor,
  node : MatNode,
  planned : Array[Int],
) -> Unit {
  let body_size = planned[cursor.node]
  cursor.node = cursor.node + 1
  fixed_tag(out, cursor, 14, body_size)
  let (class, logical, complex, global, capacity) = match node {
    Numeric(a) =>
      (
        a.dtype.mat_class(),
        a.dtype == Logical,
        a.imag is Some(_),
        a.is_global,
        0,
      )
    Csc(a) => (5, a.dtype == Logical, a.imag is Some(_), a.is_global, a.nzmax)
    Character(a) => (4, false, false, a.is_global, 0)
    Cell(a) => (1, false, false, a.is_global, 0)
    Structure(a) =>
      (
        if a.class_name is Some(_) {
          3
        } else {
          2
        },
        false,
        false,
        a.is_global,
        0,
      )
  }
  fixed_tag(out, cursor, 6, 8)
  fixed_u32(
    out,
    cursor,
    (class +
    (if logical { 512 } else { 0 }) +
    (if global { 1024 } else { 0 }) +
    (if complex { 2048 } else { 0 })).to_uint64(),
  )
  fixed_u32(out, cursor, capacity.to_uint64())
  fixed_ints(out, cursor, node.shape())
  fixed_bytes(out, cursor, 1, @utf8.encode(node.name()[:]))
  match node {
    Numeric(a) => {
      fixed_bytes(out, cursor, a.dtype.mi_type(), a.real)
      if a.imag is Some(imag) {
        fixed_bytes(out, cursor, a.dtype.mi_type(), imag)
      }
    }
    Csc(a) => {
      fixed_ints(out, cursor, a.row_indices)
      fixed_ints(out, cursor, a.col_ptrs)
      fixed_bytes(out, cursor, a.dtype.mi_type(), a.real)
      if a.imag is Some(imag) {
        fixed_bytes(out, cursor, a.dtype.mi_type(), imag)
      }
    }
    Character(a) => fixed_bytes(out, cursor, a.encoding.tag_id(), a.data)
    Cell(a) =>
      for child in a.values {
        fixed_node(out, cursor, child, planned)
      }
    Structure(a) => {
      if a.class_name is Some(name) {
        fixed_bytes(out, cursor, 1, @utf8.encode(name[:]))
      }
      let mut width = 1
      for field in a.fields {
        let count = @utf8.encode(field[:]).length() + 1
        if count > width {
          width = count
        }
      }
      fixed_u32(out, cursor, (5 + (4 << 16)).to_uint64())
      fixed_u32(out, cursor, width.to_uint64())
      let table_size = width * a.fields.length()
      fixed_tag(out, cursor, 1, table_size)
      for field in a.fields {
        let bytes = @utf8.encode(field[:])
        out.blit_from_bytes(cursor.offset, bytes, 0, bytes.length())
        cursor.offset = cursor.offset + width
      }
      cursor.offset = cursor.offset + (8 - table_size % 8) % 8
      for child in a.values {
        fixed_node(out, cursor, child, planned)
      }
    }
  }
}

///|
/// Preflight all nodes, then write directly to a privately owned fixed buffer.
pub fn write_tree(
  variables : Array[MatNode],
  compressed? : Bool = false,
  limits? : Limits = default_limits(),
  tree_limits? : TreeLimits = default_tree_limits(),
) -> Bytes raise MatError {
  validate_limits(limits)
  validate_tree_limits(tree_limits)
  if variables.length() > limits.max_variables {
    fail("variable-limit", 0, "Too many root variables")
  }
  let budget : TreeBudget = { nodes: 0, elements: 0, }
  let names : Map[String, Bool] = Map([])
  let planned = []
  let roots = []
  let mut size = 128
  for node in variables {
    if names.contains(node.name()) {
      fail("duplicate-name", 0, "Duplicate root variable")
    }
    names[node.name()] = true
    roots.push(planned.length())
    let length = node_size(node, limits, tree_limits, budget, 1, planned) + 8
    if length > limits.max_file_bytes - size {
      fail("file-limit", 0, "Encoded tree exceeds byte limit")
    }
    size = size + length
  }
  let header = canonical_header()
  if !compressed {
    let out = FixedArray::make(size, b'\x00')
    out.blit_from_bytes(0, header, 0, 128)
    let cursor : WriteCursor = { offset: 128, node: 0, }
    for node in variables {
      fixed_node(out, cursor, node, planned)
    }
    guard! cursor.offset == size
    return finish_owned_buffer(out)
  }
  let chunks = []
  let mut total = 128
  let mut expanded = 0
  for i = 0; i < variables.length(); i = i + 1 {
    let length = planned[roots[i]] + 8
    if length > limits.max_expanded_bytes - expanded {
      fail("expanded-limit", 0, "Expanded tree exceeds byte limit")
    }
    expanded = expanded + length
    let raw = FixedArray::make(length, b'\x00')
    let cursor : WriteCursor = { offset: 0, node: roots[i], }
    fixed_node(raw, cursor, variables[i], planned)
    guard! cursor.offset == length
    let available = limits.max_file_bytes - total - 8
    if available < 6 {
      fail("file-limit", 0, "No room for compressed matrix")
    }
    let chunk = deflate_zlib(finish_owned_buffer(raw), max_bytes=available)
    total = total + chunk.length() + 8
    chunks.push(chunk)
  }
  let out = FixedArray::make(total, b'\x00')
  out.blit_from_bytes(0, header, 0, 128)
  let cursor : WriteCursor = { offset: 128, node: 0, }
  for chunk in chunks {
    fixed_bytes(out, cursor, 15, chunk)
  }
  guard! cursor.offset == total
  finish_owned_buffer(out)
}