///|
/// Tail lookup includes the largest classic EOCD and its 16-bit comment.
const DIRECTORY_TAIL_SIZE : Int32 = 65557

///|
const DIRECTORY_CHUNK_SIZE : Int32 = 65536

///|
priv struct ClassicDirectoryEnd {
  offset : UInt64
  disk_entries : Int32
  entries : Int32
  central_size : UInt32
  central_offset : UInt32
  comment : Bytes
}

///|
priv struct DirectoryBounds {
  offset : UInt64
  size : UInt64
  entries : UInt64
  comment : Bytes
}

///|
priv enum DirectoryPhase {
  Initial
  Tail(ReadRange)
  Locator(ClassicDirectoryEnd)
  Zip64(ClassicDirectoryEnd, UInt64)
  Records(DirectoryBounds, UInt64)
  Complete(Directory)
  Failed(ZipErrorKind, String)
}

///|
/// Metadata-only ZIP reader driven by caller-supplied byte ranges. Supports
/// classic ZIP and ZIP64 beyond 2 GiB without retaining the archive or inflating
/// payloads. Retains owned metadata plus a bounded incomplete directory record.
/// File reads, HTTP requests, source consistency, and cancellation between
/// calls belong to the caller. No I/O callback is retained or invoked.
pub struct DirectoryReader {
  priv archive_size : UInt64
  priv limits : DirectoryLimits
  priv mut phase : DirectoryPhase
  priv mut entries : Array[DirectoryEntry]
  priv mut pending : Bytes
}

///|
/// Start reading an archive of the supplied total byte size. No bytes are read
/// until the caller drives `advance` and fulfills the returned ranges.
pub fn DirectoryReader::DirectoryReader(
  archive_size : UInt64,
  limits~ : DirectoryLimits = DirectoryLimits(),
) -> DirectoryReader {
  { archive_size, limits, phase: Initial, entries: [], pending: b"", }
}

///|
/// Initially supply empty input; thereafter supply exactly the bytes requested
/// by the preceding `NeedRead`. Accumulate transport short reads before calling.
/// All ranges must refer to the same immutable archive. Input is borrowed only
/// for this call; retained metadata and incomplete records are copied.
/// Requests are at most 65,557 bytes, and directory chunks at most 64 KiB.
/// `Done` validates directory structure and referenced bounds, not local headers
/// or payload integrity. Empty calls after completion return the same listing.
/// Any error is latched and rethrown on subsequent calls.
pub fn DirectoryReader::advance(
  self : DirectoryReader,
  input : BytesView,
) -> DirectoryStep raise ZipError {
  if self.phase is Failed(kind, message) {
    raise ZipError(kind, message)
  }
  self.advance_active(input) catch {
    ZipError(kind, message) => {
      self.phase = Failed(kind, message)
      self.entries = []
      self.pending = b""
      raise ZipError(kind, message)
    }
  }
}

///|
fn expect_range_input(input : BytesView, length : Int32) -> Unit raise ZipError {
  guard input.length() == length else {
    raise ZipError(
      InvalidRead,
      "zip: response length differs from requested range",
    )
  }
}

///|
fn range_read_length(remaining : UInt64, maximum : Int32) -> Int32 {
  if remaining < maximum.to_uint64() {
    remaining.to_int()
  } else {
    maximum
  }
}

///|
fn check_directory_limit(
  kind : LimitKind,
  limit : UInt64,
  actual : UInt64,
) -> Unit raise ZipError {
  guard actual <= limit else {
    raise ZipError(
      LimitExceeded(kind, limit, actual),
      "zip: directory limit exceeded",
    )
  }
}

///|
fn DirectoryReader::advance_active(
  self : DirectoryReader,
  input : BytesView,
) -> DirectoryStep raise ZipError {
  match self.phase {
    Initial => {
      expect_range_input(input, 0)
      guard self.archive_size >= 22UL else {
        raise ZipError(
          MissingEndOfCentral,
          "zip: end of central directory not found",
        )
      }
      let length = range_read_length(self.archive_size, DIRECTORY_TAIL_SIZE)
      let range = { offset: self.archive_size - length.to_uint64(), length, }
      self.phase = Tail(range)
      NeedRead(range)
    }
    Tail(range) => {
      expect_range_input(input, range.length)
      guard find_end_of_central(input) is Some(position) else {
        raise ZipError(
          MissingEndOfCentral,
          "zip: end of central directory not found",
        )
      }
      let reader = Cursor(input, position + 4)
      let disk = reader.read_u16()
      let central_disk = reader.read_u16()
      let disk_entries = reader.read_u16()
      let entries = reader.read_u16()
      let central_size = reader.read_u32_raw()
      let central_offset = reader.read_u32_raw()
      let comment_size = reader.read_u16()
      guard disk == 0 && central_disk == 0 else {
        raise ZipError(
          UnsupportedFeature,
          "zip: multi-disk archives are not supported",
        )
      }
      let classic = {
        offset: range.offset + position.to_uint64(),
        disk_entries,
        entries,
        central_size,
        central_offset,
        comment: reader.read_bytes(comment_size).to_owned(),
      }
      // ZIP64 records may also be present when every classic field fits.
      let zip64 = disk_entries == 0xffff ||
        entries == 0xffff ||
        central_size == MAX_U32 ||
        central_offset == MAX_U32 ||
        UInt64::extend_uint(central_offset) + UInt64::extend_uint(central_size) !=
        classic.offset
      guard !zip64 else {
        guard classic.offset >= 20UL else {
          raise ZipError(Truncated, "zip: zip64 locator out of bounds")
        }
        self.phase = Locator(classic)
        return NeedRead({ offset: classic.offset - 20UL, length: 20, })
      }
      guard disk_entries == entries else {
        raise ZipError(
          UnsupportedFeature,
          "zip: multi-disk archives are not supported",
        )
      }
      self.begin_records(
        UInt64::extend_uint(central_offset),
        UInt64::extend_uint(central_size),
        entries.to_uint64(),
        classic.offset,
        classic.comment,
      )
    }
    Locator(classic) => {
      expect_range_input(input, 20)
      let reader = Cursor(input, 0)
      guard reader.read_u32_raw() == ZIP64_LOCATOR_SIG else {
        raise ZipError(InvalidSignature, "zip: invalid zip64 locator signature")
      }
      let disk = reader.read_u32_raw()
      let offset = reader.read_u64_raw()
      let disks = reader.read_u32_raw()
      guard disk == 0 && disks == 1 else {
        raise ZipError(
          UnsupportedFeature,
          "zip: multi-disk archives are not supported",
        )
      }
      let locator = classic.offset - 20UL
      guard offset <= locator && 56UL <= locator - offset else {
        raise ZipError(Truncated, "zip: zip64 end record out of bounds")
      }
      self.phase = Zip64(classic, offset)
      NeedRead({ offset, length: 56, })
    }
    Zip64(classic, offset) => {
      expect_range_input(input, 56)
      let reader = Cursor(input, 0)
      guard reader.read_u32_raw() == ZIP64_EOCD_SIG else {
        raise ZipError(
          InvalidSignature,
          "zip: invalid zip64 end record signature",
        )
      }
      let record_size = reader.read_u64_raw()
      guard record_size == classic.offset - 20UL - offset - 12UL else {
        raise ZipError(
          UnsupportedFeature,
          "zip: zip64 end record size mismatch",
        )
      }
      ignore(reader.read_u16())
      ignore(reader.read_u16())
      let disk = reader.read_u32_raw()
      let central_disk = reader.read_u32_raw()
      let disk_entries = reader.read_u64_raw()
      let entries = reader.read_u64_raw()
      let central_size = reader.read_u64_raw()
      let central_offset = reader.read_u64_raw()
      guard disk == 0 && central_disk == 0 && disk_entries == entries else {
        raise ZipError(
          UnsupportedFeature,
          "zip: multi-disk archives are not supported",
        )
      }
      guard (
          classic.disk_entries == 0xffff ||
          classic.disk_entries.to_uint64() == entries
        ) &&
        (classic.entries == 0xffff || classic.entries.to_uint64() == entries) &&
        (
          classic.central_size == MAX_U32 ||
          UInt64::extend_uint(classic.central_size) == central_size
        ) &&
        (
          classic.central_offset == MAX_U32 ||
          UInt64::extend_uint(classic.central_offset) == central_offset
        ) else {
        raise ZipError(
          UnsupportedFeature,
          "zip: classic and zip64 directory fields differ",
        )
      }
      self.begin_records(
        central_offset,
        central_size,
        entries,
        offset,
        classic.comment,
      )
    }
    Records(bounds, offset) => {
      let end = bounds.offset + bounds.size
      let length = range_read_length(end - offset, DIRECTORY_CHUNK_SIZE)
      expect_range_input(input, length)
      let bytes : BytesView = if self.pending.length() == 0 {
        input
      } else {
        self.pending + input.to_owned()
      }
      let mut position = 0
      while self.entries.length().to_uint64() < bounds.entries &&
            bytes.length() - position >= 46 {
        let name_size = read_u16_le_at(bytes, position + 28)
        let extra_size = read_u16_le_at(bytes, position + 30)
        let comment_size = read_u16_le_at(bytes, position + 32)
        let record_size = 46 + name_size + extra_size + comment_size
        guard record_size <= bytes.length() - position else { break }
        let parsed = parse_directory_record(
          Cursor(bytes[position:position + record_size], 0),
        )
        let entry = parsed.entry
        // Local variable-length headers are deferred; their fixed prefix and
        // declared payload must at least fit before the central directory.
        guard entry.local_offset <= bounds.offset &&
          30UL <= bounds.offset - entry.local_offset &&
          entry.compressed_size <= bounds.offset - entry.local_offset - 30UL else {
          raise ZipError(
            Truncated,
            "zip: local record or payload out of bounds",
          )
        }
        self.entries.push(entry)
        position += record_size
      }
      let next = offset + length.to_uint64()
      let entries_done = self.entries.length().to_uint64() == bounds.entries
      if entries_done {
        validate_directory_signature(
          bytes[position:],
          end - next + (bytes.length() - position).to_uint64(),
        )
      }
      if next == end {
        guard entries_done else {
          raise ZipError(
            UnsupportedFeature,
            "zip: central directory size/count mismatch",
          )
        }
        self.complete(bounds.comment, bounds.offset)
      } else {
        self.pending = copy_zip_input(bytes[position:])
        self.phase = Records(bounds, next)
        NeedRead({
          offset: next,
          length: range_read_length(end - next, DIRECTORY_CHUNK_SIZE),
        })
      }
    }
    Complete(directory) => {
      expect_range_input(input, 0)
      Done(directory)
    }
    Failed(kind, message) => raise ZipError(kind, message)
  }
}

///|
/// The central directory may end with one digital-signature record. Validate
/// its framing only, not the signature itself. `remaining` includes bytes in
/// this chunk and later chunks; the 16-bit size bounds retained input even when
/// the header straddles reads or an untrusted directory declares a huge tail.
fn validate_directory_signature(
  bytes : BytesView,
  remaining : UInt64,
) -> Unit raise ZipError {
  guard remaining != 0UL else { return }
  guard remaining >= 6UL && remaining <= 65541UL else {
    raise ZipError(UnsupportedFeature, "zip: invalid directory signature size")
  }
  if bytes.length() >= 4 {
    guard read_u32_le_at_raw(bytes, 0) == DIRECTORY_SIGNATURE_SIG else {
      raise ZipError(InvalidSignature, "zip: invalid directory signature")
    }
  }
  if bytes.length() >= 6 {
    guard 6UL + read_u16_le_at(bytes, 4).to_uint64() == remaining else {
      raise ZipError(
        UnsupportedFeature,
        "zip: directory signature size mismatch",
      )
    }
  }
}

///|
fn DirectoryReader::begin_records(
  self : DirectoryReader,
  offset : UInt64,
  size : UInt64,
  entries : UInt64,
  boundary : UInt64,
  comment : Bytes,
) -> DirectoryStep raise ZipError {
  guard offset <= boundary && size == boundary - offset else {
    raise ZipError(Truncated, "zip: central directory out of bounds")
  }
  check_directory_limit(DirectoryBytes, self.limits.max_directory_bytes, size)
  let entry_limit = if self.limits.max_entries < 0x7fff_ffffUL {
    self.limits.max_entries
  } else {
    0x7fff_ffffUL
  }
  check_directory_limit(Entries, entry_limit, entries)
  guard entries <= size / 46UL else {
    raise ZipError(
      Truncated,
      "zip: central directory entry count exceeds its size",
    )
  }
  guard size != 0UL else { return self.complete(comment, offset) }
  let bounds = { offset, size, entries, comment, }
  self.phase = Records(bounds, offset)
  NeedRead({ offset, length: range_read_length(size, DIRECTORY_CHUNK_SIZE), })
}

///|
fn DirectoryReader::complete(
  self : DirectoryReader,
  comment : Bytes,
  central_offset : UInt64,
) -> DirectoryStep {
  let directory = { entries: self.entries, comment, central_offset, }
  self.entries = []
  self.pending = b""
  self.phase = Complete(directory)
  Done(directory)
}