// `moui package  ` — produce distributable artifacts:
// Windows portable folders with `run.cmd`, macOS `.app` bundles, and web
// bundles. Ports the shell helpers (`package_windows_app_msvc.ps1`,
// `package-macos-app.sh`, `package-web-app.mjs`) into the CLI. Invoked
// without positional arguments, `moui package` keeps the legacy release
// planner behavior (`package_report.mbt`).

///|
priv struct ParsedPackageBuild {
  help : Bool
  json : Bool
  platform : String
  app : String
  options : PackageBuildOptions
}

///|
priv struct PackageBuildOptions {
  output_dir : String?
  name : String?
  version : String
  build_number : String
  bundle_id : String?
  icon : String?
  renderer : String
  release : Bool
  allow_cross : Bool
  no_build : Bool
  dry_run : Bool
}

///|
fn package_build_usage() -> String {
  (
    #|Usage: moui package   [options]
    #|
    #|Produce a distributable artifact for a desktop or web target:
    #|windows -> portable folder with run.cmd, macos -> .app bundle,
    #|linux -> native bundle with run.sh and .desktop, web -> static web bundle.
    #|
    #|Arguments:
    #|  platform     Target platform: macos, windows, linux, or web.
    #|  app          Project directory holding the entrypoint packages (or
    #|               the entrypoint package itself).
    #|
    #|Options:
    #|  --output DIR        Distribution root. Defaults to dist/.
    #|  --name NAME         App display name. Defaults from the project id.
    #|  --version VERSION   Version recorded in the manifest. Defaults to 0.1.0.
    #|  --build-number N    Build number recorded in the manifest. Defaults to 1.
    #|  --bundle-id ID      macOS bundle identifier. Defaults to the project
    #|                      bundle id, then dev.wzzc.moui.app.
    #|  --icon PATH         macOS .icns icon copied into Contents/Resources.
    #|  --renderer NAME     Entrypoint override (auto / skia / wgpu / sun).
    #|  --release           Build a release artifact (default).
    #|  --debug             Build a debug artifact for package inspection.
    #|  --allow-cross       Skip the host/target guard (advanced).
    #|  --no-build          Package the existing build output without rebuilding.
    #|  --dry-run           Print the planned layout and manifest without writing.
    #|  --json              Emit machine-readable output.
    #|  -h, --help          Show this help.
  )
}

///|
/// Route to the real packager when the first positional argument names a
/// supported platform; otherwise the legacy release planner runs.
fn package_build_requested(args : Array[String]) -> Bool {
  match args.get(0) {
    Some(first) =>
      !first.has_prefix("-") &&
      (
        desktop_platform_supported(first) ||
        first == "android" ||
        first == "ios" ||
        first == "harmonyos"
      )
    None => false
  }
}

///|
fn parse_package_build_args(
  args : Array[String],
  cwd : String,
) -> Result[ParsedPackageBuild, String] {
  let help = Ref(false)
  let json = Ref(false)
  let output_dir : Ref[String?] = Ref(None)
  let name : Ref[String?] = Ref(None)
  let version : Ref[String?] = Ref(None)
  let build_number : Ref[String?] = Ref(None)
  let bundle_id : Ref[String?] = Ref(None)
  let icon : Ref[String?] = Ref(None)
  let renderer : Ref[String?] = Ref(None)
  let no_build = Ref(false)
  let dry_run = Ref(false)
  let release = Ref(true)
  let allow_cross = Ref(false)
  let positional : Array[String] = []
  let mut index = 0
  while index < args.length() {
    let argument = args[index]
    let (flag, inline) = option_parts(argument)
    match flag {
      "--json" =>
        match flag_without_value(inline, flag) {
          Ok(_) => json.val = true
          Err(message) => return Err(message)
        }
      "-h" | "--help" =>
        match flag_without_value(inline, flag) {
          Ok(_) => help.val = true
          Err(message) => return Err(message)
        }
      "--no-build" | "--dry-run" =>
        match flag_without_value(inline, flag) {
          Ok(_) =>
            match flag {
              "--no-build" => no_build.val = true
              "--dry-run" => dry_run.val = true
              _ => ()
            }
          Err(message) => return Err(message)
        }
      "--release" | "--debug" =>
        match flag_without_value(inline, flag) {
          Ok(_) =>
            match flag {
              "--release" => release.val = true
              "--debug" => release.val = false
              _ => ()
            }
          Err(message) => return Err(message)
        }
      "--allow-cross" =>
        match flag_without_value(inline, flag) {
          Ok(_) => allow_cross.val = true
          Err(message) => return Err(message)
        }
      "--output"
      | "--name"
      | "--version"
      | "--build-number"
      | "--bundle-id"
      | "--icon"
      | "--renderer" =>
        match option_value(args, index, inline, flag) {
          Ok((value, next)) => {
            match flag {
              "--output" => output_dir.val = Some(value)
              "--name" => name.val = Some(value)
              "--version" => version.val = Some(value)
              "--build-number" => build_number.val = Some(value)
              "--bundle-id" => bundle_id.val = Some(value)
              "--icon" => icon.val = Some(make_absolute(value, cwd))
              "--renderer" => renderer.val = Some(value)
              _ => ()
            }
            index = next
            continue
          }
          Err(message) => return Err(message)
        }
      _ if argument.has_prefix("-") =>
        return Err("unknown option for moui package: " + flag)
      _ => positional.push(argument)
    }
    index += 1
  }
  if help.val {
    return Ok({
      help: true,
      json: json.val,
      platform: "",
      app: "",
      options: {
        output_dir: None,
        name: None,
        version: "0.1.0",
        build_number: "1",
        bundle_id: None,
        icon: None,
        renderer: "auto",
        release: true,
        allow_cross: false,
        no_build: false,
        dry_run: false,
      },
    })
  }
  if positional.length() < 2 {
    return Err("usage: moui package   [options]")
  }
  if positional.length() > 2 {
    return Err("moui package accepts exactly two positional arguments")
  }
  let platform = positional[0]
  let app = positional[1]
  if platform == "android" || platform == "ios" || platform == "harmonyos" {
    return Err(
      "mobile artifacts are produced by `moui build " +
      platform +
      "` and verified with `moui verify`; moui package supports macos, windows, linux, and web",
    )
  }
  if !desktop_platform_supported(platform) {
    return Err(
      "unsupported platform for moui package: " +
      platform +
      " (expected macos, windows, linux, or web)",
    )
  }
  Ok({
    help: false,
    json: json.val,
    platform,
    app,
    options: {
      output_dir: output_dir.val,
      name: name.val,
      version: version.val.unwrap_or("0.1.0"),
      build_number: build_number.val.unwrap_or("1"),
      bundle_id: bundle_id.val,
      icon: icon.val,
      renderer: renderer.val.unwrap_or("auto"),
      release: release.val,
      allow_cross: allow_cross.val,
      no_build: no_build.val,
      dry_run: dry_run.val,
    },
  })
}

///|
fn copy_file(source : String, destination : String) -> Result[Unit, String] {
  let bytes = @fs.read_file_to_bytes(source) catch {
    _ => return Err("could not read file for packaging: " + source)
  }
  @fs.write_bytes_to_file(destination, bytes) catch {
    _ => return Err("could not write packaged file: " + destination)
  }
  Ok(())
}

///|
fn find_existing_file(candidates : Array[String]) -> String? {
  for candidate in candidates {
    if candidate != "" && path_is_file(candidate) {
      return Some(candidate)
    }
  }
  None
}

///|
fn locate_manifest_validator(build_base : String) -> String? {
  find_existing_file([
    join_path(build_base, "scripts/validate-package-manifest.mjs"),
    "scripts/validate-package-manifest.mjs",
    join_path(
      moon_workspace_root(build_base).unwrap_or(""),
      "scripts/validate-package-manifest.mjs",
    ),
  ])
}

///|
fn validate_package_manifest(
  manifest_path : String,
  platform : String,
  build_base : String,
) -> String {
  match locate_manifest_validator(build_base) {
    Some(script) =>
      match
        process_exec_argv(
          ["node", script, manifest_path, "--platform", platform],
          capture=true,
        ) {
        Ok(result) =>
          if result.exit_code == 0 {
            "manifest validated"
          } else {
            "manifest validation failed: " + result.stderr
          }
        Err(message) => "manifest validation skipped: " + message
      }
    None => "manifest validation skipped (validator script not found)"
  }
}

///|
fn package_build_outcome(
  platform : String,
  app : String,
  output_path : String,
  validator_note : String,
  json : Bool,
) -> CliOutcome {
  if json {
    let fields : Map[String, Json] = Map([])
    fields["schemaVersion"] = Json::number(1.0)
    fields["ok"] = Json::boolean(true)
    fields["command"] = Json::string("package")
    fields["platform"] = Json::string(platform)
    fields["app"] = Json::string(app)
    fields["output"] = Json::string(output_path)
    fields["validation"] = Json::string(validator_note)
    stdout_outcome(Json::object(fields).stringify(indent=2) + "\n")
  } else {
    let mut text = "[package] " + platform + " app=" + app + "\n"
    text += "  output: " + output_path + "\n"
    text += "  " + validator_note + "\n"
    stdout_outcome(text)
  }
}

///|
fn run_package_build(args : Array[String], cwd : String) -> CliOutcome {
  let json = json_requested(args)
  let parsed = match parse_package_build_args(args, cwd) {
    Ok(value) => value
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  if parsed.help {
    return stdout_outcome(package_build_usage())
  }
  let json = parsed.json
  let options = parsed.options
  let host = detect_host_platform()
  if !options.allow_cross &&
    parsed.platform != "web" &&
    parsed.platform != host.name() {
    return failed_outcome(
      "cannot package " +
      parsed.platform +
      " on a " +
      host.name() +
      " host; run on a matching host or pass --allow-cross",
      json,
      package_build_usage(),
    )
  }
  let flow = match
    resolve_desktop_flow(
      cwd,
      parsed.app,
      parsed.platform,
      renderer=options.renderer,
    ) {
    Ok(value) => value
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let name = match options.name {
    Some(value) => value
    None => @plan.product_name(base_name(flow.app_root))
  }
  let dist_root = resolve_path(
    cwd,
    options.output_dir.unwrap_or("dist/" + parsed.platform),
  )
  let artifact = if options.no_build || options.dry_run {
    desktop_artifact_path(
      flow.build_base,
      flow.package_path,
      flow.target.target,
      release=options.release,
    )
  } else {
    match desktop_build_once(flow, host, release=options.release) {
      Ok(value) => value
      Err(message) =>
        return failed_outcome(message, json, package_build_usage())
    }
  }
  match parsed.platform {
    "windows" =>
      package_windows_portable(flow, options, name, dist_root, artifact, json)
    "macos" =>
      package_macos_bundle(flow, options, name, dist_root, artifact, json)
    "linux" =>
      package_linux_bundle(flow, options, name, dist_root, artifact, json)
    "web" => package_web_bundle(flow, options, name, dist_root, json, cwd)
    _ =>
      failed_outcome(
        "packaging for " + parsed.platform + " is not implemented",
        json,
        package_build_usage(),
      )
  }
}

///|
/// Depth-limited file search used to find the Skia ICU data table in the
/// release cache without re-implementing the provider lock resolver.
fn find_file_under(root : String, name : String, depth : Int32) -> String? {
  if depth < 0 || !path_is_dir(root) {
    return None
  }
  let entries = @fs.read_dir(root).to_owned() catch { _ => return None }
  for entry in entries {
    let child = join_path(root, entry)
    if path_is_file(child) && base_name(child) == name {
      return Some(child)
    }
    if path_is_dir(child) {
      match find_file_under(child, name, depth - 1) {
        Some(found) => return Some(found)
        None => ()
      }
    }
  }
  None
}

///|
/// Find `icudtl.dat` under the release Skia caches used by the repo and
/// generated projects. Missing data is a warning-level packaging gap, so the
/// caller keeps the rest of the bundle.
fn find_icudtl_data(flow : DesktopFlow) -> String? {
  let workspace = moon_workspace_root(flow.build_base).unwrap_or("")
  let roots = [
    join_path(flow.build_base, "moui_skia/.skia-cache/release"),
    join_path(flow.app_root, "moui_skia/.skia-cache/release"),
    if workspace == "" {
      ""
    } else {
      join_path(workspace, "moui_skia/.skia-cache/release")
    },
    ".skia-cache/release",
    "moui_skia/.skia-cache/release",
  ]
  for root in roots {
    if root != "" {
      match find_file_under(root, "icudtl.dat", 8) {
        Some(path) => return Some(path)
        None => ()
      }
    }
  }
  None
}

///|
fn package_windows_portable(
  flow : DesktopFlow,
  options : PackageBuildOptions,
  name : String,
  dist_root : String,
  artifact : String,
  json : Bool,
) -> CliOutcome {
  let app_dir = join_path(dist_root, name)
  let executable = name + ".exe"
  let manifest_path = join_path(app_dir, "moui-package.json")
  if options.dry_run {
    let mut text = "[package] windows app=" + name + " (dry-run)\n"
    text += "  output: " + app_dir + "\n"
    text += "  files: " +
      executable +
      ", run.cmd, moui-package.json (+ zlib DLL)\n"
    return stdout_outcome(text)
  }
  if !path_is_file(artifact) {
    return failed_outcome(
      "build artifact not found: " + artifact,
      json,
      package_build_usage(),
    )
  }
  remove_tree(app_dir)
  match mkdir_p(app_dir) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  match copy_file(artifact, join_path(app_dir, executable)) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let runtime_files = ["run.cmd"]
  match msvc_zlib_root(flow.app_root) {
    Some(triplet) =>
      match msvc_zlib_runtime_dll(triplet) {
        Some(dll) => {
          let dll_name = base_name(dll)
          match copy_file(dll, join_path(app_dir, dll_name)) {
            Ok(_) => runtime_files.push(dll_name)
            Err(message) =>
              return failed_outcome(message, json, package_build_usage())
          }
        }
        None => ()
      }
    None => ()
  }
  match find_icudtl_data(flow) {
    Some(path) =>
      match copy_file(path, join_path(app_dir, "icudtl.dat")) {
        Ok(_) => runtime_files.push("icudtl.dat")
        Err(message) =>
          return failed_outcome(message, json, package_build_usage())
      }
    None => ()
  }
  let run_cmd = [
      "@echo off",
      "setlocal",
      "set \"PATH=%~dp0;%PATH%\"",
      "\"%~dp0" + executable + "\" %*",
      "exit /b %ERRORLEVEL%",
    ].join("\r\n") +
    "\r\n"
  @fs.write_bytes_to_file(
    join_path(app_dir, "run.cmd"),
    @utf8.encode(run_cmd[:], bom=false),
  ) catch {
    _ =>
      return failed_outcome(
        "could not write run.cmd",
        json,
        package_build_usage(),
      )
  }
  let fields : Map[String, Json] = Map([])
  fields["schemaVersion"] = Json::number(1.0)
  fields["platform"] = Json::string("windows")
  fields["outputKind"] = Json::string("portable-folder")
  fields["appName"] = Json::string(name)
  fields["moonPackage"] = Json::string(flow.package_path)
  fields["version"] = Json::string(options.version)
  fields["buildNumber"] = Json::string(options.build_number)
  fields["executable"] = Json::string(executable)
  fields["bundleName"] = Json::string(name)
  fields["runtimeFiles"] = Json(runtime_files)
  let manifest = Json::object(fields).stringify(indent=2) + "\n"
  @fs.write_bytes_to_file(manifest_path, @utf8.encode(manifest[:], bom=false)) catch {
    _ =>
      return failed_outcome(
        "could not write moui-package.json",
        json,
        package_build_usage(),
      )
  }
  let note = validate_package_manifest(
    manifest_path,
    "windows",
    flow.build_base,
  )
  package_build_outcome("windows", name, app_dir, note, json)
}

///|
/// Linux native bundle: the executable plus a launcher, a desktop entry, and
/// the schema-1 package manifest. AppImage/deb packaging is a later slice.
fn package_linux_bundle(
  flow : DesktopFlow,
  options : PackageBuildOptions,
  name : String,
  dist_root : String,
  artifact : String,
  json : Bool,
) -> CliOutcome {
  let app_dir = join_path(dist_root, name)
  let executable = base_name(artifact)
  let executable = if executable.has_suffix(".exe") {
    executable[:executable.length() - 4].to_owned()
  } else {
    executable
  }
  let manifest_path = join_path(app_dir, "moui-package.json")
  if options.dry_run {
    let mut text = "[package] linux app=" + name + " (dry-run)\n"
    text += "  output: " + app_dir + "\n"
    text += "  files: " +
      executable +
      ", run.sh, " +
      name +
      ".desktop, moui-package.json\n"
    return stdout_outcome(text)
  }
  if !path_is_file(artifact) {
    return failed_outcome(
      "build artifact not found: " + artifact,
      json,
      package_build_usage(),
    )
  }
  remove_tree(app_dir)
  match mkdir_p(app_dir) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let binary_path = join_path(app_dir, executable)
  match copy_file(artifact, binary_path) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  match make_executable(binary_path) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let run_sh = "#!/bin/sh\n" +
    "DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\n" +
    "exec \"$DIR/" +
    executable +
    "\" \"$@\"\n"
  let run_sh_path = join_path(app_dir, "run.sh")
  @fs.write_bytes_to_file(run_sh_path, @utf8.encode(run_sh[:], bom=false)) catch {
    _ =>
      return failed_outcome(
        "could not write run.sh",
        json,
        package_build_usage(),
      )
  }
  match make_executable(run_sh_path) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let desktop_name = name + ".desktop"
  let desktop_entry = "[Desktop Entry]\n" +
    "Type=Application\n" +
    "Name=" +
    name +
    "\n" +
    "Exec=" +
    executable +
    "\n" +
    "Terminal=false\n" +
    "Categories=Utility;\n"
  @fs.write_bytes_to_file(
    join_path(app_dir, desktop_name),
    @utf8.encode(desktop_entry[:], bom=false),
  ) catch {
    _ =>
      return failed_outcome(
        "could not write " + desktop_name,
        json,
        package_build_usage(),
      )
  }
  let fields : Map[String, Json] = Map([])
  fields["schemaVersion"] = Json::number(1.0)
  fields["platform"] = Json::string("linux")
  fields["outputKind"] = Json::string("native-bundle")
  fields["appName"] = Json::string(name)
  fields["moonPackage"] = Json::string(flow.package_path)
  fields["version"] = Json::string(options.version)
  fields["buildNumber"] = Json::string(options.build_number)
  fields["executable"] = Json::string(executable)
  fields["bundleName"] = Json::string(name)
  fields["runtimeFiles"] = Json((["run.sh", desktop_name] : Array[String]))
  let manifest = Json::object(fields).stringify(indent=2) + "\n"
  @fs.write_bytes_to_file(manifest_path, @utf8.encode(manifest[:], bom=false)) catch {
    _ =>
      return failed_outcome(
        "could not write moui-package.json",
        json,
        package_build_usage(),
      )
  }
  let note = validate_package_manifest(manifest_path, "linux", flow.build_base)
  package_build_outcome("linux", name, app_dir, note, json)
}

///|
fn package_macos_bundle(
  flow : DesktopFlow,
  options : PackageBuildOptions,
  name : String,
  dist_root : String,
  artifact : String,
  json : Bool,
) -> CliOutcome {
  let bundle_id = match options.bundle_id {
    Some(value) => value
    None =>
      match read_project_manifest(flow.app_root) {
        Ok(manifest) =>
          match manifest.bundle_id {
            Some(value) => value
            None => "dev.wzzc.moui.app"
          }
        Err(_) => "dev.wzzc.moui.app"
      }
  }
  let bundle_dir = join_path(dist_root, name + ".app")
  let macos_dir = join_path(bundle_dir, "Contents/MacOS")
  let resources_dir = join_path(bundle_dir, "Contents/Resources")
  let executable = base_name(artifact)
  let executable = if executable.has_suffix(".exe") {
    executable[:executable.length() - 4].to_owned()
  } else {
    executable
  }
  let manifest_path = join_path(resources_dir, "moui-package.json")
  if options.dry_run {
    let mut text = "[package] macos app=" + name + " (dry-run)\n"
    text += "  output: " + bundle_dir + "\n"
    text += "  files: Contents/MacOS/" +
      executable +
      ", Contents/Resources/moui-package.json\n"
    return stdout_outcome(text)
  }
  if !path_is_file(artifact) {
    return failed_outcome(
      "build artifact not found: " + artifact,
      json,
      package_build_usage(),
    )
  }
  remove_tree(bundle_dir)
  match mkdir_p(macos_dir) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  match mkdir_p(resources_dir) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let icon_name = match options.icon {
    Some(path) =>
      if path_is_file(path) {
        let file_name = base_name(path)
        match copy_file(path, join_path(resources_dir, file_name)) {
          Ok(_) => Some(file_name)
          Err(message) =>
            return failed_outcome(message, json, package_build_usage())
        }
      } else {
        return failed_outcome(
          "macOS icon not found: " + path,
          json,
          package_build_usage(),
        )
      }
    None => None
  }
  let binary_path = join_path(macos_dir, executable)
  match copy_file(artifact, binary_path) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  match make_executable(binary_path) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let icon_entry = match icon_name {
    Some(value) =>
      "  CFBundleIconFile\n  " + value + "\n"
    None => ""
  }
  let plist = "\n" +
    "\n" +
    "\n\n" +
    "  CFBundleExecutable\n  " +
    executable +
    "\n" +
    "  CFBundleIdentifier\n  " +
    bundle_id +
    "\n" +
    "  CFBundleName\n  " +
    name +
    "\n" +
    "  CFBundlePackageType\n  APPL\n" +
    icon_entry +
    "  CFBundleShortVersionString\n  " +
    options.version +
    "\n" +
    "  CFBundleVersion\n  " +
    options.build_number +
    "\n" +
    "  LSMinimumSystemVersion\n  12.0\n" +
    "  NSHighResolutionCapable\n  \n" +
    "\n\n"
  @fs.write_bytes_to_file(
    join_path(bundle_dir, "Contents/Info.plist"),
    @utf8.encode(plist[:], bom=false),
  ) catch {
    _ =>
      return failed_outcome(
        "could not write Info.plist",
        json,
        package_build_usage(),
      )
  }
  @fs.write_bytes_to_file(
    join_path(bundle_dir, "Contents/PkgInfo"),
    @utf8.encode("APPL????", bom=false),
  ) catch {
    _ =>
      return failed_outcome(
        "could not write PkgInfo",
        json,
        package_build_usage(),
      )
  }
  let runtime_files = ["PkgInfo"]
  match icon_name {
    Some(value) => runtime_files.push(value)
    None => ()
  }
  let fields : Map[String, Json] = Map([])
  fields["schemaVersion"] = Json::number(1.0)
  fields["platform"] = Json::string("macos")
  fields["outputKind"] = Json::string("app-bundle")
  fields["appName"] = Json::string(name)
  fields["moonPackage"] = Json::string(flow.package_path)
  fields["bundleIdentifier"] = Json::string(bundle_id)
  fields["version"] = Json::string(options.version)
  fields["buildNumber"] = Json::string(options.build_number)
  fields["executable"] = Json::string(executable)
  fields["bundleName"] = Json::string(name + ".app")
  fields["runtimeFiles"] = Json(runtime_files)
  let manifest = Json::object(fields).stringify(indent=2) + "\n"
  @fs.write_bytes_to_file(manifest_path, @utf8.encode(manifest[:], bom=false)) catch {
    _ =>
      return failed_outcome(
        "could not write moui-package.json",
        json,
        package_build_usage(),
      )
  }
  let note = validate_package_manifest(manifest_path, "macos", flow.build_base)
  package_build_outcome("macos", name, bundle_dir, note, json)
}

///|
/// Standalone web packager used when the repo-local `package-web-app.mjs` is
/// not present (generated projects). It builds a release wasm artifact and
/// copies the index/runtime assets with their module paths rewritten for a
/// flat static bundle. Compression siblings are the repo packager's job.
fn package_web_bundle_fallback(
  flow : DesktopFlow,
  options : PackageBuildOptions,
  name : String,
  dist_root : String,
  json : Bool,
) -> CliOutcome {
  let source_root = join_path(flow.build_base, flow.package_path)
  let index_path = join_path(source_root, "index.html")
  if !path_is_file(index_path) {
    return failed_outcome(
      "web index.html not found: " + index_path,
      json,
      package_build_usage(),
    )
  }
  let artifact = if options.no_build {
    desktop_artifact_path(
      flow.build_base,
      flow.package_path,
      flow.target.target,
      release=true,
    )
  } else {
    match desktop_build_once(flow, detect_host_platform(), release=true) {
      Ok(value) => value
      Err(message) =>
        return failed_outcome(message, json, package_build_usage())
    }
  }
  if !path_is_file(artifact) {
    return failed_outcome(
      "web build artifact not found: " + artifact,
      json,
      package_build_usage(),
    )
  }
  let out_dir = join_path(dist_root, base_name(flow.app_root))
  let wasm_name = base_name(artifact)
  remove_tree(out_dir)
  match mkdir_p(out_dir) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  match copy_file(artifact, join_path(out_dir, wasm_name)) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let index_source = @fs.read_file_to_string(index_path) catch {
    _ =>
      return failed_outcome(
        "could not read web index.html",
        json,
        package_build_usage(),
      )
  }
  let wasm_url = "./" + wasm_name
  let index_text = index_source
    .replace_all(
      old="../.mooncakes/wzzc-dev/moui_web_renderer/runtime.js",
      new="./runtime.js",
    )
    .replace_all(old="../moui_web_renderer/runtime.js", new="./runtime.js")
    .replace_all(
      old="../_build/wasm-gc/debug/build/" + flow.package_path + "/" + wasm_name,
      new=wasm_url,
    )
    .replace_all(
      old="../_build/wasm-gc/release/build/" +
        flow.package_path +
        "/" +
        wasm_name,
      new=wasm_url,
    )
    .replace_all(
      old="./_build/wasm-gc/debug/build/" + flow.package_path + "/" + wasm_name,
      new=wasm_url,
    )
  @fs.write_bytes_to_file(
    join_path(out_dir, "index.html"),
    @utf8.encode(index_text[:], bom=false),
  ) catch {
    _ =>
      return failed_outcome(
        "could not write packaged index.html",
        json,
        package_build_usage(),
      )
  }
  let runtime_source = match
    find_existing_file([
      join_path(flow.build_base, "moui_web_renderer/runtime.js"),
      join_path(
        flow.build_base,
        ".mooncakes/wzzc-dev/moui_web_renderer/runtime.js",
      ),
    ]) {
    Some(path) => path
    None =>
      return failed_outcome(
        "web runtime.js was not found under the workspace",
        json,
        package_build_usage(),
      )
  }
  let runtime_text = @fs.read_file_to_string(runtime_source) catch {
    _ =>
      return failed_outcome(
        "could not read web runtime.js",
        json,
        package_build_usage(),
      )
  }
  let runtime_text = runtime_text.replace_all(
    old="../moui/backend/web/browser_runtime.js",
    new="./browser_runtime.js",
  )
  @fs.write_bytes_to_file(
    join_path(out_dir, "runtime.js"),
    @utf8.encode(runtime_text[:], bom=false),
  ) catch {
    _ =>
      return failed_outcome(
        "could not write packaged runtime.js",
        json,
        package_build_usage(),
      )
  }
  let browser_source = match
    find_existing_file([
      join_path(flow.build_base, "moui/backend/web/browser_runtime.js"),
      join_path(
        flow.build_base,
        ".mooncakes/wzzc-dev/moui/backend/web/browser_runtime.js",
      ),
    ]) {
    Some(path) => path
    None =>
      return failed_outcome(
        "web browser_runtime.js was not found under the workspace",
        json,
        package_build_usage(),
      )
  }
  match copy_file(browser_source, join_path(out_dir, "browser_runtime.js")) {
    Ok(_) => ()
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  let runtime_files = [
    "index.html", wasm_name, "runtime.js", "browser_runtime.js",
  ]
  match
    find_existing_file([
      join_path(flow.build_base, "moui_web_renderer/canvas2d_runtime.js"),
      join_path(
        flow.build_base,
        ".mooncakes/wzzc-dev/moui_web_renderer/canvas2d_runtime.js",
      ),
    ]) {
    Some(path) =>
      match copy_file(path, join_path(out_dir, "canvas2d_runtime.js")) {
        Ok(_) => runtime_files.push("canvas2d_runtime.js")
        Err(message) =>
          return failed_outcome(message, json, package_build_usage())
      }
    None => ()
  }
  for directory in ["assets", "docs"] {
    let source = join_path(source_root, directory)
    if path_is_dir(source) {
      let target = join_path(out_dir, directory)
      match mkdir_p(target) {
        Ok(_) => ()
        Err(message) =>
          return failed_outcome(message, json, package_build_usage())
      }
      match copy_tree(source, target) {
        Ok(_) => ()
        Err(message) =>
          return failed_outcome(message, json, package_build_usage())
      }
    }
  }
  let fields : Map[String, Json] = Map([])
  fields["schemaVersion"] = Json::number(1.0)
  fields["platform"] = Json::string("web")
  fields["outputKind"] = Json::string("static-bundle")
  fields["appName"] = Json::string(name)
  fields["moonPackage"] = Json::string(flow.package_path)
  fields["version"] = Json::string(options.version)
  fields["buildNumber"] = Json::string(options.build_number)
  fields["executable"] = Json::string(wasm_name)
  fields["bundleName"] = Json::string(base_name(out_dir))
  fields["runtimeFiles"] = Json(runtime_files)
  let manifest = Json::object(fields).stringify(indent=2) + "\n"
  @fs.write_bytes_to_file(
    join_path(out_dir, "moui-package.json"),
    @utf8.encode(manifest[:], bom=false),
  ) catch {
    _ =>
      return failed_outcome(
        "could not write moui-package.json",
        json,
        package_build_usage(),
      )
  }
  package_build_outcome(
    "web", name, out_dir, "web bundle written (standalone fallback)", json,
  )
}

///|
/// The web bundle is produced by the shared Node packager; the CLI locates
/// it in the workspace (or the installed MoUI package) and streams its
/// output. The script performs its own release+strip build.
fn package_web_bundle(
  flow : DesktopFlow,
  options : PackageBuildOptions,
  name : String,
  dist_root : String,
  json : Bool,
  cwd : String,
) -> CliOutcome {
  if options.dry_run {
    let mut text = "[package] web app=" + name + " (dry-run)\n"
    text += "  output: " + dist_root + "\n"
    text += "  files: index.html, *.wasm, runtime.js, .gz/.br siblings, bundle-size.json, moui-package.json\n"
    return stdout_outcome(text)
  }
  let script = match
    find_existing_file([
      join_path(flow.build_base, "scripts/package-web-app.mjs"),
      "scripts/package-web-app.mjs",
      join_path(cwd, "scripts/package-web-app.mjs"),
      join_path(
        moon_workspace_root(cwd).unwrap_or(""),
        ".mooncakes/wzzc-dev/moui/scripts/package-web-app.mjs",
      ),
    ]) {
    Some(path) => path
    None =>
      return package_web_bundle_fallback(flow, options, name, dist_root, json)
  }
  let out_dir = join_path(dist_root, base_name(flow.app_root))
  let argv = ["node", script, flow.package_path, "--out", out_dir]
  if options.no_build {
    argv.push("--no-build")
  }
  let result = match
    process_exec_argv(argv, cwd=flow.build_base, capture=false) {
    Ok(value) => value
    Err(message) => return failed_outcome(message, json, package_build_usage())
  }
  if result.exit_code != 0 {
    return failed_outcome(
      "web packaging failed with exit code " + result.exit_code.to_string(),
      json,
      package_build_usage(),
    )
  }
  let result_bundle_name = base_name(out_dir)
  let wasm_name = base_name(flow.package_path) + ".wasm"
  let fields : Map[String, Json] = Map([])
  fields["schemaVersion"] = Json::number(1.0)
  fields["platform"] = Json::string("web")
  fields["outputKind"] = Json::string("static-bundle")
  fields["appName"] = Json::string(name)
  fields["moonPackage"] = Json::string(flow.package_path)
  fields["version"] = Json::string(options.version)
  fields["buildNumber"] = Json::string(options.build_number)
  fields["executable"] = Json::string(wasm_name)
  fields["bundleName"] = Json::string(result_bundle_name)
  fields["runtimeFiles"] = Json(
    (["index.html", wasm_name, "runtime.js"] : Array[String]),
  )
  let manifest = Json::object(fields).stringify(indent=2) + "\n"
  @fs.write_bytes_to_file(
    join_path(out_dir, "moui-package.json"),
    @utf8.encode(manifest[:], bom=false),
  ) catch {
    _ =>
      return failed_outcome(
        "could not write moui-package.json",
        json,
        package_build_usage(),
      )
  }
  package_build_outcome("web", name, out_dir, "web bundle written", json)
}