///|
suberror PackageDownloadError {
  PackageDownloadError(String)
} derive(Eq)

///|
impl Show for PackageDownloadError with fn output(self, logger) {
  match self {
    PackageDownloadError(message) =>
      logger.write_string("PackageDownloadError(\{message})")
  }
}

///|
struct ZipEntry {
  path : String
  content : Bytes
  is_dir : Bool
}

///|
fn is_http_redirect(code : Int) -> Bool {
  code == 301 || code == 302 || code == 303 || code == 307 || code == 308
}

///|
fn package_download_error(message : String) -> Unit raise PackageDownloadError {
  raise PackageDownloadError::PackageDownloadError(message)
}

///|
fn package_metadata_url_from_uri(uri : String) -> String? {
  if uri.has_prefix("package://") {
    match parse_package_uri(uri) {
      Some(parsed) =>
        Some(
          package_metadata_url({
            authority: parsed.authority,
            path: strip_package_checksum_from_path(parsed.path),
            fragment: parsed.fragment,
          }),
        )
      None => None
    }
  } else if uri.has_prefix("projectpackage://") {
    let body = String::unsafe_substring(uri, start=17, end=uri.length())
    let without_fragment = match body.find("#") {
      Some(idx) => String::unsafe_substring(body, start=0, end=idx)
      None => body
    }
    Some("https://\{strip_package_checksum_from_path(without_fragment)}")
  } else {
    None
  }
}

///|
fn extract_package_zip_sha256(body : String) -> String? {
  let parsed = Some(@json.parse(body[:])) catch { _ => None }
  match parsed {
    Some(Object(top)) =>
      match top.get("packageZipChecksums") {
        Some(Object(checksums)) =>
          match checksums.get("sha256") {
            Some(String(value)) => Some(value)
            _ => None
          }
        _ => None
      }
    _ => None
  }
}

///|
fn ensure_dir(path : String) -> Unit raise {
  if path == "" || path == "/" {
    return
  }
  if @fs.path_exists(path) {
    let dir_ok = @fs.is_dir(path) catch { _ => false }
    if dir_ok {
      return
    }
    package_download_error("path exists but is not a directory: \{path}")
  }
  let mut i = if path.has_prefix("/") { 1 } else { 0 }
  while i <= path.length() {
    if i == path.length() || path[i].to_int().unsafe_to_char() == '/' {
      let part = String::unsafe_substring(path, start=0, end=i)
      if part != "" && part != "/" && !@fs.path_exists(part) {
        @fs.create_dir(part)
      }
    }
    i += 1
  }
}

///|
fn parent_dir(path : String) -> String {
  match path.rev_find("/") {
    Some(idx) => String::unsafe_substring(path, start=0, end=idx)
    None => ""
  }
}

///|
fn bytes_slice(data : Bytes, start : Int, end : Int) -> Bytes raise {
  if start < 0 || end < start || end > data.length() {
    package_download_error("byte slice out of range")
  }
  Bytes::from_array(FixedArray::makei(end - start, fn(i) { data[start + i] }))
}

///|
fn empty_bytes() -> Bytes {
  Bytes::from_array([])
}

///|
fn read_u16_le(data : Bytes, offset : Int) -> Int raise {
  if offset < 0 || offset + 2 > data.length() {
    package_download_error("unexpected end of zip data")
  }
  data[offset].to_int() | (data[offset + 1].to_int() << 8)
}

///|
fn read_u32_le(data : Bytes, offset : Int) -> Int raise {
  if offset < 0 || offset + 4 > data.length() {
    package_download_error("unexpected end of zip data")
  }
  data[offset].to_int() |
  (data[offset + 1].to_int() << 8) |
  (data[offset + 2].to_int() << 16) |
  (data[offset + 3].to_int() << 24)
}

///|
fn find_zip_eocd(data : Bytes) -> Int raise {
  if data.length() < 22 {
    package_download_error("zip data is too short")
  }
  let min = if data.length() > 66000 { data.length() - 66000 } else { 0 }
  let mut i = data.length() - 22
  while i >= min {
    if read_u32_le(data, i) == 0x06054b50 {
      return i
    }
    i -= 1
  }
  package_download_error("zip end-of-central-directory not found")
  0
}

///|
fn read_zip_entries(data : Bytes) -> Array[ZipEntry] raise {
  let eocd = find_zip_eocd(data)
  let total_entries = read_u16_le(data, eocd + 10)
  let central_offset = read_u32_le(data, eocd + 16)
  let entries : Array[ZipEntry] = []
  let mut offset = central_offset
  for _ in 0.. compressed
        8 => @zlib.deflate_decompress(compressed, max_size=uncompressed_size)
        _ => {
          package_download_error(
            "unsupported zip compression method: \{compression_method}",
          )
          empty_bytes()
        }
      }
    }
    if !is_dir && content.length() != uncompressed_size {
      package_download_error("zip entry size mismatch: \{name}")
    }
    ignore(flags)
    entries.push({ path: name, content, is_dir })
    offset = name_end + extra_len + comment_len
  }
  entries
}

///|
fn zip_entry_path_is_safe(path : String) -> Bool {
  // Packages produced by the official Pkl tooling commonly contain a
  // directory marker named exactly `/`. It maps to the extraction root and
  // carries no payload, so accepting it does not permit an absolute file.
  if path == "/" {
    return true
  }
  if path == "" || path.has_prefix("/") || path.find("\\") is Some(_) {
    return false
  }
  let mut start = 0
  let mut i = 0
  while i <= path.length() {
    if i == path.length() || path[i].to_int().unsafe_to_char() == '/' {
      let segment = String::unsafe_substring(path, start~, end=i)
      if segment == ".." {
        return false
      }
      start = i + 1
    }
    i += 1
  }
  true
}

///|
fn extract_zip_to_dir(data : Bytes, package_dir : String) -> Unit raise {
  ensure_dir(package_dir)
  for entry in read_zip_entries(data) {
    if !zip_entry_path_is_safe(entry.path) {
      package_download_error("unsafe zip entry path: \{entry.path}")
    }
    let target = join_path(package_dir, entry.path)
    if entry.is_dir {
      ensure_dir(target)
    } else {
      ensure_dir(parent_dir(target))
      @fs.write_bytes_to_file(target, entry.content)
    }
  }
}

///|
async fn download_package_uri_to_cache(uri : String) -> Bool {
  try {
    let parts = match package_cache_parts(uri, "") {
      Some(parts) => parts
      None => {
        package_download_error("malformed package URI: \{uri}")
        return false
      }
    }
    let metadata_url = match package_metadata_url_from_uri(uri) {
      Some(url) => url
      None => {
        package_download_error("malformed package URI: \{uri}")
        return false
      }
    }
    let metadata = match fetch_uri(metadata_url) {
      Some(body) => body
      None => {
        package_download_error("metadata fetch failed: \{metadata_url}")
        return false
      }
    }
    let zip_url = match extract_package_zip_url(metadata) {
      Some(url) => url
      None => {
        package_download_error("metadata is missing packageZipUrl")
        return false
      }
    }
    let expected_sha256 = match extract_package_zip_sha256(metadata) {
      Some(value) => value
      None => {
        package_download_error("metadata is missing packageZipChecksums.sha256")
        return false
      }
    }
    let zip_bytes = match fetch_uri_bytes(zip_url) {
      Some(bytes) => bytes
      None => {
        package_download_error("zip fetch failed: \{zip_url}")
        return false
      }
    }
    let actual_sha256 = @crypto.bytes_to_hex_string(@crypto.sha256(zip_bytes))
    if actual_sha256 != expected_sha256 {
      package_download_error(
        "package zip sha256 mismatch: expected \{expected_sha256}, got \{actual_sha256}",
      )
    }
    let roots = package_cache_roots()
    let cache_root = roots[0]
    let package_root = join_path(cache_root, parts.package_root_relative)
    let package_dir = join_path(cache_root, parts.package_dir_relative)
    ensure_dir(package_root)
    extract_zip_to_dir(zip_bytes, package_dir)
    @fs.write_string_to_file(
      join_path(cache_root, parts.metadata_relative),
      metadata,
    )
    true
  } catch {
    error => {
      println("package download failed for \{uri}: \{error}")
      false
    }
  }
}

///|
async fn resolve_or_download_package_uri_to_cache(
  uri : String,
  rest : String,
) -> String? {
  match resolve_package_uri_to_cache(uri, rest) {
    Some(path) => Some(path)
    None =>
      if download_package_uri_to_cache(uri) {
        resolve_package_uri_to_cache(uri, rest)
      } else {
        None
      }
  }
}