///|
/// Validated root metadata; offsets are intentionally not exposed or cached.
pub(all) struct MatRootInfo {
  name : String
  class_id : Int
  shape : Array[Int]
  is_global : Bool
  is_complex : Bool
  is_logical : Bool
  compressed : Bool
} derive(@debug.Debug)

///|
pub extend MatRootInfo with @debug.Debug::{to_repr}

///|
fn root_info(
  bytes : Bytes,
  outer : Element,
  little : Bool,
  limits : Limits,
  compressed : Bool,
) -> MatRootInfo raise MatError {
  let end = outer.start + outer.size
  let flags = tag(bytes, outer.start, end, little)
  if flags.kind != 6 || flags.size != 8 {
    fail("invalid-flags", flags.start, "Expected 8-byte array flags")
  }
  let bits = bits_at(bytes, flags.start, 4, little).to_uint()
  let class_id = (bits & 255U).reinterpret_as_int()
  if class_id < 1 || class_id > 15 {
    fail(
      "unsupported-class",
      flags.start,
      "Supported base classes are 1 through 15",
    )
  }
  let dims = tag(bytes, flags.next, end, little)
  if dims.kind != 5 ||
    dims.size < 8 ||
    dims.size % 4 != 0 ||
    dims.size / 4 > limits.max_dimensions {
    fail("invalid-shape", dims.start, "Expected bounded miINT32 dimensions")
  }
  let shape = Array::makei(dims.size / 4, i => {
    bits_at(bytes, dims.start + i * 4, 4, little).to_uint().reinterpret_as_int()
  })
  let name = tag(bytes, dims.next, end, little)
  let name = tree_text(bytes, name, limits, false)
  {
    name,
    class_id,
    shape,
    is_global: (bits & 1024U) != 0U,
    is_complex: (bits & 2048U) != 0U,
    is_logical: (bits & 512U) != 0U,
    compressed,
  }
}

///|
fn scan_tree(
  bytes : Bytes,
  selection : Array[String]?,
  collect_roots : Bool,
  limits : Limits,
  tree_limits : TreeLimits,
) -> (MatTree, Array[MatRootInfo]) raise MatError {
  validate_limits(limits)
  validate_tree_limits(tree_limits)
  if bytes.length() > limits.max_file_bytes {
    fail("file-limit", 0, "File exceeds max_file_bytes")
  }
  if bytes.length() < 128 {
    fail("invalid-header", 0, "MAT Level 5 header needs 128 bytes")
  }
  if bytes[0:8] == b"\x89HDF\x0d\x0a\x1a\x0a" ||
    bytes[0:19] == b"MATLAB 7.3 MAT-file" {
    fail("unsupported-v7.3", 0, "HDF5 MAT v7.3 is outside MAT Level 5")
  }
  let little = if bytes[126:128] == b"IM" {
    true
  } else if bytes[126:128] == b"MI" {
    false
  } else {
    fail("invalid-header", 126, "Invalid endian marker")
    true
  }
  if bits_at(bytes, 124, 2, little) != 256UL {
    fail("unsupported-version", 124, "Expected Level 5 version 0x0100")
  }
  let subsystem = bits_at(bytes, 116, 8, little)
  if subsystem != 0UL && subsystem != 0x2020202020202020UL {
    fail(
      "unsupported-subsystem", 116, "Subsystem/object workspace metadata is unsupported",
    )
  }
  let wanted : Map[String, Bool] = Map([])
  if selection is Some(names) {
    for name in names {
      wanted[name] = true
    }
  }
  let budget : TreeBudget = { nodes: 0, elements: 0, }
  let variables = []
  let roots = []
  let kept : Map[String, MatNode] = Map([])
  let seen : Map[String, Bool] = Map([])
  let mut offset = 128
  let mut expanded = 0
  let mut root_count = 0
  while offset < bytes.length() {
    if root_count >= limits.max_variables {
      fail("variable-limit", offset, "Too many root variables")
    }
    let outer = tag(bytes, offset, bytes.length(), little)
    let (source, inner, compressed) = if outer.kind == 14 {
      (bytes, outer, false)
    } else if outer.kind == 15 {
      let unpacked = inflate_zlib(
        payload(bytes, outer),
        max_bytes=limits.max_expanded_bytes - expanded,
      )
      expanded = expanded + unpacked.length()
      let inner = tag(unpacked, 0, unpacked.length(), little)
      if inner.kind != 14 || inner.next != unpacked.length() {
        fail(
          "invalid-compressed", offset, "Compressed element must contain exactly one matrix",
        )
      }
      (unpacked, inner, true)
    } else {
      fail("unsupported-element", offset, "Expected miMATRIX or miCOMPRESSED")
      abort("unreachable")
    }
    let info = root_info(source, inner, little, limits, compressed)
    let keep = selection is None || wanted.contains(info.name)
    let value = visit_node(
      source, inner, little, limits, tree_limits, budget, 1, keep,
    )
    if seen.contains(info.name) {
      fail("duplicate-name", offset, "Duplicate root variable")
    }
    seen[info.name] = true
    if value is Some(node) {
      variables.push(node)
      if selection is Some(_) {
        kept[info.name] = node
      }
    }
    if collect_roots {
      roots.push(info)
    }
    root_count = root_count + 1
    offset = outer.next
  }
  let ordered = if selection is Some(names) {
    let result = []
    let selected : Map[String, Bool] = Map([])
    for name in names {
      if selected.contains(name) {
        fail("duplicate-name", 0, "Repeated selection: " + name)
      }
      selected[name] = true
      match kept.get(name) {
        Some(node) => result.push(node)
        None => fail("missing-variable", 0, "No variable named: " + name)
      }
    }
    result
  } else {
    variables
  }
  (
    {
      description: @utf8.decode_lossy(bytes[0:116]).trim().to_owned(),
      little_endian: little,
      variables: ordered,
    },
    roots,
  )
}

///|
/// Validate every variable while constructing only the requested data trees.
pub fn read_selected(
  bytes : Bytes,
  names : Array[String],
  limits? : Limits = default_limits(),
  tree_limits? : TreeLimits = default_tree_limits(),
) -> MatTree raise MatError {
  let (file, _) = scan_tree(bytes, Some(names), false, limits, tree_limits)
  file
}

///|
/// Full validation with no persistent data trees; compressed roots are decoded.
pub fn inspect_roots(
  bytes : Bytes,
  limits? : Limits = default_limits(),
  tree_limits? : TreeLimits = default_tree_limits(),
) -> Array[MatRootInfo] raise MatError {
  let (_, roots) = scan_tree(bytes, Some([]), true, limits, tree_limits)
  roots
}

///|
pub fn read_tree(
  bytes : Bytes,
  limits? : Limits = default_limits(),
  tree_limits? : TreeLimits = default_tree_limits(),
) -> MatTree raise MatError {
  let (file, _) = scan_tree(bytes, None, false, limits, tree_limits)
  file
}