///|
/// Discover models listed by META-INF/smithy/manifest in a local JAR or ZIP.
/// Keys are relative to META-INF/smithy; no archive files are extracted.
pub fn archive_models(
  data : Bytes,
  origin : String,
) -> Map[String, String] raise BuildError {
  archive_validate_bounds(data, origin)
  let archive = @zipc.Archive::from_bytes(data) catch {
    @zipc.ZipError(message) => raise archive_error(origin, message)
  }
  let out : Map[String, String] = Map([])
  guard archive.find("META-INF/smithy/manifest") is Some(manifest) else {
    return out
  }
  let manifest = archive_text(manifest, origin)
    .replace_all(old="\r\n", new="\n")
    .replace_all(old="\r", new="\n")
  for line in manifest.split("\n") {
    let name = line.to_owned()
    if name == "" || name.has_prefix("#") {
      continue
    }
    if !archive_model_path(name) {
      raise archive_error(origin, "Invalid Smithy manifest path `" + name + "`")
    }
    if out.contains(name) {
      continue
    }
    if !name.has_suffix(".smithy") && !name.has_suffix(".json") {
      raise archive_error(
        origin,
        "Unrecognized model file `" + name + "` in Smithy manifest",
      )
    }
    guard archive.find("META-INF/smithy/" + name) is Some(member_) else {
      raise archive_error(
        origin,
        "Smithy manifest references missing entry `" + name + "`",
      )
    }
    out[name] = archive_text(member_, origin)
  }
  out
}

///|
// zipc 0.2.2 decodes ZIP32 offsets as signed Int values. Validate every range
// before handing it to the decoder so malformed offsets cannot index Bytes
// outside their bounds. Decompression and CRC checks stay with zipc.
fn archive_validate_bounds(
  data : Bytes,
  origin : String,
) -> Unit raise BuildError {
  fn range(start : Int32, size : Int32) -> Unit raise BuildError {
    if start < 0 ||
      size < 0 ||
      size > data.length() ||
      start > data.length() - size {
      raise archive_error(
        origin, "Truncated ZIP entry or invalid archive offset",
      )
    }
  }
  fn u16(at : Int32) -> Int32 {
    data[at].to_int() | (data[at + 1].to_int() << 8)
  }
  fn u32(at : Int32) -> Int32 raise BuildError {
    if data[at + 3].to_int() >= 128 {
      raise archive_error(
        origin, "ZIP64 or oversized ZIP32 archives are not supported",
      )
    }
    data[at].to_int() |
    (data[at + 1].to_int() << 8) |
    (data[at + 2].to_int() << 16) |
    (data[at + 3].to_int() << 24)
  }
  if data.length() < 22 || !@zipc.bytes_has_magic(data) {
    raise archive_error(origin, "Not a ZIP archive")
  }
  let mut end : Int32? = None
  let mut at = data.length() - 22
  let limit = if at > 65535 { at - 65535 } else { 0 }
  while at >= limit {
    if data[at] == 0x50 &&
      data[at + 1] == 0x4b &&
      data[at + 2] == 0x05 &&
      data[at + 3] == 0x06 {
      end = Some(at)
      break
    }
    at -= 1
  }
  guard end is Some(end) else {
    raise archive_error(origin, "Missing ZIP end of central directory")
  }
  if u16(end + 4) != 0 || u16(end + 6) != 0 || u16(end + 8) != u16(end + 10) {
    raise archive_error(origin, "Multi-disk ZIP archives are not supported")
  }
  range(end + 22, u16(end + 20))
  let count = u16(end + 10)
  let size = u32(end + 12)
  let directory = u32(end + 16)
  range(directory, size)
  if directory + size > end {
    raise archive_error(origin, "Invalid ZIP central directory range")
  }
  let mut offset = directory
  for _ in 0.. directory + size {
    raise archive_error(origin, "ZIP entries exceed the central directory")
  }
}

///|
fn archive_error(origin : String, message : String) -> BuildError {
  InvalidBuildConfig(
    "Could not read Smithy archive `" + origin + "`: " + message,
  )
}

///|
fn archive_model_path(name : String) -> Bool {
  if name.has_prefix("/") || name.has_suffix("/") {
    return false
  }
  if name
    .iter()
    .any(c => {
      c.to_int() < 32 ||
      c.to_int() == 127 ||
      "\\?%*:|\"'><# ".contains(c.to_string())
    }) {
    return false
  }
  name.split("/").all(part => part != "" && part != "." && part != "..")
}

///|
fn archive_text(
  member_ : @zipc.Member,
  origin : String,
) -> String raise BuildError {
  guard member_.kind() is @zipc.File(file) else {
    raise archive_error(
      origin,
      "Expected a model file at `" + member_.path() + "`",
    )
  }
  let bytes = file.to_bytes() catch {
    @zipc.ZipError(message) =>
      raise archive_error(origin, member_.path() + ": " + message)
  }
  @utf8.decode(bytes) catch {
    _ =>
      raise archive_error(origin, "Invalid UTF-8 in `" + member_.path() + "`")
  }
}