// 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
  }
}

///|
/// Read the file at `path` back and confirm that its MD5 fingerprint is
/// still `expected`.
///
/// The payload is checked against the SHA-256 checksum while it is written,
/// but that only proves the bytes in the archive are intact. Reading the file
/// back from the disk proves that what landed on the disk is the same as what
/// went in, which is what the user asked for.
///
/// `expected` may be empty, in which case the file comes from a package that
/// was written before the MD5 fingerprint was stored.
async fn verify_written_file(path : String, expected : String) -> Unit {
  if expected == "" {
    return
  }
  let actual = md5_hex(@fs.read_binary(path))
  if actual != expected {
    fail(
      "md5 mismatch for " + path + ": expected " + expected + " got " + actual,
    )
  }
}

///|
/// 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_binary(out_path, data, permission=e.mode)
    // `write_binary`'s permission is subject to the umask, so set it explicitly.
    set_permissions(host, out_path, e.mode)
    // Read the file back and confirm that the fingerprint is unchanged.
    verify_written_file(out_path, e.md5)
  }
}

///|
/// 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())
}

///|
/// Re-read every extracted file and confirm that its MD5 fingerprint is
/// still the one that went into the package.
///
/// The payload is checked against the SHA-256 checksum while it is written,
/// but that only proves the bytes in the archive are intact. Reading the
/// files back from the disk proves that what landed on the disk is the same
/// as what went in, which is what the user asked for.
///
/// Returns the number of files whose fingerprint did not match.
async fn verify_extracted_md5(manifest : Manifest, dest : String) -> Int32 {
  let mut mismatches = 0
  let mut checked = 0
  for e in manifest.entries {
    let out_path = dest + "/" + e.path
    if !@fs.exists(out_path) {
      println("  md5 MISMATCH  " + e.path + "  (file is missing)")
      mismatches = mismatches + 1
      continue
    }
    let actual = md5_hex(@fs.read_binary(out_path))
    checked = checked + 1
    if e.md5 != "" && actual != e.md5 {
      println(
        "  md5 MISMATCH  " + e.path + "  expected " + e.md5 + " got " + actual,
      )
      mismatches = mismatches + 1
    } else {
      println("  md5  " + actual + "  " + e.path)
    }
  }
  println(
    "verified " +
    checked.to_string() +
    " files, " +
    mismatches.to_string() +
    " mismatches",
  )
  mismatches
}

///|
/// 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_binary(out_path, data, permission=e.mode)
    // `write_binary`'s permission is subject to the umask, so set it explicitly.
    set_permissions(host, out_path, e.mode)
    println("  " + e.path)
    // Read the file back and confirm that the fingerprint is unchanged.
    verify_written_file(out_path, e.md5)
  }
  // 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,
  )
  // Read the extracted files back and confirm that every MD5 fingerprint is
  // still the one that went into the package.
  let mismatches = verify_extracted_md5(manifest, dest)
  if mismatches > 0 {
    fail(
      "md5 mismatch for " + mismatches.to_string() + " file(s) after unpacking",
    )
  }
}

///|
/// Check the files in `dir` against the manifest of `pkg`.
///
/// This audits what is already on the disk, which is what the user asked
/// for: the files that were extracted must still carry the fingerprint they
/// had in the package. It works on a folder that was filled by `unpack` and
/// on the cache folder of a self-extracting bundle.
///
/// Returns false when a file is missing or its fingerprint changed.
pub async fn verify_extracted_dir(pkg : String, dir : String) -> Bool {
  let file = @fs.open(pkg, mode=@fs.Mode::ReadOnly)
  defer file.close()
  let (_base, manifest) = locate_archive(file)
  let dir = norm_sep(dir)
  let mut ok = true
  let mut checked = 0
  for e in manifest.entries {
    let out_path = dir + "/" + e.path
    if !@fs.exists(out_path) {
      println("verify: missing " + e.path)
      ok = false
      continue
    }
    let actual = md5_hex(@fs.read_binary(out_path))
    checked = checked + 1
    if e.md5 != "" && actual != e.md5 {
      println(
        "verify: md5 mismatch for " +
        e.path +
        ": expected " +
        e.md5 +
        " got " +
        actual,
      )
      ok = false
    }
  }
  if ok {
    println("verify: OK (" + checked.to_string() + " files in " + dir + ")")
  } else {
    println("verify: FAILED (" + checked.to_string() + " files checked)")
  }
  ok
}

///|
/// Check every entry of a package: magic, sizes, SHA-256 and MD5 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 e.md5 != "" && md5_hex(data) != e.md5 {
      println("verify: md5 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)
  }
}