///|
/// An install receipt records what moon-binstall placed at a destination:
/// the coordinate and source it came from, the package/binary identity, the
/// selected tag, and two digests kept strictly separate (issue 0001).
/// `asset_digest` hashes the downloaded release asset (often a zip/tar
/// archive) and is compared to the next candidate's declared digest so an
/// identical release skips without downloading. `binary_digest` hashes the
/// extracted executable actually written and is compared to the file's
/// current content — a receipt alone never trusts a file.
pub struct InstallReceipt {
  coordinate : String
  source : String
  repository : String
  package_key : String
  binary : String
  target : String
  tag : String
  asset_digest : String
  binary_digest : String
  destination : String
}

///|
/// Receipts live beside the managed binaries in a dot directory so PATH
/// scans never pick them up.
pub fn receipt_path(bin_dir : String, binary : String) -> String {
  bin_dir + "/.moon-binstall-receipts/" + binary + ".json"
}

///|
/// Refuse to trust receipt paths that traverse a symlinked metadata directory
/// or use a non-regular receipt entry. This is checked again immediately
/// before a rename, not just during the install decision.
async fn validate_receipt_location(path : String) -> Unit {
  let dir = parent_dir(path)
  match path_kind_no_follow(dir) {
    Some(Directory) | None => ()
    Some(_) =>
      abort_install(
        Filesystem, "check the receipt directory", dir,
        "receipt directory is not a real directory (symlinks are not allowed)",
        action="remove the unsafe receipt directory and retry",
      )
  }
  match path_kind_no_follow(path) {
    Some(Regular) | None => ()
    Some(_) =>
      abort_install(
        Filesystem, "check the receipt file", path,
        "receipt entry is not a regular file (symlinks are not allowed)",
        action="remove the unsafe receipt entry and retry",
      )
  }
}

///|
/// Create a private receipt directory and write a new regular file before
/// atomically renaming it. Do not open an existing receipt for truncation:
/// that would follow a malicious symlink and overwrite its target.
async fn write_receipt(receipt : InstallReceipt) -> Unit {
  let path = receipt_path(
    parent_dir(receipt.destination),
    receipt.binary,
  )
  validate_receipt_location(path)
  let dir = parent_dir(path)
  match path_kind_no_follow(dir) {
    Some(Directory) => ()
    None => @fs.mkdir(dir, permission=0o700, allow_exist=false)
    Some(_) =>
      abort_install(
        Filesystem, "check the receipt directory", dir,
        "receipt directory must not be a symlink or non-directory",
      )
  }
  validate_receipt_location(path)
  let json : Json = {
    "version": 1,
    "coordinate": receipt.coordinate,
    "source": receipt.source,
    "repository": receipt.repository,
    "package": receipt.package_key,
    "binary": receipt.binary,
    "target": receipt.target,
    "tag": receipt.tag,
    "asset_digest": receipt.asset_digest,
    "binary_digest": receipt.binary_digest,
    "destination": receipt.destination,
  }
  let staging_dir = create_staging_directory_at(dir, 0, "receipt")
  defer (@fs.rmdir(staging_dir, recursive=true) catch { _ => () })
  let staging = staging_dir + "/" + receipt.binary + ".json"
  @fs.write_file(
    staging,
    json.stringify() + "\n",
    create_mode=CreateNew,
    permission=0o600,
  )
  validate_receipt_location(path)
  @fs.rename(staging, path, replace=true)
}

///|
/// Directory part of an absolute path, without the trailing element.
fn parent_dir(path : String) -> String {
  let mut index = path.length() - 1
  while index > 0 && path[index] != '/' {
    index = index - 1
  }
  if index == 0 {
    "/"
  } else {
    path[:index].to_owned()
  }
}

///|
/// Read a receipt for the binary at `destination`; None when absent or
/// malformed. Malformed receipts are treated like missing ones so a bad
/// file can never silently authorize an update.
async fn read_receipt_file(path : String) -> InstallReceipt? {
  // Do not read receipts through symlinks even if the link target is valid JSON.
  guard path_kind_no_follow(parent_dir(path)) is Some(Directory) else {
    return None
  }
  guard path_kind_no_follow(path) is Some(Regular) else {
    return None
  }
  let text = @fs.read_file(path).text() catch { _ => return None }
  let json = @json.parse(text) catch { _ => return None }
  match json {
    {
      "coordinate": String(coordinate),
      "source": String(source),
      "repository": String(repository),
      "package": String(package_key),
      "binary": String(binary),
      "target": String(target),
      "tag": String(tag),
      "asset_digest": String(asset_digest),
      "binary_digest": String(binary_digest),
      "destination": String(destination),
      ..
    } =>
      Some(
        InstallReceipt::{
          coordinate,
          source,
          repository,
          package_key,
          binary,
          target,
          tag,
          asset_digest,
          binary_digest,
          destination,
        },
      )
    _ => None
  }
}

///|
/// Parse a semver-ish version: optional leading 'v', exactly three numeric
/// components. Anything else is incomparable — never guessed.
fn parse_semver(value : String) -> (Int, Int, Int)? {
  let body = if value.has_prefix("v") || value.has_prefix("V") {
    value[1:].to_owned()
  } else {
    value
  }
  let parts = body.split(".").collect()
  guard parts.length() == 3 else { return None }
  let numbers : Array[Int] = []
  for part in parts {
    if part == "" || part.length() > 9 {
      return None
    }
    let mut value = 0
    for c in part {
      guard c >= '0' && c <= '9' else { return None }
      value = value * 10 + (c.to_int() - '0'.to_int())
    }
    numbers.push(value)
  }
  Some((numbers[0], numbers[1], numbers[2]))
}

///|
/// Strict semver ordering: -1 when a < b, 0 when equal, 1 when a > b, None
/// when either side cannot be parsed. Used for update decisions only.
pub fn semver_compare(a : String, b : String) -> Int? {
  match (parse_semver(a), parse_semver(b)) {
    (Some((amaj, amin, apat)), Some((bmaj, bmin, bpat))) => {
      if amaj != bmaj {
        return Some(if amaj < bmaj { -1 } else { 1 })
      }
      if amin != bmin {
        return Some(if amin < bmin { -1 } else { 1 })
      }
      if apat != bpat {
        return Some(if apat < bpat { -1 } else { 1 })
      }
      Some(0)
    }
    _ => None
  }
}