///|
/// Resource and path limits applied to one ZIP/OPC open.
///
/// These limits are enforced before extraction from central-directory
/// metadata and checked again against the extracted entries. The dependency's
/// own hard limits remain an additional ceiling.
pub(all) struct ArchivePolicy {
  max_compressed_input_bytes : Int
  max_entries : Int
  max_entry_compressed_bytes : Int
  max_entry_uncompressed_bytes : Int
  max_total_uncompressed_bytes : Int
  max_compression_ratio : Int
  max_path_depth : Int
  max_path_length : Int
} derive(Debug, Eq)

///|
/// Conservative defaults for untrusted, in-memory OOXML opens.
///
/// The path length is measured in MoonBit string code units. Callers may use a
/// stricter validated policy for a particular trust boundary.
pub fn ArchivePolicy::default() -> ArchivePolicy {
  {
    max_compressed_input_bytes: 64 * 1024 * 1024,
    max_entries: 8192,
    max_entry_compressed_bytes: 32 * 1024 * 1024,
    max_entry_uncompressed_bytes: 64 * 1024 * 1024,
    max_total_uncompressed_bytes: 96 * 1024 * 1024,
    max_compression_ratio: 200,
    max_path_depth: 32,
    max_path_length: 1024,
  }
}

///|
/// Reject internally inconsistent or non-positive limits.
pub fn ArchivePolicy::validate(self : ArchivePolicy) -> Unit raise PackageError {
  if self.max_compressed_input_bytes <= 0 {
    raise PackageError::InvalidPackage(
      "archive policy max_compressed_input_bytes must be positive",
    )
  }
  if self.max_entries <= 0 {
    raise PackageError::InvalidPackage(
      "archive policy max_entries must be positive",
    )
  }
  if self.max_entry_compressed_bytes <= 0 {
    raise PackageError::InvalidPackage(
      "archive policy max_entry_compressed_bytes must be positive",
    )
  }
  if self.max_entry_uncompressed_bytes <= 0 {
    raise PackageError::InvalidPackage(
      "archive policy max_entry_uncompressed_bytes must be positive",
    )
  }
  if self.max_total_uncompressed_bytes <= 0 {
    raise PackageError::InvalidPackage(
      "archive policy max_total_uncompressed_bytes must be positive",
    )
  }
  if self.max_compression_ratio <= 0 {
    raise PackageError::InvalidPackage(
      "archive policy max_compression_ratio must be positive",
    )
  }
  if self.max_path_depth <= 0 {
    raise PackageError::InvalidPackage(
      "archive policy max_path_depth must be positive",
    )
  }
  if self.max_path_length <= 0 {
    raise PackageError::InvalidPackage(
      "archive policy max_path_length must be positive",
    )
  }
  if self.max_entry_compressed_bytes > self.max_compressed_input_bytes {
    raise PackageError::InvalidPackage(
      "archive policy per-entry compressed limit exceeds input limit",
    )
  }
  if self.max_entry_uncompressed_bytes > self.max_total_uncompressed_bytes {
    raise PackageError::InvalidPackage(
      "archive policy per-entry uncompressed limit exceeds total limit",
    )
  }
}

///|
fn archive_ratio_exceeds(
  uncompressed : Int,
  compressed : Int,
  maximum_ratio : Int,
) -> Bool {
  if uncompressed == 0 {
    return false
  }
  if compressed == 0 {
    return true
  }
  let quotient = uncompressed / compressed
  quotient > maximum_ratio ||
  (quotient == maximum_ratio && uncompressed % compressed != 0)
}

///|
fn canonical_archive_path(
  name : String,
  policy : ArchivePolicy,
) -> String raise PackageError {
  if name == "" {
    raise PackageError::InvalidPackage("ZIP entry path is empty")
  }
  if name.length() > policy.max_path_length {
    raise PackageError::InvalidPackage(
      "ZIP entry path exceeds max_path_length: " + name,
    )
  }
  if name.has_prefix("/") || name.has_prefix("\\") {
    raise PackageError::InvalidPackage("absolute ZIP entry path: " + name)
  }
  if name.contains("\\") {
    raise PackageError::InvalidPackage(
      "non-canonical ZIP path separator: " + name,
    )
  }
  for character in name {
    if !character.is_printable() {
      raise PackageError::InvalidPackage(
        "ZIP entry path contains a control character",
      )
    }
  }
  let directory = name.has_suffix("/")
  let body = if directory {
    name.unsafe_substring(start=0, end=name.length() - 1)
  } else {
    name
  }
  if body == "" {
    raise PackageError::InvalidPackage("ZIP entry path has no component")
  }
  let components : Array[String] = []
  for component_view in body.split("/") {
    let component = component_view.to_owned()
    if component == "" || component == "." || component == ".." {
      raise PackageError::InvalidPackage(
        "ZIP entry path contains an empty, dot, or parent component: " + name,
      )
    }
    if component.trim().to_owned() != component {
      raise PackageError::InvalidPackage(
        "ZIP entry path contains a whitespace alias: " + name,
      )
    }
    let lower = component.to_lower()
    if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
      raise PackageError::InvalidPackage(
        "ZIP entry path contains an encoded path alias: " + name,
      )
    }
    if component.contains("?") || component.contains("#") {
      raise PackageError::InvalidPackage(
        "ZIP entry path contains a URI query or fragment delimiter: " + name,
      )
    }
    components.push(component)
  }
  if components.length() > policy.max_path_depth {
    raise PackageError::InvalidPackage(
      "ZIP entry path exceeds max_path_depth: " + name,
    )
  }
  let canonical = components.join("/")
  if directory {
    canonical + "/"
  } else {
    canonical
  }
}

///|
fn preflight_archive(
  content : FixedArray[Byte],
  policy : ArchivePolicy,
) -> Array[@fzip.UnzipFileInfo] raise PackageError {
  policy.validate()
  if content.length() > policy.max_compressed_input_bytes {
    raise PackageError::InvalidPackage(
      "ZIP input exceeds max_compressed_input_bytes",
    )
  }
  let infos = @fzip.unzip_list(content) catch {
    _ => raise PackageError::InvalidPackage("unable to inspect ZIP container")
  }
  if infos.length() > policy.max_entries {
    raise PackageError::InvalidPackage("ZIP entry count exceeds max_entries")
  }
  let seen : Map[String, Unit] = Map([])
  let mut total_uncompressed = 0
  for info in infos {
    if info.size < 0 || info.original_size < 0 {
      raise PackageError::InvalidPackage("ZIP entry declares a negative size")
    }
    if info.size > policy.max_entry_compressed_bytes {
      raise PackageError::InvalidPackage(
        "ZIP entry exceeds max_entry_compressed_bytes: " + info.name,
      )
    }
    if info.original_size > policy.max_entry_uncompressed_bytes {
      raise PackageError::InvalidPackage(
        "ZIP entry exceeds max_entry_uncompressed_bytes: " + info.name,
      )
    }
    if info.original_size >
      policy.max_total_uncompressed_bytes - total_uncompressed {
      raise PackageError::InvalidPackage(
        "ZIP archive exceeds max_total_uncompressed_bytes",
      )
    }
    total_uncompressed += info.original_size
    if info.compression != 0 && info.compression != 8 {
      raise PackageError::InvalidPackage(
        "unsupported ZIP compression method for entry: " + info.name,
      )
    }
    if info.compression == 0 && info.size != info.original_size {
      raise PackageError::InvalidPackage(
        "stored ZIP entry size mismatch: " + info.name,
      )
    }
    if archive_ratio_exceeds(
        info.original_size,
        info.size,
        policy.max_compression_ratio,
      ) {
      raise PackageError::InvalidPackage(
        "ZIP entry exceeds max_compression_ratio: " + info.name,
      )
    }
    let canonical = canonical_archive_path(info.name, policy)
    let identity = if canonical.has_suffix("/") {
      canonical.unsafe_substring(start=0, end=canonical.length() - 1).to_lower()
    } else {
      canonical.to_lower()
    }
    if seen.contains(identity) {
      raise PackageError::InvalidPackage(
        "duplicate or aliased ZIP entry path: " + info.name,
      )
    }
    seen[identity] = ()
  }
  infos
}

///|
fn extract_verified_archive(
  content : FixedArray[Byte],
  policy : ArchivePolicy,
  infos : Array[@fzip.UnzipFileInfo],
) -> Array[(String, FixedArray[Byte])] raise PackageError {
  let entries = @fzip.unzip_sync(content, opts={ verify_checksum: true }) catch {
    _ =>
      raise PackageError::InvalidPackage(
        "ZIP extraction or CRC integrity verification failed",
      )
  }
  if entries.length() != infos.length() {
    raise PackageError::InvalidPackage(
      "ZIP metadata and extracted entry counts disagree",
    )
  }
  let mut total_uncompressed = 0
  for index, entry in entries {
    let info = infos[index]
    if entry.0 != info.name || entry.1.length() != info.original_size {
      raise PackageError::InvalidPackage(
        "ZIP metadata and extracted output disagree: " + info.name,
      )
    }
    if entry.1.length() > policy.max_entry_uncompressed_bytes ||
      entry.1.length() >
      policy.max_total_uncompressed_bytes - total_uncompressed {
      raise PackageError::InvalidPackage(
        "ZIP extracted output exceeds archive policy",
      )
    }
    total_uncompressed += entry.1.length()
  }
  entries
}