///|
fn zip_need(ok : Bool, message : String) -> Unit raise @ledger.InputError {
  if !ok {
    raise bad("ZipProfile", "$", message)
  }
}

///|
fn zip_u16(data : Bytes, at : Int32) -> Int32 {
  data[at].to_int() | (data[at + 1].to_int() << 8)
}

///|
fn zip_u32(data : Bytes, at : Int32) -> UInt32 {
  data[at].to_uint() |
  (data[at + 1].to_uint() << 8) |
  (data[at + 2].to_uint() << 16) |
  (data[at + 3].to_uint() << 24)
}

///|
fn zip_number(data : Bytes, at : Int32) -> Int32 raise @ledger.InputError {
  let value = zip_u32(data, at).to_int64()
  zip_need(
    value <= 268435456L,
    "ZIP64 or oversized offsets and lengths are unsupported",
  )
  value.to_int()
}

///|
priv struct ZipMember {
  name : String
  offset : Int32
  end : Int32
  start : Int32
  compressed : Int32
  expanded : Int32
  crc : UInt32
  compression : Int32
}

///|
// Validate every record before expansion. No filesystem extraction occurs.
fn zip_entries(
  data : Bytes,
  limits : @ledger.Limits,
  maximum : Int32,
) -> Array[ZipMember] raise @ledger.InputError {
  carrier_budget(data.length(), maximum)
  zip_need(data.length() >= 22, "Truncated ZIP end record")
  let end = data.length() - 22
  zip_need(
    zip_u32(data, end) == 0x06054b50U,
    "Expected uncommented classic ZIP end record",
  )
  zip_need(
    zip_u16(data, end + 4) == 0 &&
    zip_u16(data, end + 6) == 0 &&
    zip_u16(data, end + 20) == 0,
    "Split ZIP and archive comments are unsupported",
  )
  let count = zip_u16(data, end + 10)
  zip_need(
    count == zip_u16(data, end + 8) && count <= limits.max_reports + 1,
    "Inconsistent or excessive ZIP entry count",
  )
  let directory = zip_number(data, end + 16)
  zip_need(
    directory + zip_number(data, end + 12) == end,
    "Central directory must immediately precede the end record",
  )
  let entries : Array[ZipMember] = []
  let names : Set[String] = Set([])
  let mut cursor = directory
  let mut total = 0L
  for _ in 0..= 0 && cursor + 46 <= end, "Truncated central header")
    zip_need(zip_u32(data, cursor) == 0x02014b50U, "Invalid central signature")
    let flags = zip_u16(data, cursor + 8)
    let compression = zip_u16(data, cursor + 10)
    zip_need(
      (flags & 0xf7f1) == 0 && (compression == 0 || compression == 8),
      "Encrypted or unsupported ZIP flags/compression",
    )
    zip_need(
      zip_u16(data, cursor + 6) <= 20 && zip_u16(data, cursor + 34) == 0,
      "ZIP64 and split entries are unsupported",
    )
    zip_need(
      zip_u16(data, cursor + 30) == 0 && zip_u16(data, cursor + 32) == 0,
      "Extra fields and entry comments are unsupported",
    )
    let attrs = zip_u32(data, cursor + 38)
    let kind = (attrs >> 16) & 0xf000U
    zip_need(
      (attrs & 0x10U) == 0 && (kind == 0U || kind == 0x8000U),
      "Only regular files are supported; directories and links are refused",
    )
    let name_length = zip_u16(data, cursor + 28)
    zip_need(
      name_length > 0 && cursor + 46 + name_length <= end,
      "Truncated or empty filename",
    )
    let name = carrier_text({
      name: "ZIP filename",
      data: data[cursor + 46:cursor + 46 + name_length].to_owned(),
    })
    ignore(collection_path(name))
    zip_need(!names.contains(name), "Duplicate ZIP filenames are ambiguous")
    names.add(name)
    let compressed = zip_number(data, cursor + 20)
    let expanded = zip_number(data, cursor + 24)
    total += expanded.to_int64()
    carrier_sizes(expanded, total, limits)
    let crc = zip_u32(data, cursor + 16)
    let offset = zip_number(data, cursor + 42)
    zip_need(offset + 30 <= directory, "Truncated local header")
    zip_need(
      zip_u32(data, offset) == 0x04034b50U &&
      zip_u16(data, offset + 4) == zip_u16(data, cursor + 6) &&
      zip_u16(data, offset + 6) == flags &&
      zip_u16(data, offset + 8) == compression,
      "Local and central headers disagree",
    )
    zip_need(
      zip_u32(data, offset + 10) == zip_u32(data, cursor + 12),
      "Local and central timestamps disagree",
    )
    zip_need(
      zip_u16(data, offset + 26) == name_length &&
      zip_u16(data, offset + 28) == 0,
      "Local filename length/extra fields disagree",
    )
    let start = offset + 30 + name_length
    zip_need(
      start + compressed <= directory,
      "Member payload exceeds local region",
    )
    zip_need(
      data[offset + 30:start] == data[cursor + 46:cursor + 46 + name_length],
      "Local and central filenames disagree",
    )
    let mut entry_end = start + compressed
    if (flags & 8) == 0 {
      zip_need(
        zip_u32(data, offset + 14) == crc &&
        zip_number(data, offset + 18) == compressed &&
        zip_number(data, offset + 22) == expanded,
        "Local CRC or sizes disagree",
      )
    } else {
      for
        (at, expected) in [
          (14, crc),
          (18, compressed.reinterpret_as_uint()),
          (22, expanded.reinterpret_as_uint()),
        ] {
        let value = zip_u32(data, offset + at)
        zip_need(
          value == 0U || value == expected,
          "Inconsistent descriptor placeholders",
        )
      }
      zip_need(entry_end + 12 <= directory, "Missing classic data descriptor")
      if zip_u32(data, entry_end) == 0x08074b50U {
        entry_end += 4
      }
      zip_need(entry_end + 12 <= directory, "Truncated data descriptor")
      zip_need(
        zip_u32(data, entry_end) == crc &&
        zip_number(data, entry_end + 4) == compressed &&
        zip_number(data, entry_end + 8) == expanded,
        "Invalid data descriptor",
      )
      entry_end += 12
    }
    entries.push({
      name,
      offset,
      end: entry_end,
      start,
      compressed,
      expanded,
      crc,
      compression,
    })
    cursor += 46 + name_length
  }
  zip_need(cursor == end, "Undeclared central data")
  entries.sort_by((a, b) => a.offset.compare(b.offset))
  let mut next = 0
  for entry in entries {
    zip_need(
      entry.offset == next,
      "Overlapping, hidden, prefixed or gapped local records",
    )
    next = entry.end
  }
  zip_need(next == directory, "Undeclared local data")
  entries
}

///|
fn zip_expand(
  data : Bytes,
  entry : ZipMember,
) -> CarrierMember raise @ledger.InputError {
  let compressed = data[entry.start:entry.start + entry.compressed].to_owned()
  let decoded = if entry.compression == 0 {
    zip_need(entry.compressed == entry.expanded, "Stored entry sizes disagree")
    compressed
  } else {
    let output = @fzip.inflate_sync(compressed.to_fixedarray(), opts={
      out: Some(FixedArray::make(entry.expanded, b'\x00')),
      dictionary: None,
      max_input_size: compressed.length().max(1),
      max_output_size: entry.expanded.max(1),
    }) catch {
      @fzip.FzipError::FzipError(message~, ..) =>
        raise bad("ZipDeflate", entry.name, message)
    }
    Bytes::from_array(output[:])
  }
  zip_need(
    decoded.length() == entry.expanded,
    "Expanded size differs from declared size",
  )
  zip_need(
    @fzip.crc32(decoded.to_fixedarray()) == entry.crc,
    "ZIP entry CRC-32 mismatch",
  )
  { name: entry.name, data: decoded, }
}

///|
/// Deterministic classic ZIP carrier for the sealed full-plan file pack.
/// Compression is supplied by hustcer/fzip; this API is not a general archiver.
pub fn Evidence::zip_pack(
  self : Evidence,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
  max_bytes~ : Int32 = 16777216,
) -> Bytes raise @ledger.InputError {
  carrier_budget(0, max_bytes)
  let entries = carrier_pack(self, limits)
  let result = @fzip.zip_sync_checked(
    entries.map(entry => (entry.name, entry.data.to_fixedarray())),
  ) catch {
    @fzip.FzipError::FzipError(message~, ..) =>
      raise bad("ZipWrite", "$", message)
  }
  let bytes = Bytes::from_array(result[:])
  ignore(zip_entries(bytes, limits, max_bytes))
  bytes
}

///|
/// Reject unsupported carrier metadata, then verify original XML, full plan,
/// policy and whole evidence digest. This does not authenticate the sender.
pub fn decode_zip_pack(
  data : Bytes,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
  max_bytes~ : Int32 = 16777216,
) -> Evidence raise @ledger.InputError {
  let entries = zip_entries(data, limits, max_bytes)
  guard entries.iter().find_first(entry => entry.name == "index.json")
    is Some(index_entry) else {
    raise bad("MissingPackFile", "index.json", "ZIP pack requires its index")
  }
  let index_entry = zip_expand(data, index_entry)
  let index = decode_pack_index(carrier_text(index_entry), limits~)
  let required = Set::from_array(index.filenames())
  zip_need(
    entries.length() == required.length() + 1,
    "Pack entry inventory differs from index",
  )
  for entry in entries {
    zip_need(
      entry.name == "index.json" || required.contains(entry.name),
      "Undeclared pack entry",
    )
  }
  carrier_restore(entries.map(entry => zip_expand(data, entry)), limits)
}