// Native CLI adapter. Thin by design: all verification logic lives in the
// pure library packages; this file only parses arguments, performs IO
// (E5xxx territory), injects file bytes, and renders reports.
//
// Exit codes (frozen): 0 verification passed, 1 verification failed,
// 2 usage or IO error.

///|
/// Kept in sync with moon.mod at release time.
const CLI_VERSION : String = "0.5.1"

///|
let usage_text : String =
  #|moon-evidence - trusted evidence pack verification
  #|
  #|Usage:
  #|  moon-evidence verify [--json] [--incremental ]
  #|                       [--expected-manifest-digest ]
  #|                        [ ...]
  #|  moon-evidence explain  [ ...]
  #|  moon-evidence inspect [--json] 
  #|  moon-evidence pack  [-o ] [--subject-id ]
  #|                        [--subject-type ]
  #|                        [--algorithm sha256|sha512]
  #|                        [--version-id ] [--version-parent ]
  #|                        [--json]
  #|  moon-evidence create  --subject-id  [--subject-type ]
  #|                          [--algorithm sha256|sha512]
  #|                          [--version-id ] [--version-parent ]
  #|                          [-o ] [--json]
  #|  moon-evidence --version
  #|  moon-evidence --help
  #|
  #|Commands:
  #|  verify    Verify evidence packs; --json emits canonical JSON reports
  #|  explain   Verify and print human-readable findings reports
  #|  inspect   Print canonical manifest metadata for automation/anchoring
  #|  pack      Copy a source directory into a new, self-contained pack
  #|  seal      Alias for pack
  #|  create    Create a manifest from files in a directory
  #|
  #|Verify options:
  #|  --json               Emit canonical JSON instead of human-readable text
  #|  --incremental   Cache file digests in  for fast re-verification;
  #|                       unchanged files are skipped on subsequent runs
  #|  --expected-manifest-digest 
  #|                       Compare the canonical manifest with an external
  #|                       sha256:/sha512: anchor (E2004 on mismatch)
  #|
  #|Pack options:
  #|  --subject-id        Subject identifier (default: source directory name)
  #|  --subject-type    Subject type (default: generic)
  #|  --algorithm       Hash algorithm: sha256 or sha512 (default: sha256)
  #|  --version-id        Version identifier (default: v1)
  #|  --version-parent    Parent version identifier (default: none)
  #|  -o, --output       New pack directory (default: -evidence-pack)
  #|  --json                   Emit one versioned JSON result object
  #|
  #|Create options:
  #|  --subject-id        Evidence subject identifier (required)
  #|  --subject-type    Subject type (default: generic)
  #|  --algorithm       Hash algorithm: sha256 or sha512 (default: sha256)
  #|  --version-id        Version identifier (default: v1)
  #|  --version-parent  Parent version hash for chaining (default: none)
  #|  -o, --output      Output manifest path (default: /manifest.json)
  #|  --json                  Emit one versioned JSON result object
  #|
  #|Batch mode:
  #|  Pass multiple paths to verify/explain several packs in one invocation.
  #|  Exit code is 0 only if ALL packs pass; 1 if any fails; 2 on usage error.
  #|
  #|Exit codes:
  #|  0  verification passed (all packs)
  #|  1  verification failed (at least one pack)
  #|  2  usage or IO error

///|
fn main {
  @sys.exit(run(@env.args()))
}

// ---------------------------------------------------------------------------
// Argument handling
// ---------------------------------------------------------------------------

///|
/// Drops the runtime-dependent argv prefix (native: program path; js: node
/// path + script path) by scanning for the first recognized token. Returns
/// the empty array when no known command is present.
fn strip_runtime_prefix(raw : Array[String]) -> Array[String] {
  let known = [
    "verify", "explain", "inspect", "pack", "seal", "create", "--version", "-v",
    "--help", "-h", "version", "help",
  ]
  for index in 0.. Int {
  let args = strip_runtime_prefix(raw_args)
  if args.length() == 0 {
    println(usage_text)
    return 2
  }
  match args[0] {
    "--version" | "-v" | "version" => {
      println("moon-evidence \{CLI_VERSION}")
      0
    }
    "--help" | "-h" | "help" => {
      println(usage_text)
      0
    }
    "verify" => run_verify(args, json_allowed=true)
    "explain" => run_verify(args, json_allowed=false)
    "inspect" => run_inspect(args)
    "pack" | "seal" => run_pack(args)
    "create" => run_create(args)
    _ => {
      println(usage_text)
      2
    }
  }
}

// ---------------------------------------------------------------------------
// verify / explain
// ---------------------------------------------------------------------------

///|
fn run_verify(args : Array[String], json_allowed~ : Bool) -> Int {
  let mut json_mode = false
  let mut incremental_dir : String? = None
  let mut expected_manifest_digest : String? = None
  let paths : Array[String] = []
  let mut i = 1
  while i < args.length() {
    let arg = args[i]
    if arg == "--json" && json_allowed {
      json_mode = true
    } else if arg == "--incremental" && i + 1 < args.length() {
      i = i + 1
      incremental_dir = Some(args[i])
    } else if arg == "--expected-manifest-digest" && i + 1 < args.length() {
      i = i + 1
      let value = args[i]
      guard @digest.parse_digest(value) is Some(parsed) &&
        parsed.to_string() == value else {
        println(
          "verify: --expected-manifest-digest must be canonical sha256: or sha512:",
        )
        return 2
      }
      expected_manifest_digest = Some(value)
    } else if !arg.has_prefix("-") {
      paths.push(arg)
    } else {
      println(usage_text)
      return 2
    }
    i = i + 1
  }
  if paths.is_empty() {
    println(usage_text)
    return 2
  }
  if paths.length() == 1 {
    return run_verify_single(
      paths[0],
      json_mode,
      incremental_dir,
      expected_manifest_digest,
    )
  }
  let mut all_ok = true
  let mut pass_count = 0
  let mut fail_count = 0
  for pack_path in paths {
    if paths.length() > 1 {
      println("--- \{pack_path} ---")
    }
    let code = run_verify_single(
      pack_path, json_mode, incremental_dir, expected_manifest_digest,
    )
    if code == 2 {
      return 2
    }
    if code == 0 {
      pass_count = pass_count + 1
    } else {
      fail_count = fail_count + 1
      all_ok = false
    }
  }
  println(
    "\nbatch: \{pass_count} passed, \{fail_count} failed out of \{paths.length()} packs",
  )
  if all_ok {
    0
  } else {
    1
  }
}

///|
fn run_verify_single(
  pack_path : String,
  json_mode : Bool,
  cache_dir : String?,
  expected_manifest_digest : String?,
) -> Int {
  // Resolve pack layout (E5001 when the path itself is absent).
  if !@fs.path_exists(pack_path) {
    println("[E5001] \{pack_path}: path does not exist")
    return 2
  }
  let is_directory = @fs.is_dir(pack_path) catch {
    _ => {
      println("[E5002] \{pack_path}: path metadata read failed")
      return 2
    }
  }
  let (manifest_path, pack_root) = if is_directory {
    ("\{pack_path}/manifest.json", pack_path)
  } else {
    (pack_path, parent_dir(pack_path))
  }
  if !@fs.path_exists(manifest_path) {
    println("[E5001] \{manifest_path}: path does not exist")
    return 2
  }

  // Read the manifest (E5002 when unreadable).
  let manifest_text = @fs.read_file_to_string(manifest_path) catch {
    _ => {
      println("[E5002] \{manifest_path}: file read failed")
      return 2
    }
  }

  // Load listed files per the manifest. A missing payload is evidence-level
  // rejection (E2003 from the pure verifier); an existing path that cannot be
  // read is a command IO failure and exits 2 before a VerifyReport is emitted.
  let files : Map[String, Bytes] = {}
  let mut parsed_manifest : @model.Manifest? = None
  try @model.Manifest::parse(manifest_text) catch {
    // Parse errors are reported by verify_manifest itself.
    _ => ()
  } noraise {
    manifest => {
      parsed_manifest = Some(manifest)
      for entry in manifest.files {
        let file_path = "\{pack_root}/\{entry.path}"
        if @fs.path_exists(file_path) {
          try @fs.read_file_to_bytes(file_path) catch {
            _ => {
              println("[E5002] \{entry.path}: file read failed")
              return 2
            }
          } noraise {
            content => files[entry.path] = content
          }
        }
        // Absent files surface as E2003 from the pure pipeline.
      }
      // Pack-dir mode: also walk files/ so payloads the manifest does not
      // list reach the pure pipeline, which reports them as W1001.
      if is_directory {
        let files_root = "\{pack_root}/files"
        if !@fs.path_exists(files_root) {
          println("[E5001] \{files_root}: payload directory does not exist")
          return 2
        }
        let files_root_is_dir = @fs.is_dir(files_root) catch {
          _ => {
            println("[E5002] \{files_root}: path metadata read failed")
            return 2
          }
        }
        if !files_root_is_dir {
          println("[E5002] \{files_root}: payload path is not a directory")
          return 2
        }
        let listed : Map[String, Bool] = {}
        for entry in manifest.files {
          listed[entry.path] = true
        }
        let on_disk : Array[String] = []
        let collect_state : PackCollectState = {
          hit_depth_cap: false,
          hit_file_cap: false,
          io_error_path: None,
        }
        collect_pack_files(pack_root, "files", on_disk, collect_state)
        match collect_state.io_error_path {
          Some(path) => {
            println("[E5002] \{path}: directory inventory read failed")
            return 2
          }
          None => ()
        }
        if collect_state.hit_depth_cap {
          println(
            "[E5002] \{files_root}: directory recursion depth limit (>=\{max_pack_depth}) reached; verification aborted because the payload inventory is incomplete",
          )
          return 2
        }
        if collect_state.hit_file_cap || on_disk.length() >= max_pack_files {
          println(
            "[E5002] \{files_root}: file inventory limit (>=\{max_pack_files}) reached; verification aborted because the payload inventory is incomplete",
          )
          return 2
        }
        for rel_path in on_disk {
          if listed.get(rel_path) is None {
            try @fs.read_file_to_bytes("\{pack_root}/\{rel_path}") catch {
              _ => {
                println("[E5002] \{rel_path}: file read failed")
                return 2
              }
            } noraise {
              content => files[rel_path] = content
            }
          }
        }
      }
    }
  }

  // Pure verification: incremental when a cache dir is provided, full
  // otherwise. Incremental skips rehashing files whose (path, digest)
  // match a trusted previous run — the Merkle root is always recomputed,
  // but per-file content integrity depends on cache trustworthiness.
  // See verify/incremental.mbt for the trust model.
  let mut files_rehashed = 0
  let mut files_skipped = 0
  let report = match cache_dir {
    Some(dir) => {
      let (previous, cached_digest) = load_incremental_cache(dir)
      let selected_digest = match expected_manifest_digest {
        Some(value) => Some(value)
        None => cached_digest
      }
      let inc = @verify.verify_manifest_incremental(
        manifest_text,
        files,
        previous,
        expected_manifest_digest=selected_digest,
      )
      files_rehashed = inc.files_rehashed
      files_skipped = inc.files_skipped
      inc.report
    }
    None =>
      match expected_manifest_digest {
        Some(value) =>
          @verify.verify_manifest(
            manifest_text,
            files,
            expected_manifest_digest=value,
          )
        None => @verify.verify_manifest(manifest_text, files)
      }
  }

  // Version chain is optional; verify it when the pack ships one.
  let chain_findings : Array[@diag.Finding] = []
  let chain_path = "\{pack_root}/versions/version_chain.json"
  if @fs.path_exists(chain_path) {
    try @fs.read_file_to_string(chain_path) catch {
      _ => {
        println("[E5002] \{chain_path}: file read failed")
        return 2
      }
    } noraise {
      chain_text =>
        chain_findings.append(
          @verify.verify_chain_text(
            chain_text,
            path="versions/version_chain.json",
          ),
        )
    }
  }

  // Merge semantic pipeline and chain findings. Command-level IO failures
  // have already exited 2 and never masquerade as evidence rejection.
  let merged_findings = report.findings + chain_findings
  let merged : @diag.VerifyReport = {
    ok: !merged_findings.iter().any(f => f.severity is Error),
    findings: merged_findings,
    checked: report.checked,
  }

  // Save the incremental cache after a successful verification so the
  // next run can skip unchanged files. The cache stores the manifest's
  // file digests (path → digest) and the manifest's canonical digest
  // (for tamper detection on the next run).
  if cache_dir is Some(dir) && merged.ok {
    save_incremental_cache(dir, manifest_text, parsed_manifest)
  }

  // Output: text mode appends incremental stats; JSON mode stays
  // unchanged to preserve the contract for automated consumers.
  if json_mode {
    println(@diag.to_json(merged))
  } else {
    println(@diag.explain(merged))
    if cache_dir is Some(_) {
      println(
        "incremental: \{files_rehashed} rehashed, \{files_skipped} skipped",
      )
    }
  }
  if merged.ok {
    0
  } else {
    1
  }
}

///|
/// Loads the incremental cache from `/cache.json`. Returns
/// (previous digests, expected manifest digest); both empty/None when
/// the cache file does not exist or is unreadable (first run).
fn load_incremental_cache(cache_dir : String) -> (Map[String, String], String?) {
  let previous : Map[String, String] = {}
  let mut expected_digest : String? = None
  let cache_file = "\{cache_dir}/cache.json"
  if !@fs.path_exists(cache_file) {
    return (previous, expected_digest)
  }
  let cache_text = @fs.read_file_to_string(cache_file) catch {
    _ => return (previous, expected_digest)
  }
  try @json.parse(cache_text) catch {
    _ => return (previous, expected_digest)
  } noraise {
    parsed =>
      match parsed {
        Object(fields) => {
          match fields.get("manifest_digest") {
            Some(String(d)) => expected_digest = Some(d)
            _ => ()
          }
          match fields.get("files") {
            Some(Object(files_map)) =>
              for path in files_map.keys() {
                match files_map.get(path) {
                  Some(String(d)) => previous[path] = d
                  _ => ()
                }
              }
            _ => ()
          }
        }
        _ => ()
      }
  }
  (previous, expected_digest)
}

///|
/// Saves the incremental cache to `/cache.json`. Stores the
/// manifest's canonical digest and each file entry's path → digest so
/// the next run can skip rehashing unchanged files and detect manifest
/// tampering.
fn save_incremental_cache(
  cache_dir : String,
  manifest_text : String,
  parsed_manifest : @model.Manifest?,
) -> Unit {
  // Compute the manifest's canonical digest for tamper detection.
  let canonical = @canonjson.canonicalize(manifest_text) catch {
    _ => manifest_text
  }
  let manifest_digest = @digest.Digest::of_bytes(
    @digest.Sha256,
    @digest.string_to_utf8_bytes(canonical),
  ).to_string()
  // Build the files map from the parsed manifest.
  let files_map : Map[String, Json] = {}
  match parsed_manifest {
    Some(manifest) =>
      for entry in manifest.files {
        files_map[entry.path] = Json::string(entry.digest.to_string())
      }
    None => ()
  }
  let cache_json = Json::object({
    "manifest_digest": Json::string(manifest_digest),
    "files": Json::object(files_map),
  })
  let cache_str = cache_json.stringify()
  // Ensure the cache directory exists, then write.
  if !@fs.path_exists(cache_dir) {
    warn_stderr(
      "warning: incremental cache directory '\{cache_dir}' does not exist; cache not saved. Create it beforehand (e.g. `mkdir -p \{cache_dir}`) to enable incremental verification.",
    )
    return
  }
  @fs.write_string_to_file("\{cache_dir}/cache.json", cache_str) catch {
    _ => ()
  }
}

///|
/// Maximum directory recursion depth for `collect_pack_files`.
///
/// Bounds symlink-to-directory cycles that would otherwise cause infinite
/// recursion. 32 is generous for any legitimate evidence pack (the spec
/// recommends a flat `files/` tree) while still terminating malicious
/// cycles quickly.
let max_pack_depth : Int = 32

///|
/// Maximum total file count `collect_pack_files` will collect from a pack
/// directory tree. Bounds resource exhaustion from "symlink bombs" (a small
/// directory of symlinks pointing to `/dev/zero` or similar) and from
/// pathologically deep trees.
let max_pack_files : Int = 10000

///|
struct CreateCollectState {
  mut hit_depth_cap : Bool
}

///|
/// Tracks whether a verification inventory walk completed. Any set field
/// means the caller must abort with exit 2 rather than issue W1001 from a
/// partial view of the payload tree.
struct PackCollectState {
  mut hit_depth_cap : Bool
  mut hit_file_cap : Bool
  mut io_error_path : String?
}

///|
/// Recursively collects files under `/` as pack-relative
/// paths (`files/...`). Missing or unreadable directories set an explicit
/// failure in `state`; callers reject partial inventories as command IO
/// failures.
///
/// Security mitigation: the `@fs` package (moonbitlang/x/fs) does not
/// expose `lstat`, `is_symlink`, `read_link`, or any other symlink-
/// detection API - its `is_dir`/`is_file` follow symlinks, so a symlink
/// to a directory is indistinguishable from a real directory and a symlink
/// to a file is indistinguishable from a real file. This means a hostile
/// pack directory could use symlinks to (a) create infinite cycles via
/// symlink-to-dir loops, or (b) pull in arbitrary out-of-tree content via
/// symlink-to-file. Since the underlying platform API cannot be queried
/// for link status, this function applies two runtime mitigations instead:
///
/// 1. **Depth cap** (`max_pack_depth`, 32): recursion stops when the
///    directory nesting exceeds 32 levels, terminating any symlink cycle.
/// 2. **File cap** (`max_pack_files`, 10000): collection stops once 10000
///    files have been accumulated, bounding resource use against symlink
///    bombs.
///
/// These are not TODOs or placeholders - they are the actual security
/// boundary enforced at runtime given the `@fs` API available. Pack
/// creators should still ensure the pack tree is symlink-free before
/// sealing (see SECURITY.md), and consumers should run verification in a
/// chroot/container that bounds the reachable filesystem.
fn collect_pack_files(
  root : String,
  rel_dir : String,
  out : Array[String],
  state : PackCollectState,
  depth? : Int = 0,
) -> Unit {
  if state.hit_depth_cap || state.hit_file_cap || state.io_error_path is Some(_) {
    return
  }
  // Depth cap: terminate symlink-to-directory cycles.
  if depth >= max_pack_depth {
    state.hit_depth_cap = true
    return
  }
  // File cap: bound resource use against symlink bombs.
  if out.length() >= max_pack_files {
    state.hit_file_cap = true
    return
  }
  let full_dir = "\{root}/\{rel_dir}"
  if !@fs.path_exists(full_dir) {
    state.io_error_path = Some(full_dir)
    return
  }
  let entries = @fs.read_dir(full_dir) catch {
    _ => {
      state.io_error_path = Some(full_dir)
      return
    }
  }
  for name in entries {
    // File cap check inside the loop too, so a single huge directory
    // does not blow past the limit before the next recursion.
    if out.length() >= max_pack_files {
      state.hit_file_cap = true
      return
    }
    let rel_path = "\{rel_dir}/\{name}"
    let full_path = "\{root}/\{rel_path}"
    let is_subdir = @fs.is_dir(full_path) catch {
      _ => {
        state.io_error_path = Some(full_path)
        return
      }
    }
    if is_subdir {
      collect_pack_files(root, rel_path, out, state, depth=depth + 1)
    } else {
      out.push(rel_path)
    }
    if state.hit_depth_cap ||
      state.hit_file_cap ||
      state.io_error_path is Some(_) {
      return
    }
  }
}

///|
/// Recursively collects files under `/` as input-relative
/// paths. Missing or unreadable directories yield nothing.
///
/// Security mitigation: mirroring `collect_pack_files`, this function
/// applies the same depth and file caps to bound symlink cycles and
/// resource exhaustion.
fn collect_create_files(
  root : String,
  rel_dir : String,
  out : Array[String],
  state : CreateCollectState,
  depth? : Int = 0,
) -> Unit {
  // Depth cap: terminate symlink-to-directory cycles.
  if depth >= max_pack_depth {
    state.hit_depth_cap = true
    return
  }
  // File cap: bound resource use against symlink bombs.
  if out.length() >= max_pack_files {
    return
  }
  let full_dir = if rel_dir.is_empty() { root } else { "\{root}/\{rel_dir}" }
  if !@fs.path_exists(full_dir) {
    return
  }
  let entries = @fs.read_dir(full_dir) catch { _ => return }
  for name in entries {
    // File cap check inside the loop too, so a single huge directory
    // does not blow past the limit before the next recursion.
    if out.length() >= max_pack_files {
      return
    }
    let rel_path = if rel_dir.is_empty() { name } else { "\{rel_dir}/\{name}" }
    let is_subdir = @fs.is_dir("\{root}/\{rel_path}") catch { _ => false }
    if is_subdir {
      collect_create_files(root, rel_path, out, state, depth=depth + 1)
    } else {
      out.push(rel_path)
    }
  }
}

///|
/// Parent directory of a file path, handling both separators; `.` when the
/// path has no directory component.
fn parent_dir(path : String) -> String {
  let mut cut = -1
  for index in 0.. String {
  let mut end = path.length()
  while end > 0 {
    let unit = path[end - 1].to_int()
    if unit == 0x2f || unit == 0x5c {
      end = end - 1
    } else {
      break
    }
  }
  if end == 0 {
    return "evidence"
  }
  let mut cut = -1
  for index in 0.. Unit {
  if json_mode {
    println(
      Json::object({
        "schema": Json::string("moon-evidence-cli-error/v1"),
        "ok": Json::boolean(false),
        "error": Json::object({
          "code": Json::string(code),
          "path": Json::string(path),
          "message": Json::string(message),
        }),
      }).stringify(),
    )
  } else if path.is_empty() {
    println("[\{code}] \{message}")
  } else {
    println("[\{code}] \{path}: \{message}")
  }
}

///|
/// Builds the stable metadata envelope consumed by ledger adapters.
fn manifest_result_json(
  schema : String,
  pack_path : String,
  manifest_path : String,
  manifest : @model.Manifest,
  manifest_digest : String,
) -> String {
  let merkle_root = match manifest.merkle_root {
    Some(root) => Json::string(root.to_string())
    None => Json::null()
  }
  let version_parent = match manifest.version.parent {
    Some(parent) => Json::string(parent)
    None => Json::null()
  }
  Json::object({
    "schema": Json::string(schema),
    "ok": Json::boolean(true),
    "pack_path": Json::string(pack_path),
    "manifest_path": Json::string(manifest_path),
    "manifest_digest": Json::string(manifest_digest),
    "merkle_root": merkle_root,
    "algorithm": Json::string(manifest.algorithm.label()),
    "files_total": Json::number(manifest.files.length().to_double()),
    "subject": Json::object({
      "id": Json::string(manifest.subject.id),
      "type": Json::string(manifest.subject.kind),
    }),
    "version": Json::object({
      "id": Json::string(manifest.version.id),
      "parent": version_parent,
    }),
  }).stringify()
}

///|
fn canonical_manifest_digest(
  manifest_text : String,
  manifest : @model.Manifest,
) -> String raise @canonjson.CanonError {
  let canonical = @canonjson.canonicalize(manifest_text)
  @digest.Digest::of_bytes(
    manifest.algorithm,
    @digest.string_to_utf8_bytes(canonical),
  ).to_string()
}

// ---------------------------------------------------------------------------
// inspect
// ---------------------------------------------------------------------------

///|
fn run_inspect(args : Array[String]) -> Int {
  let json_mode = args.contains("--json")
  let mut input_path : String? = None
  let mut i = 1
  while i < args.length() {
    let arg = args[i]
    if arg == "--json" {
      ()
    } else if !arg.has_prefix("-") && input_path is None {
      input_path = Some(arg)
    } else {
      emit_command_error(json_mode, "E5002", "inspect", "invalid arguments")
      return 2
    }
    i = i + 1
  }
  guard input_path is Some(path) else {
    emit_command_error(json_mode, "E5002", "inspect", "path required")
    return 2
  }
  if !@fs.path_exists(path) {
    emit_command_error(json_mode, "E5001", path, "path does not exist")
    return 2
  }
  let is_directory = @fs.is_dir(path) catch { _ => false }
  let (manifest_path, pack_path) = if is_directory {
    ("\{path}/manifest.json", path)
  } else {
    (path, parent_dir(path))
  }
  if !@fs.path_exists(manifest_path) {
    emit_command_error(json_mode, "E5001", manifest_path, "path does not exist")
    return 2
  }
  let manifest_text = @fs.read_file_to_string(manifest_path) catch {
    _ => {
      emit_command_error(json_mode, "E5002", manifest_path, "file read failed")
      return 2
    }
  }
  let manifest = @model.Manifest::parse(manifest_text) catch {
    error => {
      emit_command_error(
        json_mode,
        error.error_code(),
        error.field_path(),
        error.detail(),
      )
      return 2
    }
  }
  let manifest_digest = canonical_manifest_digest(manifest_text, manifest) catch {
    error => {
      emit_command_error(
        json_mode,
        "E1004",
        manifest_path,
        "canonicalization failed: \{error}",
      )
      return 2
    }
  }
  if json_mode {
    println(
      manifest_result_json(
        "moon-evidence-inspect/v1", pack_path, manifest_path, manifest, manifest_digest,
      ),
    )
  } else {
    let merkle = match manifest.merkle_root {
      Some(root) => root.to_string()
      None => "none"
    }
    println("manifest: \{manifest_path}")
    println("manifest digest: \{manifest_digest}")
    println("merkle root: \{merkle}")
    println(
      "subject: \{manifest.subject.kind}/\{manifest.subject.id}; version: \{manifest.version.id}; files: \{manifest.files.length()}",
    )
  }
  0
}

///|
/// Creates each missing path component. `moonbitlang/x/fs.create_dir` is
/// recursive on JS but single-level on native, so the CLI supplies the
/// portable recursion itself.
fn ensure_directory(path : String) -> Bool {
  if path.is_empty() || path == "." {
    return true
  }
  if @fs.path_exists(path) {
    return @fs.is_dir(path) catch { _ => false }
  }
  let parent = parent_dir(path)
  if parent != path && parent != "." && !ensure_directory(parent) {
    return false
  }
  @fs.create_dir(path) catch {
    _ => return false
  }
  true
}

///|
/// Best-effort rollback for a directory created by `pack`. Existing output is
/// rejected before this function can be reached, so rollback never owns user
/// data that predated the command.
fn remove_created_tree(path : String) -> Unit {
  if !@fs.path_exists(path) {
    return
  }
  let is_directory = @fs.is_dir(path) catch { _ => false }
  if !is_directory {
    @fs.remove_file(path) catch {
      _ => ()
    }
    return
  }
  let entries = @fs.read_dir(path) catch { _ => [] }
  for name in entries {
    remove_created_tree("\{path}/\{name}")
  }
  @fs.remove_dir(path) catch {
    _ => ()
  }
}

// ---------------------------------------------------------------------------
// pack / seal
// ---------------------------------------------------------------------------

///|
fn run_pack(args : Array[String]) -> Int {
  let json_mode = args.contains("--json")
  let mut source_path : String? = None
  let mut subject_id : String? = None
  let mut subject_type : String? = None
  let mut output_path : String? = None
  let mut algorithm_name : String? = None
  let mut version_id : String? = None
  let mut version_parent : String? = None
  let mut i = 1
  while i < args.length() {
    let arg = args[i]
    if arg == "--json" {
      ()
    } else if arg == "--subject-id" && i + 1 < args.length() {
      i = i + 1
      subject_id = Some(args[i])
    } else if arg == "--subject-type" && i + 1 < args.length() {
      i = i + 1
      subject_type = Some(args[i])
    } else if arg == "--algorithm" && i + 1 < args.length() {
      i = i + 1
      algorithm_name = Some(args[i])
    } else if arg == "--version-id" && i + 1 < args.length() {
      i = i + 1
      version_id = Some(args[i])
    } else if arg == "--version-parent" && i + 1 < args.length() {
      i = i + 1
      version_parent = Some(args[i])
    } else if (arg == "-o" || arg == "--output") && i + 1 < args.length() {
      i = i + 1
      output_path = Some(args[i])
    } else if !arg.has_prefix("-") && source_path is None {
      source_path = Some(arg)
    } else {
      emit_command_error(json_mode, "E5002", "pack", "invalid arguments")
      return 2
    }
    i = i + 1
  }
  guard source_path is Some(source_dir) else {
    emit_command_error(json_mode, "E5002", "pack", "source directory required")
    return 2
  }
  if !@fs.path_exists(source_dir) {
    emit_command_error(json_mode, "E5001", source_dir, "path does not exist")
    return 2
  }
  let source_is_dir = @fs.is_dir(source_dir) catch { _ => false }
  if !source_is_dir {
    emit_command_error(json_mode, "E5002", source_dir, "not a directory")
    return 2
  }
  let source_name = base_name(source_dir)
  let sid = match subject_id {
    Some(value) => value
    None => source_name
  }
  let stype = match subject_type {
    Some(value) => value
    None => "generic"
  }
  let vid = match version_id {
    Some(value) => value
    None => "v1"
  }
  if sid.is_empty() || stype.is_empty() || vid.is_empty() {
    emit_command_error(
      json_mode, "E5002", "pack", "subject and version fields must not be empty",
    )
    return 2
  }
  let algorithm = match algorithm_name {
    Some(name) =>
      match @digest.normalize_algorithm(name) {
        Some(value) => value
        None => {
          emit_command_error(
            json_mode,
            "E5002",
            "pack",
            "unknown algorithm '\{name}' (supported: sha256, sha512)",
          )
          return 2
        }
      }
    None => @digest.Sha256
  }
  let pack_dir = match output_path {
    Some(value) => value
    None => "\{source_name}-evidence-pack"
  }
  if @fs.path_exists(pack_dir) {
    emit_command_error(
      json_mode, "E5002", pack_dir, "output path already exists; refusing to overwrite",
    )
    return 2
  }

  let file_paths : Array[String] = []
  let collect_state : CreateCollectState = { hit_depth_cap: false }
  collect_create_files(source_dir, "", file_paths, collect_state)
  if collect_state.hit_depth_cap {
    emit_command_error(
      json_mode,
      "E5002",
      source_dir,
      "directory recursion depth limit (>=\{max_pack_depth}) reached",
    )
    return 2
  }
  if file_paths.length() >= max_pack_files {
    emit_command_error(
      json_mode,
      "E5002",
      source_dir,
      "too many files (>=\{max_pack_files})",
    )
    return 2
  }

  let files : Map[String, Bytes] = {}
  for rel_path in file_paths {
    let source_file = "\{source_dir}/\{rel_path}"
    let content = @fs.read_file_to_bytes(source_file) catch {
      _ => {
        emit_command_error(json_mode, "E5002", source_file, "file read failed")
        return 2
      }
    }
    files["files/\{rel_path}"] = content
  }

  let manifest_text = @create.create_manifest(files, {
    subject: { id: sid, kind: stype },
    algorithm,
    version_id: vid,
    version_parent,
  })
  let manifest = @model.Manifest::parse(manifest_text) catch {
    error => {
      emit_command_error(
        json_mode,
        error.error_code(),
        error.field_path(),
        error.detail(),
      )
      return 2
    }
  }
  let manifest_digest = canonical_manifest_digest(manifest_text, manifest) catch {
    error => {
      emit_command_error(
        json_mode,
        "E1004",
        "manifest",
        "canonicalization failed: \{error}",
      )
      return 2
    }
  }

  if !ensure_directory(pack_dir) || !ensure_directory("\{pack_dir}/files") {
    remove_created_tree(pack_dir)
    emit_command_error(
      json_mode, "E5002", pack_dir, "directory creation failed",
    )
    return 2
  }
  for rel_path, content in files {
    let target = "\{pack_dir}/\{rel_path}"
    if !ensure_directory(parent_dir(target)) {
      remove_created_tree(pack_dir)
      emit_command_error(
        json_mode,
        "E5002",
        parent_dir(target),
        "directory creation failed",
      )
      return 2
    }
    @fs.write_bytes_to_file(target, content) catch {
      _ => {
        remove_created_tree(pack_dir)
        emit_command_error(json_mode, "E5002", target, "write failed")
        return 2
      }
    }
  }
  let manifest_path = "\{pack_dir}/manifest.json"
  @fs.write_string_to_file(manifest_path, manifest_text) catch {
    _ => {
      remove_created_tree(pack_dir)
      emit_command_error(json_mode, "E5002", manifest_path, "write failed")
      return 2
    }
  }

  if json_mode {
    println(
      manifest_result_json(
        "moon-evidence-pack-result/v1", pack_dir, manifest_path, manifest, manifest_digest,
      ),
    )
  } else {
    println(
      "packed \{pack_dir} (\{files.length()} files, \{algorithm.label()})",
    )
    println("manifest digest: \{manifest_digest}")
  }
  0
}

// ---------------------------------------------------------------------------
// create
// ---------------------------------------------------------------------------

///|
fn run_create(args : Array[String]) -> Int {
  let json_mode = args.contains("--json")
  let mut dir_path : String? = None
  let mut subject_id : String? = None
  let mut subject_type : String? = None
  let mut output_path : String? = None
  let mut algorithm_name : String? = None
  let mut version_id : String? = None
  let mut version_parent : String? = None
  let mut i = 1
  while i < args.length() {
    let arg = args[i]
    if arg == "--json" {
      ()
    } else if arg == "--subject-id" && i + 1 < args.length() {
      i = i + 1
      subject_id = Some(args[i])
    } else if arg == "--subject-type" && i + 1 < args.length() {
      i = i + 1
      subject_type = Some(args[i])
    } else if arg == "--algorithm" && i + 1 < args.length() {
      i = i + 1
      algorithm_name = Some(args[i])
    } else if arg == "--version-id" && i + 1 < args.length() {
      i = i + 1
      version_id = Some(args[i])
    } else if arg == "--version-parent" && i + 1 < args.length() {
      i = i + 1
      version_parent = Some(args[i])
    } else if (arg == "-o" || arg == "--output") && i + 1 < args.length() {
      i = i + 1
      output_path = Some(args[i])
    } else if !arg.has_prefix("-") && dir_path is None {
      dir_path = Some(arg)
    } else {
      println(usage_text)
      return 2
    }
    i = i + 1
  }
  guard dir_path is Some(pack_dir) else {
    println("create: directory path required")
    return 2
  }
  guard subject_id is Some(sid) else {
    println("create: --subject-id required")
    return 2
  }
  let stype = match subject_type {
    Some(t) => t
    None => "generic"
  }
  let algorithm = match algorithm_name {
    Some(name) =>
      match @digest.normalize_algorithm(name) {
        Some(algo) => algo
        None => {
          println(
            "create: unknown algorithm '\{name}' (supported: sha256, sha512)",
          )
          return 2
        }
      }
    None => @digest.Sha256
  }
  let vid = match version_id {
    Some(v) => v
    None => "v1"
  }
  if !@fs.path_exists(pack_dir) {
    println("[E5001] \{pack_dir}: path does not exist")
    return 2
  }
  let is_dir = @fs.is_dir(pack_dir) catch { _ => false }
  if !is_dir {
    println("create: \{pack_dir} is not a directory")
    return 2
  }
  // Recursively collect files, mirroring verify's collect_pack_files
  // semantics: depth cap + file cap bound symlink cycles and bombs.
  // Unlike verify (which reads from an existing files/ subtree), create
  // reads from the raw input directory and uses on-disk relative paths
  // directly — whatever layout create sees is what verify will check.
  let file_paths : Array[String] = []
  let collect_state : CreateCollectState = { hit_depth_cap: false }
  collect_create_files(pack_dir, "", file_paths, collect_state)
  if collect_state.hit_depth_cap {
    println(
      "[E5002] \{pack_dir}: directory recursion depth limit (>=\{max_pack_depth}) reached, manifest creation aborted to avoid silent truncation",
    )
    return 2
  }
  if file_paths.length() >= max_pack_files {
    println(
      "[E5002] \{pack_dir}: too many files (>=\{max_pack_files}), manifest creation aborted to avoid silent truncation",
    )
    return 2
  }
  let files : Map[String, Bytes] = {}
  for rel_path in file_paths {
    // Skip the manifest file itself — it's the output, not evidence.
    // Only the root-level manifest.json is excluded; a nested file of
    // the same name is legitimate evidence.
    if rel_path == "manifest.json" {
      continue
    }
    let full_path = "\{pack_dir}/\{rel_path}"
    let content = @fs.read_file_to_bytes(full_path) catch {
      _ => {
        println("[E5002] \{full_path}: file read failed")
        return 2
      }
    }
    // Use the on-disk relative path directly so create→verify is
    // symmetric: verify resolves each manifest entry as
    // /, which matches the on-disk layout
    // create saw when collecting.
    files[rel_path] = content
  }
  let manifest_json = @create.create_manifest(files, {
    subject: { id: sid, kind: stype },
    algorithm,
    version_id: vid,
    version_parent,
  })
  let out = match output_path {
    Some(p) => p
    None => "\{pack_dir}/manifest.json"
  }
  @fs.write_string_to_file(out, manifest_json) catch {
    _ => {
      println("[E5002] \{out}: write failed")
      return 2
    }
  }
  if json_mode {
    let manifest = @model.Manifest::parse(manifest_json) catch {
      error => {
        emit_command_error(
          true,
          error.error_code(),
          error.field_path(),
          error.detail(),
        )
        return 2
      }
    }
    let manifest_digest = canonical_manifest_digest(manifest_json, manifest) catch {
      error => {
        emit_command_error(
          true,
          "E1004",
          out,
          "canonicalization failed: \{error}",
        )
        return 2
      }
    }
    println(
      manifest_result_json(
        "moon-evidence-pack-result/v1", pack_dir, out, manifest, manifest_digest,
      ),
    )
  } else {
    println("created \{out} (\{files.length()} files, \{algorithm.label()})")
  }
  0
}