///|
/// Encode a `String` into bytes.
///
/// By default this returns UTF-8 bytes. When `latin1` is `true`, each UTF-16
/// code unit is truncated to one byte, which is useful for legacy ZIP metadata
/// that is explicitly stored as Latin-1 compatible byte data.
pub fn str_to_u8(s : String, latin1? : Bool = false) -> FixedArray[Byte] {
  if latin1 {
    let ar = FixedArray::make(s.length(), b'\x00')
    for i in 0..> 1) + 8, b'\x00')
  let mut ai = 0
  let mut i = 0
  while i < l {
    if ai + 5 > ar.length() {
      let n = FixedArray::make(ai + 8 + ((l - i) << 1), b'\x00')
      ar.blit_to(n, len=ai, src_offset=0, dst_offset=0)
      ar = n
    }
    let mut c = s[i].to_int()
    if c < 128 {
      ar[ai] = c.to_byte()
      ai += 1
    } else if c < 2048 {
      ar[ai] = (192 | (c >> 6)).to_byte()
      ai += 1
      ar[ai] = (128 | (c & 63)).to_byte()
      ai += 1
    } else if c > 55295 && c < 57344 {
      // surrogate pair: combine high + low surrogate into a single codepoint
      i += 1
      let low = if i < l { s[i].to_int() } else { 0 }
      c = (65536 + ((c & 1023) << 10)) | (low & 1023)
      ar[ai] = (240 | (c >> 18)).to_byte()
      ai += 1
      ar[ai] = (128 | ((c >> 12) & 63)).to_byte()
      ai += 1
      ar[ai] = (128 | ((c >> 6) & 63)).to_byte()
      ai += 1
      ar[ai] = (128 | (c & 63)).to_byte()
      ai += 1
    } else {
      ar[ai] = (224 | (c >> 12)).to_byte()
      ai += 1
      ar[ai] = (128 | ((c >> 6) & 63)).to_byte()
      ai += 1
      ar[ai] = (128 | (c & 63)).to_byte()
      ai += 1
    }
    i += 1
  }
  trim_buf(ar, ai)
}

///|
/// Decode bytes into a `String`.
///
/// The selected byte range is decoded as strict UTF-8 per RFC 3629. Any of the
/// following raise `InvalidUTF8`:
///
/// * continuation bytes (`0x80`-`0xBF`) appearing as a sequence start;
/// * overlong encodings (`0xC0 0x80` → U+0000, etc.);
/// * a continuation byte without the `10xxxxxx` prefix;
/// * surrogate code points (`U+D800`-`U+DFFF`);
/// * code points above `U+10FFFF`;
/// * 5- or 6-byte legacy UTF-8 sequences (start byte `>= 0xF5`).
///
/// Earlier versions of this function silently accepted these patterns, which
/// produced fzip-specific decoder output that disagreed with standard
/// libraries — a parser-differential risk for ZIP filenames where strict
/// rejection is preferred over silent corruption.
///
/// When `latin1` is `true`, every byte is mapped directly to the same Unicode
/// code point. `offset` and `len` select the input range.
pub fn str_from_u8(
  data : FixedArray[Byte],
  latin1? : Bool = false,
  offset? : Int = 0,
  len? : Int = -1,
) -> String raise FzipError {
  let actual_len = if len < 0 { data.length() - offset } else { len }
  let end = offset + actual_len
  if latin1 {
    let buf = StringBuilder::new()
    for i in offset..= end {
        raise fzip_err(InvalidUTF8)
      }
      let c1 = data[i + 1].to_int()
      if (c1 & 0xC0) != 0x80 {
        raise fzip_err(InvalidUTF8)
      }
      let cp = ((c & 0x1F) << 6) | (c1 & 0x3F)
      buf.write_char(cp.unsafe_to_char())
      i += 2
    } else if c < 0xF0 {
      // 3-byte sequence: 0xE0-0xEF.
      if i + 2 >= end {
        raise fzip_err(InvalidUTF8)
      }
      let c1 = data[i + 1].to_int()
      let c2 = data[i + 2].to_int()
      if (c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80 {
        raise fzip_err(InvalidUTF8)
      }
      let cp = ((c & 0x0F) << 12) | ((c1 & 0x3F) << 6) | (c2 & 0x3F)
      // Reject overlong encodings (codepoint < U+0800) and UTF-16 surrogate
      // code points (U+D800 .. U+DFFF) which are not valid Unicode scalars.
      if cp < 0x800 || (cp >= 0xD800 && cp <= 0xDFFF) {
        raise fzip_err(InvalidUTF8)
      }
      buf.write_char(cp.unsafe_to_char())
      i += 3
    } else if c < 0xF5 {
      // 4-byte sequence: 0xF0-0xF4.
      if i + 3 >= end {
        raise fzip_err(InvalidUTF8)
      }
      let c1 = data[i + 1].to_int()
      let c2 = data[i + 2].to_int()
      let c3 = data[i + 3].to_int()
      if (c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80 || (c3 & 0xC0) != 0x80 {
        raise fzip_err(InvalidUTF8)
      }
      let cp = ((c & 0x07) << 18) |
        ((c1 & 0x3F) << 12) |
        ((c2 & 0x3F) << 6) |
        (c3 & 0x3F)
      // 4-byte minimum is U+10000; max is U+10FFFF.
      if cp < 0x10000 || cp > 0x10FFFF {
        raise fzip_err(InvalidUTF8)
      }
      // Emit as a UTF-16 surrogate pair.
      let v = cp - 0x10000
      buf.write_char((0xD800 | (v >> 10)).unsafe_to_char())
      buf.write_char((0xDC00 | (v & 0x3FF)).unsafe_to_char())
      i += 4
    } else {
      // 0xF5-0xFF: outside RFC 3629 (legacy 5/6-byte starts or invalid).
      raise fzip_err(InvalidUTF8)
    }
  }
  buf.to_string()
}