///|
/// Snapshot format: a self-contained, checksummed byte image of an index.
/// Layout: magic "MSK1" | payload | crc32(payload) as u32le.
/// The payload is versioned; readers reject unknown versions and any
/// checksum mismatch, so a torn write is detected rather than loaded.
/// Pure functions over bytes — usable on native, wasm-gc and js alike.

///|
const SNAPSHOT_MAGIC : Bytes = b"MSK1"

///|
const SNAPSHOT_VERSION : Int32 = 1

///|
/// Serialize the index to a portable byte image.
pub fn Index::to_bytes(self : Index) -> Bytes {
  let payload : Array[Byte] = []
  write_varint(payload, SNAPSHOT_VERSION)
  // Schema
  write_varint(payload, self.schema.length())
  for field in self.schema {
    write_string(payload, field.name)
    write_double(payload, field.weight)
  }
  // Analyzer + BM25 config
  write_varint(payload, if self.analyzer.lowercase { 1 } else { 0 })
  write_varint(payload, if self.analyzer.remove_stop_words { 1 } else { 0 })
  write_varint(payload, if self.analyzer.cjk_bigram { 1 } else { 0 })
  write_double(payload, self.bm25.k1)
  write_double(payload, self.bm25.b)
  // Documents (external ids; internal numbers are reassigned in order)
  write_varint(payload, self.doc_ids.length())
  for id in self.doc_ids {
    write_string(payload, id)
  }
  // Deleted internal doc numbers
  write_varint(payload, self.deleted.length())
  for (num, _) in self.deleted.iter() {
    write_varint(payload, num)
  }
  // Per-field stats: doc -> length
  for field in self.schema {
    let stats = self.stats[field.name]
    write_varint(payload, stats.lengths.length())
    for (num, len) in stats.lengths.iter() {
      write_varint(payload, num)
      write_varint(payload, len)
    }
  }
  // Postings per field
  for field in self.schema {
    let field_map = self.postings[field.name]
    write_varint(payload, field_map.length())
    // Sort terms for deterministic output.
    let terms : Array[String] = []
    for (term, _) in field_map.iter() {
      terms.push(term)
    }
    terms.sort()
    for term in terms {
      write_string(payload, term)
      let list = field_map[term]
      write_varint(payload, list.length())
      for posting in list {
        write_varint(payload, posting.doc)
        write_varint(payload, posting.freq)
        write_varint(payload, posting.positions.length())
        // Delta-encode positions for compactness.
        let mut prev = 0
        for pos in posting.positions {
          write_varint(payload, pos - prev)
          prev = pos
        }
      }
    }
  }
  // Frame: magic + payload + crc
  let image : Array[Byte] = []
  for b in SNAPSHOT_MAGIC.iter() {
    image.push(b)
  }
  for b in payload {
    image.push(b)
  }
  let payload_bytes = Bytes::from_array(payload[:])
  write_u32_le(image, crc32(payload_bytes))
  Bytes::from_array(image[:])
}

///|
/// Restore an index from a byte image. Raises on bad magic, unknown
/// version, truncation, or checksum mismatch.
pub fn Index::from_bytes(data : Bytes) -> Index raise CodecError {
  if data.length() < 8 {
    raise CodecError::UnexpectedEof
  }
  let magic = data.get_view(end=4)
  match magic {
    Some(m) if m == SNAPSHOT_MAGIC => ()
    _ => raise CodecError::BadMagic
  }
  let payload_len = data.length() - 8
  let payload = match data.get_view(start=4, end=4 + payload_len) {
    Some(p) => p
    None => raise CodecError::UnexpectedEof
  }
  let crc_reader = Reader::new(data)
  crc_reader.pos = 4 + payload_len
  let expected = crc_reader.read_u32_le()
  let actual = crc32(payload.to_owned())
  if expected != actual {
    raise CodecError::CrcMismatch(expected, actual)
  }
  let reader = Reader::new(payload.to_owned())
  let version = reader.read_varint()
  if version != SNAPSHOT_VERSION {
    raise CodecError::BadVersion(version)
  }
  // Schema
  let schema_len = reader.read_varint()
  let schema : Array[FieldSchema] = []
  for _ in 0..