#!/usr/bin/env bash
#
# Assemble a macOS .app bundle for a Nucleic build channel.
#
#   scripts/package-app.sh {dev|beta|rc|stable}
#
# Wraps the channel's bare SwiftPM executable in a proper .app: writes Info.plist,
# ships the SwiftPM resource bundles (GRDB / SwiftTerm) inside Contents/Resources,
# and ad-hoc code-signs so it launches locally. The channels build to distinct
# app names + bundle IDs, so they coexist:
#
#   channel  product (process)  app bundle               bundle id
#   dev      nucleic-local      Nucleic Dev.app          xyz.blakeslee.nucleic.desktop.dev
#   beta     nucleic-beta       Nucleic Beta.app         xyz.blakeslee.nucleic.desktop.beta
#   rc       nucleic-rc         Nucleic RC.app           xyz.blakeslee.nucleic.desktop.rc
#   stable   nucleic            Nucleic.app              xyz.blakeslee.nucleic.desktop.release
#
# (The production channel keyword stays "stable" everywhere in the build system; only its
# bundle-id suffix is "release", per the requested xyz.blakeslee.nucleic.desktop.* scheme.)
#
# Version comes from ./VERSION (NUCLEIC_MARKETING_VERSION + NUCLEIC_BUILD_NUMBER), maintained by
# scripts/release-macos.sh. This script only reads it; see the Version section below.
#
# Env overrides:
#   NUCLEIC_CONFIG   build configuration (default: release)
#   NUCLEIC_VERSION  marketing version / CFBundleShortVersionString (default: ./VERSION, else 0.1.0)
#   NUCLEIC_BUILD    build number / CFBundleVersion (default: ./VERSION, else git commit count)
#   NUCLEIC_SIGN_ID  codesign identity (default: "-", ad-hoc). Set to a Developer ID
#                    to sign for distribution (notarization/TestFlight is separate).
#
# An app icon is picked up automatically. Two formats, in priority order:
#   • Resources/AppIcon.icon  — Icon Composer package (macOS 26 Liquid Glass). Carries the
#     themed appearances (default/dark/clear/tinted); actool compiles it to Assets.car.
#   • Resources/AppIcon.icns  — legacy flat icon, no themed variants.
# Per-channel overrides (AppIcon-<channel>.{icon,icns}) take precedence over the generic names.

set -euo pipefail

CHANNEL="${1:-dev}"
CONFIG="${NUCLEIC_CONFIG:-release}"
SIGN_ID="${NUCLEIC_SIGN_ID:--}"
# MARKETING_VERSION / BUILD_NUMBER are resolved below, after ROOT, from ./VERSION.

case "$CHANNEL" in
  dev)    PRODUCT=nucleic-local; APP_NAME="Nucleic Dev";  BUNDLE_ID="xyz.blakeslee.nucleic.desktop.dev" ;;
  beta)   PRODUCT=nucleic-beta;  APP_NAME="Nucleic Beta"; BUNDLE_ID="xyz.blakeslee.nucleic.desktop.beta" ;;
  rc)     PRODUCT=nucleic-rc;    APP_NAME="Nucleic RC";   BUNDLE_ID="xyz.blakeslee.nucleic.desktop.rc" ;;
  stable) PRODUCT=nucleic;       APP_NAME="Nucleic";      BUNDLE_ID="xyz.blakeslee.nucleic.desktop.release" ;;
  *) echo "usage: $0 {dev|beta|rc|stable}" >&2; exit 2 ;;
esac

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"

# ── Version ──────────────────────────────────────────────────────────────────────
# Single source of truth is ./VERSION (KEY=value, bumped per release by scripts/release-macos.sh).
# A direct env override still wins for one-off builds (NUCLEIC_VERSION / NUCLEIC_BUILD), and a
# checkout with no VERSION file falls back to 0.1.0 and the git commit count — so `make app-beta`
# and old trees never break. This script only READS the version; only release-macos.sh bumps it.
NUCLEIC_MARKETING_VERSION=""; NUCLEIC_BUILD_NUMBER=""
# shellcheck source=/dev/null
[ -f "$ROOT/VERSION" ] && . "$ROOT/VERSION"
MARKETING_VERSION="${NUCLEIC_VERSION:-${NUCLEIC_MARKETING_VERSION:-0.1.0}}"

# iCloud sync drops "conflict copies" beside source files — "Foo 2.swift", "Foo 3.swift"
# — that are byte-identical to the original. SwiftPM globs the whole Sources/Tests tree,
# so each copy compiles as a second definition and the build dies with "invalid
# redeclaration". Sweep them before building. We only touch our own source trees, and
# only files whose name is "<base> <n>.swift" with an existing "<base>.swift" alongside,
# so a deliberately-named file is never removed.
clean_icloud_dupes() {
  local dupe base removed=0
  while IFS= read -r -d '' dupe; do
    base="${dupe% [0-9].swift}.swift"
    [ -f "$base" ] || continue
    rm -f "$dupe"
    echo "  removed iCloud conflict copy: ${dupe#"$ROOT/"}"
    removed=$((removed + 1))
  done < <(find "$ROOT/Sources" "$ROOT/Tests" -type f -name '* [0-9].swift' -print0 2>/dev/null)
  [ "$removed" -gt 0 ] && echo "▸ Cleaned $removed iCloud conflict cop$([ "$removed" -eq 1 ] && echo y || echo ies) before build"
  return 0
}
clean_icloud_dupes

echo "▸ Building $PRODUCT  (channel=$CHANNEL, config=$CONFIG)"
NUCLEIC_CHANNEL="$CHANNEL" swift build -c "$CONFIG" --build-system native --product "$PRODUCT"
BIN_DIR="$(NUCLEIC_CHANNEL="$CHANNEL" swift build -c "$CONFIG" --build-system native --product "$PRODUCT" --show-bin-path)"

# Build number: ./VERSION (release-maintained, monotonic) → env override → git commit count.
BUILD_NUMBER="${NUCLEIC_BUILD:-${NUCLEIC_BUILD_NUMBER:-$(git rev-list --count HEAD 2>/dev/null || echo 1)}}"
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"

APP="$ROOT/dist/$APP_NAME.app"
CONTENTS="$APP/Contents"
echo "▸ Assembling $APP"
rm -rf "$APP"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"

# Executable — keep the channel's product name so the OS process name is preserved
# even when launched from the bundle.
cp "$BIN_DIR/$PRODUCT" "$CONTENTS/MacOS/$PRODUCT"

# SwiftPM resource bundles must ship inside the app — `Bundle.module` resolves them
# via Bundle.main.resourceURL (= Contents/Resources).
shopt -s nullglob
for b in "$BIN_DIR"/*.bundle; do
  cp -R "$b" "$CONTENTS/Resources/"
done
shopt -u nullglob

# Sparkle.framework — the in-app auto-updater (direct/non-App-Store distribution). SwiftPM
# emits it into the build products dir; it must live in Contents/Frameworks and be reachable
# from the executable via @executable_path/../Frameworks. It is deep-signed inside-out below.
SPARKLE_FW=""
for cand in "$BIN_DIR/Sparkle.framework" "$BIN_DIR/../Sparkle.framework"; do
  [ -d "$cand" ] && { SPARKLE_FW="$cand"; break; }
done
if [ -n "$SPARKLE_FW" ]; then
  mkdir -p "$CONTENTS/Frameworks"
  cp -R "$SPARKLE_FW" "$CONTENTS/Frameworks/"
  # The SwiftPM-linked binary may not already carry the embedded-frameworks rpath; add it
  # (idempotent — a duplicate just errors harmlessly and is ignored).
  install_name_tool -add_rpath "@executable_path/../Frameworks" "$CONTENTS/MacOS/$PRODUCT" 2>/dev/null || true
  echo "  • embedded Sparkle.framework (auto-update)"
else
  echo "  • Sparkle.framework not found in build products — auto-update will be inert"
fi

# Container runtime: NOTHING is required to be bundled. The kernel, the vminitd initfs, and the
# sandbox image all download + cache automatically on first use. Optionally, a kernel staged at
# Resources/vmlinux-arm64 (scripts/fetch-kernel.sh) is bundled here as an offline/dev fast-path so
# the app skips that one download.
if [ -f "$ROOT/Resources/vmlinux-arm64" ]; then
  cp "$ROOT/Resources/vmlinux-arm64" "$CONTENTS/Resources/vmlinux-arm64"
  echo "  • bundled Resources/vmlinux-arm64 (kernel fast-path)"
else
  echo "  • kernel not bundled — it downloads automatically at runtime (optional: scripts/fetch-kernel.sh)"
fi

# Optional icon. Prefer a themed Icon Composer package (.icon → Assets.car via actool);
# fall back to a flat .icns. Per-channel files win over the generic names.
ICON_PLIST=""
ICON_SET=""   # Resources/AppIcon[-<channel>].icon  (Icon Composer, themed)
[ -d "$ROOT/Resources/AppIcon-$CHANNEL.icon" ] && ICON_SET="$ROOT/Resources/AppIcon-$CHANNEL.icon"
[ -z "$ICON_SET" ] && [ -d "$ROOT/Resources/AppIcon.icon" ] && ICON_SET="$ROOT/Resources/AppIcon.icon"
ICON_ICNS=""  # Resources/AppIcon[-<channel>].icns  (legacy flat fallback)
[ -f "$ROOT/Resources/AppIcon-$CHANNEL.icns" ] && ICON_ICNS="$ROOT/Resources/AppIcon-$CHANNEL.icns"
[ -z "$ICON_ICNS" ] && [ -f "$ROOT/Resources/AppIcon.icns" ] && ICON_ICNS="$ROOT/Resources/AppIcon.icns"

if [ -n "$ICON_SET" ]; then
  # actool consumes the .icon directly (no .xcassets wrapper) and emits Assets.car into
  # Contents/Resources. --app-icon names the icon; CFBundleIconName resolves that name
  # against the compiled catalog at launch and is what enables the themed appearances.
  # actool's partial plist is unusable on macOS (it omits CFBundleIconName), so we discard
  # it and write the key ourselves below. ICON_NAME = the .icon basename (matches per-channel).
  ICON_NAME="$(basename "$ICON_SET" .icon)"
  echo "  • compiling themed app icon: ${ICON_SET#"$ROOT/"} (CFBundleIconName=$ICON_NAME)"
  ACTOOL_PLIST="$(mktemp -t actool-partial-plist)"
  xcrun actool "$ICON_SET" \
    --compile "$CONTENTS/Resources" \
    --app-icon "$ICON_NAME" \
    --include-all-app-icons \
    --output-partial-info-plist "$ACTOOL_PLIST" \
    --enable-on-demand-resources NO \
    --development-region en \
    --target-device mac \
    --platform macosx \
    --minimum-deployment-target 26.0 \
    --output-format human-readable-text --notices --warnings --errors
  rm -f "$ACTOOL_PLIST"
  ICON_PLIST="
	<key>CFBundleIconName</key>
	<string>$ICON_NAME</string>"
  # Ship a flat .icns alongside if one exists — harmless belt-and-suspenders for contexts
  # that look for an icon file rather than the asset catalog.
  if [ -n "$ICON_ICNS" ]; then
    cp "$ICON_ICNS" "$CONTENTS/Resources/AppIcon.icns"
    ICON_PLIST="$ICON_PLIST
	<key>CFBundleIconFile</key>
	<string>AppIcon</string>"
  fi
elif [ -n "$ICON_ICNS" ]; then
  cp "$ICON_ICNS" "$CONTENTS/Resources/AppIcon.icns"
  ICON_PLIST="
	<key>CFBundleIconFile</key>
	<string>AppIcon</string>"
else
  echo "  • no app icon found (Resources/AppIcon.icon or AppIcon.icns) — using system default"
fi

# Sparkle auto-update keys. Embedded only for the distribution channels (beta/rc/stable) and
# only when a public EdDSA key is available — a dev/local build ships no feed, so its updater
# stays dormant and the "Check for Updates…" menu item hides itself. The per-channel feed lives
# under NUCLEIC_FEED_BASE (the R2 bucket behind updates.nucleic.blakeslee.xyz that hosts the
# appcasts + DMGs; the GitHub release is a documented fallback origin); the public key is
# the counterpart of the private signing key generated once by Sparkle's `generate_keys` (the
# public half is safe to commit at signing/sparkle_public_ed_key). Both are overridable by env.
FEED_BASE="${NUCLEIC_FEED_BASE:-https://updates.nucleic.blakeslee.xyz}"
SPARKLE_PUB="${NUCLEIC_SPARKLE_PUB:-}"
[ -z "$SPARKLE_PUB" ] && [ -f "$ROOT/signing/sparkle_public_ed_key" ] && \
  SPARKLE_PUB="$(tr -d '[:space:]' < "$ROOT/signing/sparkle_public_ed_key")"
SPARKLE_PLIST=""
if [ "$CHANNEL" != "dev" ] && [ -n "$SPARKLE_PUB" ]; then
  SPARKLE_PLIST="
	<key>SUFeedURL</key>
	<string>$FEED_BASE/appcast-$CHANNEL.xml</string>
	<key>SUPublicEDKey</key>
	<string>$SPARKLE_PUB</string>
	<key>SUEnableAutomaticChecks</key>
	<true/>"
  echo "  • Sparkle feed: $FEED_BASE/appcast-$CHANNEL.xml"
else
  echo "  • Sparkle feed: not embedded for this build (updater dormant)"
fi

cat > "$CONTENTS/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDevelopmentRegion</key>
	<string>en</string>
	<key>CFBundleExecutable</key>
	<string>$PRODUCT</string>
	<key>CFBundleIdentifier</key>
	<string>$BUNDLE_ID</string>
	<key>CFBundleInfoDictionaryVersion</key>
	<string>6.0</string>
	<key>CFBundleName</key>
	<string>$APP_NAME</string>
	<key>CFBundleDisplayName</key>
	<string>$APP_NAME</string>
	<key>CFBundlePackageType</key>
	<string>APPL</string>
	<key>CFBundleShortVersionString</key>
	<string>$MARKETING_VERSION</string>
	<key>CFBundleVersion</key>
	<string>$BUILD_NUMBER</string>$ICON_PLIST$SPARKLE_PLIST
	<key>LSMinimumSystemVersion</key>
	<string>26.0</string>
	<key>NSHighResolutionCapable</key>
	<true/>
	<key>NSPrincipalClass</key>
	<string>NSApplication</string>
	<key>NSSupportsAutomaticTermination</key>
	<true/>
	<key>NSSupportsSuddenTermination</key>
	<true/>
</dict>
</plist>
PLIST

printf 'APPL????' > "$CONTENTS/PkgInfo"

# Strip extended attributes (resource forks / Finder info) that iCloud and Finder
# attach to copied files — codesign rejects them with "resource fork, Finder
# information, or similar detritus not allowed". SwiftPM ships some resource files
# read-only, so make the tree writable first or xattr -c gets EACCES.
chmod -R u+w "$APP"
xattr -cr "$APP"

# ── Code signing ────────────────────────────────────────────────────────────────
# Distribution builds are signed with a "Developer ID Application" identity, the
# hardened runtime (--options runtime, required by notarization), and a SECURE
# TIMESTAMP (also required by notarization). An ad-hoc/local build ("-") can't get a
# secure timestamp, so it's only requested for a real identity.
#
# Hardened runtime is ALSO conditional, and this is load-bearing: the hardened runtime
# forces library validation (CS_REQUIRE_LV), which demands every loaded Mach-O share the
# main executable's Team ID. For a real identity that's fine — the inner Sparkle helpers
# are re-signed with the same team below, so they match. But an AD-HOC binary has no team
# identity, and the app and Sparkle.framework are signed in separate ad-hoc operations, so
# the loader rejects Sparkle at launch with "mapping process and mapped file have different
# Team IDs" (EXC_CRASH / DYLD, even though `codesign --verify` — a static check — passes).
# Ad-hoc local builds don't need (and can't notarize with) the hardened runtime, so we omit
# it for them; that drops the library-validation requirement and the embedded Sparkle loads.
#
# The virtualization entitlement is applied to the main executable so the app can
# boot Linux VMs in-process (ContainerEngine). `com.apple.security.virtualization`
# is NOT a restricted/managed entitlement: it needs no special Apple approval and no
# provisioning profile — a Developer ID cert + notarization is enough. (The
# restricted entitlement we deliberately avoid is com.apple.vm.networking; see BUILD.md.)
if [ "$SIGN_ID" = "-" ]; then
  TS_FLAG=(--timestamp=none)          # ad-hoc: no secure timestamp available
  RT_FLAG=()                          # ad-hoc: NO hardened runtime (would force library validation
                                      #         and reject the separately-ad-hoc-signed Sparkle)
  echo "▸ Code-signing ad-hoc (local only — not distributable, not notarizable; no hardened runtime)"
else
  TS_FLAG=(--timestamp)               # Developer ID: secure timestamp for notarization
  RT_FLAG=(--options runtime)         # Developer ID: hardened runtime, required by notarization
  echo "▸ Code-signing for distribution (identity: $SIGN_ID)"
fi

# Sign inside-out: every nested Mach-O MUST be signed BEFORE the enclosing bundle, or (for a
# real identity) notarization rejects the unsigned inner code. Sparkle.framework carries its own
# helper executables (the Autoupdate tool, the Updater app, and the Installer/Downloader XPC
# services) which codesign does NOT sign transitively when it seals the framework, so each is
# signed explicitly here, deepest-first, exactly as Sparkle's "sign outside Xcode" guidance says.
# (RT_FLAG carries the hardened runtime only for a real identity — see the note above.)
# (${RT_FLAG[@]+...} guard: RT_FLAG is empty for ad-hoc, and bash 3.2 — macOS's /bin/bash —
# errors on "${empty[@]}" under `set -u`; the guard expands to nothing when unset/empty.)
sign_one() { [ -e "$1" ] && codesign --force ${RT_FLAG[@]+"${RT_FLAG[@]}"} "${TS_FLAG[@]}" --sign "$SIGN_ID" "$1"; }
SPK="$CONTENTS/Frameworks/Sparkle.framework"
if [ -d "$SPK" ]; then
  V="$SPK/Versions/B"
  sign_one "$V/XPCServices/Installer.xpc"
  sign_one "$V/XPCServices/Downloader.xpc"
  sign_one "$V/Autoupdate"
  sign_one "$V/Updater.app"
  sign_one "$SPK"
fi
# Any other embedded frameworks (none today) — sign the bundle.
shopt -s nullglob
for nested in "$CONTENTS/Frameworks"/*.framework; do
  [ "$nested" = "$SPK" ] && continue
  codesign --force ${RT_FLAG[@]+"${RT_FLAG[@]}"} "${TS_FLAG[@]}" --sign "$SIGN_ID" "$nested"
done
shopt -u nullglob

# Finally the app bundle itself — signs the main executable with the virtualization
# entitlement and seals the bundle.
codesign --force ${RT_FLAG[@]+"${RT_FLAG[@]}"} "${TS_FLAG[@]}" \
  --entitlements "$ROOT/signing/nucleic.entitlements" \
  --sign "$SIGN_ID" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"

echo "✓ $APP"
echo "  bundle id   $BUNDLE_ID"
echo "  version     $MARKETING_VERSION (build $BUILD_NUMBER, commit $COMMIT)"
echo "  open with:  open \"$APP\""
