// Unpack & verify logic: read a .appkg and extract it to a destination dir.

///|
/// Locate the archive inside an open file.
///
/// Returns the offset of the archive within the file and its manifest.
/// A plain package holds the archive at offset 0; an executable with an
/// appended archive holds it at the tail.
async fn locate_archive(file : @fs.File) -> (Int64, Manifest) {
  let total = file.size()
  if total < 24 {
    fail("not a LunaPack file (too small)")
  }
  let footer = file.read_exactly_at(24, position=total - 24)
  let mb = magic_bytes()
  let mut magic_ok = true
  let mut i = 0
  while i < 8 {
    if footer.at((i : Int32)) != mb.at((i : Int32)) {
      magic_ok = false
    }
    i = i + 1
  }
  if magic_ok {

  } else {
    fail("not a LunaPack file (bad magic)")
  }
  let moff = le8_to_i64(footer, 8)
  let mlen = le8_to_i64(footer, 16)
  // The archive is [magic][payload][manifest][footer], so its total length is
  // manifest_offset + manifest_length + footer_size.
  let archive_len = moff + mlen + 24
  let base = total - archive_len
  if base < 0 {
    fail("not a LunaPack file (bad magic)")
  }
  // The archive must start with the magic as well.
  let head = file.read_exactly_at(8, position=base)
  let mut head_ok = true
  i = 0
  while i < 8 {
    if head.at((i : Int32)) != mb.at((i : Int32)) {
      head_ok = false
    }
    i = i + 1
  }
  if head_ok {

  } else {
    fail("not a LunaPack file (bad magic)")
  }
  let mbytes = file.read_exactly_at(mlen.to_int(), position=base + moff)
  (base, json_to_manifest(utf8_bytes_to_string(mbytes)))
}

///|
/// Locate the archive inside `path`, when there is one.
///
/// Returns the offset of the archive and its manifest, or None when the file
/// carries no LunaPack archive.
pub async fn probe_archive(path : String) -> (Int64, Manifest)? {
  let file = @fs.open(path, mode=@fs.Mode::ReadOnly)
  defer file.close()
  try {
    let (base, m) = locate_archive(file)
    Some((base, m))
  } catch {
    _ => None
  }
}

///|
/// Read the manifest of a package file.
pub async fn read_package_manifest(pkg : String) -> Manifest {
  let file = @fs.open(pkg, mode=@fs.Mode::ReadOnly)
  defer file.close()
  let (_, m) = locate_archive(file)
  m
}

///|
/// Fetch and (if needed) decompress the payload of one entry.
async fn entry_payload(file : @fs.File, e : Entry, base : Int64) -> Bytes {
  let stored = file.read_exactly_at(
    e.stored_size.to_int(),
    position=base + e.offset,
  )
  if e.codec == "gzip" {
    gzip_decompress(stored)
  } else {
    stored
  }
}

///|
/// Write every entry of `manifest` into `dest`, without printing anything.
async fn write_entries(
  file : @fs.File,
  manifest : Manifest,
  base : Int64,
  dest : String,
  host : String,
) -> Unit {
  for e in manifest.entries {
    let data = entry_payload(file, e, base)
    if sha256_hex(data) != e.checksum {
      fail("checksum mismatch for " + e.path)
    }
    if data.length().to_int64() != e.size {
      fail("size mismatch for " + e.path)
    }
    let out_path = dest + "/" + e.path
    mkdir_parent(dest, e.path)
    @fs.write_file(out_path, data, permission=e.mode)
    // `write_file`'s permission is subject to the umask, so set it explicitly.
    set_permissions(host, out_path, e.mode)
  }
}

///|
/// Extract the payload of `pkg` into `dest` silently.
///
/// Used by the self-extracting launcher, which does not want to list every
/// file it writes.
pub async fn extract_to(pkg : String, dest : String) -> Unit {
  let dest = norm_sep(dest)
  @fs.mkdir(dest, recursive=true, allow_exist=true)
  let file = @fs.open(pkg, mode=@fs.Mode::ReadOnly)
  defer file.close()
  let (base, manifest) = locate_archive(file)
  write_entries(file, manifest, base, dest, host_platform())
}

///|
/// Extract a package into `dest`.
///
/// `run_scripts` controls whether the embedded pre_install / post_install
/// scripts are executed.
pub async fn unpack(pkg : String, dest : String, run_scripts : Bool) -> Unit {
  let dest = norm_sep(dest)
  @fs.mkdir(dest, recursive=true, allow_exist=true)
  let file = @fs.open(pkg, mode=@fs.Mode::ReadOnly)
  defer file.close()
  let (base, manifest) = locate_archive(file)
  let host = host_platform()
  // pre_install runs before the payload is written.
  if run_scripts {
    match manifest.pre_install {
      Some(sc) =>
        if script_applies(sc.platform, host) {
          println("running pre_install script (" + sc.platform + ")...")
          let st = run_install_script(host, "pre_install", sc, dest)
          if st != 0 {
            fail("pre_install script failed with exit code " + st.to_string())
          }
        } else {
          println("skipping pre_install script for " + sc.platform)
        }
      None => ()
    }
  }
  for e in manifest.entries {
    let data = entry_payload(file, e, base)
    if sha256_hex(data) != e.checksum {
      fail("checksum mismatch for " + e.path)
    }
    if data.length().to_int64() != e.size {
      fail("size mismatch for " + e.path)
    }
    let out_path = dest + "/" + e.path
    mkdir_parent(dest, e.path)
    @fs.write_file(out_path, data, permission=e.mode)
    // `write_file`'s permission is subject to the umask, so set it explicitly.
    set_permissions(host, out_path, e.mode)
    println("  " + e.path)
  }
  // post_install runs once every file is in place.
  if run_scripts {
    match manifest.post_install {
      Some(sc) =>
        if script_applies(sc.platform, host) {
          println("running post_install script (" + sc.platform + ")...")
          let st = run_install_script(host, "post_install", sc, dest)
          if st != 0 {
            fail("post_install script failed with exit code " + st.to_string())
          }
        } else {
          println("skipping post_install script for " + sc.platform)
        }
      None => ()
    }
  }
  println(
    "unpacked " + manifest.entries.length().to_string() + " files -> " + dest,
  )
}

///|
/// Check every entry of a package: magic, sizes and SHA-256 digests.
pub async fn verify(pkg : String) -> Bool {
  let file = @fs.open(pkg, mode=@fs.Mode::ReadOnly)
  defer file.close()
  let (base, manifest) = locate_archive(file)
  for e in manifest.entries {
    let data = entry_payload(file, e, base)
    if sha256_hex(data) != e.checksum {
      println("verify: checksum mismatch for " + e.path)
      return false
    }
    if data.length().to_int64() != e.size {
      println("verify: size mismatch for " + e.path)
      return false
    }
  }
  println("verify: OK (" + manifest.entries.length().to_string() + " files)")
  true
}

///|
/// Create every missing parent directory of `rel` under `dest`.
async fn mkdir_parent(dest : String, rel : String) -> Unit {
  let mut i = rel.length() - 1
  while i >= 0 {
    if rel[i] == '/' {
      break
    }
    i = i - 1
  }
  if i > 0 {
    let dir_rel = rel[:i].to_owned()
    let d = dest + "/" + dir_rel
    @fs.mkdir(d, recursive=true, allow_exist=true)
  }
}