#!/usr/bin/env bun
/**
 * appwrap CLI v0 — scaffold a native wrapper around a built PWA.
 *
 *   appwrap init [--config <path>] [--out native]   # from the PWA project dir
 *   appwrap sync [--config <path>] [--out native]   # re-copy PWA dist into the wrapper
 *
 * Config (TS preferred, JSON fallback) — probed in order: appwrap.config.ts → .js → appwrap.json.
 * Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
 */
import { execFileSync, spawn } from 'child_process';
import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
import { networkInterfaces, tmpdir } from 'os';
import { dirname, join, resolve } from 'path';
import { pathToFileURL } from 'url';
// PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
// the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
// monorepo and from a published tarball (where runtime/ is bundled at the package root).
import type * as CapManifest from '../../../runtime/app/shell/capabilities.manifest';
// Config shape lives in its own import-safe module so a `appwrap.config.ts` file can import the
// type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
import type { AppwrapConfig } from './config';
import { unknownConfigKeys } from './config';
import {
  androidScreenOrientation,
  applyBuildNumberFlag,
  iosOrientations,
  mergeManifest,
  resolveBuildNumber,
  stampAndroidOrientation,
  stampAndroidQueries,
  stampAppBoundDomains,
  stampPlistBackgroundTasks,
  stampPlistOrientations,
  stampPrivacyTracking,
} from './derive';
import type { WebManifest } from './derive';

/** What `child_process.execFileSync` attaches to the Error it throws on a non-zero exit
 * (stdout/stderr are Buffer with the default encoding, string when `encoding` is set). */
interface ExecError extends Error {
  stdout?: string | Buffer;
  stderr?: string | Buffer;
  status?: number | null;
}
const asExecError = (e: unknown): ExecError => (e ?? {}) as ExecError;
/** Combined stdout+stderr captured on an exec failure (empty string when none). */
const execErrText = (e: unknown): string => {
  const err = asExecError(e);
  return `${err.stdout ?? ''}${err.stderr ?? ''}`;
};

/**
 * Resolved monotonic build number (iOS CFBundleVersion / Android versionCode). Thin env wrapper over
 * the pure `resolveBuildNumber` (in derive.ts, where it unit-tests). Precedence: `APPWRAP_BUILD_NUMBER`
 * env (CI run #) > explicit numeric `cfg.buildNumber` > named strategy ('timestamp'|'epoch') > derived
 * from version. The env override gives CI a monotonic, collision-free build for free; the derived
 * default is CONSTANT per version, so repeat uploads of one marketing version would 409 without it.
 */
function buildNumberOf(cfg: AppwrapConfig): number {
  return resolveBuildNumber(cfg, process.env.APPWRAP_BUILD_NUMBER);
}

const IOS_PERMISSION_KEYS: Record<string, string[]> = {
  location: ['NSLocationWhenInUseUsageDescription'],
  photos: ['NSPhotoLibraryUsageDescription'],
  camera: ['NSCameraUsageDescription'],
  microphone: ['NSMicrophoneUsageDescription'],
  faceid: ['NSFaceIDUsageDescription'],
  // iOS 17 key + pre-17 fallback key, same usage string
  calendar: ['NSCalendarsFullAccessUsageDescription', 'NSCalendarsUsageDescription'],
};

/** Runtime permissions stamped into AndroidManifest.xml per declared domain.
 * photos/faceid need none: system picker / USE_BIOMETRIC is baseline. */
const ANDROID_PERMISSION_KEYS: Record<string, string[]> = {
  location: ['android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION'],
  camera: ['android.permission.CAMERA'],
  microphone: ['android.permission.RECORD_AUDIO'],
  calendar: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'],
  contacts: ['android.permission.READ_CONTACTS'],
};

/** Resolve a bundled asset dir. A published tarball ships runtime/ + templates/ at the package root
 * (one level above src/); the monorepo resolves them at the repo root (three levels up). */
function resolveAssetRoot(rel: string): string {
  const local = resolve(import.meta.dir, '..', rel);
  return existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
}
const TEMPLATE_DIR = resolveAssetRoot('runtime');
const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');

/** This CLI's own published version — used to pin the `bunx @livx.cc/appwrap@^x.y.z` invocations the
 * emitted workflow runs. Pinning to THIS version's floor means CI fails LOUDLY ("version not found")
 * until that version is published, instead of bunx silently resolving an older published build that
 * lacks `release`/`init` flags. package.json sits one level above src/ (published) or the monorepo dir. */
const CLI_VERSION: string = (await import(resolve(import.meta.dir, '..', 'package.json'), { with: { type: 'json' } })).default.version;

// Load the capability manifest VALUES from the resolved runtime (pure data — safe outside NativeScript).
// Top-level await resolves before any command dispatches at the bottom of this file.
const { MODULES, OPTIONAL_GROUPS } = (await import(
  resolve(TEMPLATE_DIR, 'app/shell/capabilities.manifest')
)) as typeof CapManifest;

/** Native requirements composed for a build: the union (deduped) of the active modules' self-contained
 * manifest declarations. Two modes:
 *  - `modules` ABSENT (legacy): every capability active; permissions come ONLY from `permissions{}`
 *    (the iOS/Android key maps above) — unchanged pre-modules behavior.
 *  - `modules` PRESENT (explicit): core + listed; perms/bg-modes/deps derived from manifests, with
 *    `permissions{}` overriding the default usage copy. Capabilities not listed are stripped.
 */
interface NativeReqs {
  explicit: boolean;
  activeOptIn: string[];           // opt-in capability names that are active (for the handshake map)
  activeOptionalGroups: string[];  // strippable handler groups (own file) that are active
  iosPlist: Array<{ key: string; usage: string }>;
  iosEntitlements: Record<string, boolean | string | string[]>;
  androidPerms: string[];
  androidGradleDeps: string[];
  androidKotlin: boolean;        // any active module ships Kotlin native source
  androidManifestApp: string[];  // raw XML injected inside AndroidManifest <application>
  nativeSrc: string[];           // active modules' nativeSrc dir names (under runtime/modules-native/)
}

function nativeReqs(cfg: AppwrapConfig): NativeReqs {
  const optIn = MODULES.filter((m) => !m.core);
  // Legacy default (no `modules` key) = every opt-in capability EXCEPT strictly-opt-in own-file
  // modules (OPTIONAL_GROUPS, e.g. health): those carry deps/perms legacy won't stamp, so they must
  // be explicitly requested. Explicit mode = exactly what `modules` lists.
  const active = cfg.modules
    ? new Set(cfg.modules)
    : new Set(optIn.filter((m) => !OPTIONAL_GROUPS.includes(m.group as (typeof OPTIONAL_GROUPS)[number])).map((m) => m.name));
  const activeMods = MODULES.filter((m) => m.core || active.has(m.name));

  const iosPlist: Array<{ key: string; usage: string }> = [];
  const seenKeys = new Set<string>();
  const androidPerms = new Set<string>();
  const gradle = new Set<string>();
  const iosEntitlements: Record<string, boolean | string | string[]> = {};
  const nativeSrc: string[] = [];
  const androidManifestApp: string[] = [];
  let androidKotlin = false;

  if (cfg.modules) {
    // explicit: self-contained module declarations win
    for (const m of activeMods) {
      for (const p of m.ios?.permissions ?? []) {
        if (seenKeys.has(p.key)) continue;
        seenKeys.add(p.key);
        iosPlist.push({ key: p.key, usage: cfg.permissions?.[p.domain as keyof typeof cfg.permissions] ?? p.defaultUsage });
      }
      for (const ap of m.android?.permissions ?? []) androidPerms.add(ap);
      for (const g of m.android?.gradleDeps ?? []) gradle.add(g);
      Object.assign(iosEntitlements, m.ios?.entitlements ?? {});
      if (m.android?.kotlin) androidKotlin = true;
      if (m.android?.manifestApplication) androidManifestApp.push(m.android.manifestApplication);
      if (m.nativeSrc) nativeSrc.push(m.nativeSrc);
    }
  } else {
    // legacy: only what `permissions{}` declares (via the key maps) — no behavior change
    for (const [domain, text] of Object.entries(cfg.permissions ?? {})) {
      for (const key of IOS_PERMISSION_KEYS[domain] ?? []) {
        if (text && !seenKeys.has(key)) { seenKeys.add(key); iosPlist.push({ key, usage: text }); }
      }
      for (const p of ANDROID_PERMISSION_KEYS[domain] ?? []) androidPerms.add(p);
    }
  }

  return {
    explicit: !!cfg.modules,
    activeOptIn: optIn.filter((m) => active.has(m.name)).map((m) => m.name),
    activeOptionalGroups: OPTIONAL_GROUPS.filter((g) => activeMods.some((m) => m.group === g)),
    iosPlist,
    iosEntitlements,
    androidPerms: [...androidPerms],
    androidGradleDeps: [...gradle],
    androidKotlin,
    androidManifestApp,
    nativeSrc,
  };
}

/** Map a strippable optional group → its handler file + register fn (for the generated barrel). */
const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
  health: { file: './handlers-health', fn: 'registerHealthHandlers' },
  oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
  reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
  scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
  speech: { file: './handlers-speech', fn: 'registerSpeechHandlers' },
  tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
  appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
};

/** Generate the two composition artifacts in the wrapper: the active capability list (drives the
 * handshake map) and the optional-handler barrel (imports only active strippable groups). */
function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
  const shell = join(outDir, 'app/shell');
  writeFileSync(
    join(shell, 'active-modules.generated.ts'),
    `/** Generated by \`appwrap\` from the appwrap config \`modules\`. Do not edit. */\n` +
      `export const ACTIVE_MODULE_NAMES: string[] = ${JSON.stringify(req.activeOptIn)};\n`
  );

  const groups = req.activeOptionalGroups.filter((g) => OPTIONAL_GROUP_HANDLERS[g]);
  const imports = groups.map((g) => `import { ${OPTIONAL_GROUP_HANDLERS[g].fn} } from '${OPTIONAL_GROUP_HANDLERS[g].file}';`).join('\n');
  const calls = groups.map((g) => `  ${OPTIONAL_GROUP_HANDLERS[g].fn}();`).join('\n');
  writeFileSync(
    join(shell, 'optional-handlers.generated.ts'),
    `/** Generated by \`appwrap\` — only the active strippable modules are imported. Do not edit. */\n` +
      `${imports}${imports ? '\n' : ''}\nexport function registerOptionalHandlers(): void {\n${calls}\n}\n`
  );

  // iOS BGTaskScheduler launch handlers must register at didFinishLaunching (the AppDelegate calls
  // registerBackgroundLaunchHandlers) — too early for the page-load barrel. Wire the real impl ONLY
  // when backgroundTask is active, so a build without it never references BGTaskScheduler. No-op default.
  const bgActive = req.activeOptionalGroups.includes('backgroundTask');
  writeFileSync(
    join(shell, 'background-bootstrap.generated.ts'),
    `/** Generated by \`appwrap\` — wires the iOS BGTask launch handlers only when backgroundTask is active. Do not edit. */\n` +
      (bgActive
        ? `export { registerBackgroundTaskLaunchHandlers as registerBackgroundLaunchHandlers } from './handlers-background';\n`
        : `export function registerBackgroundLaunchHandlers(): void {}\n`)
  );
}

/** Stamp the active modules' gradle dependencies into Android app.gradle. Idempotent marker block. */
function stampAndroidGradleDeps(outDir: string, deps: string[]): void {
  const appGradle = join(outDir, 'App_Resources/Android/app.gradle');
  if (!existsSync(appGradle)) return;
  const strip = (s: string) => s.replace(/\n*\/\/ appwrap-modules:begin[\s\S]*?\/\/ appwrap-modules:end\n*/g, '\n');
  let s = strip(readFileSync(appGradle, 'utf8')).trimEnd() + '\n';
  if (deps.length) {
    const lines = deps.map((d) => `  implementation "${d}"`).join('\n');
    s += `\n// appwrap-modules:begin (native deps from active modules)\ndependencies {\n${lines}\n}\n// appwrap-modules:end\n`;
  }
  writeFileSync(appGradle, s);
}

/** Module-owned native source lives here (mirroring App_Resources); copied into native/ when active. */
const MODULES_NATIVE_DIR = resolve(TEMPLATE_DIR, 'modules-native');
const MODULE_KOTLIN_VERSION = '2.1.0';

/** Merge iOS entitlements from active modules + remote push into ONE app.entitlements (NS auto-detects
 * + signs it). Removes the file when empty so a no-entitlement build (personal team, push off) signs. */
function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
  const iosDir = join(outDir, 'App_Resources/iOS');
  if (!existsSync(iosDir)) return;
  const file = join(iosDir, 'app.entitlements');
  const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements };
  if (!!cfg.push?.enabled && cfg.push?.ios !== false) ent['aps-environment'] = cfg.push.apsEnvironment ?? 'development';
  const keys = Object.keys(ent);
  if (keys.length === 0) { rmSync(file, { force: true }); return; }
  const val = (v: boolean | string | string[]): string =>
    typeof v === 'boolean' ? `<${v}/>`
    : Array.isArray(v) ? `<array>\n${v.map((s) => `    <string>${s}</string>`).join('\n')}\n  </array>`
    : `<string>${v}</string>`;
  const body = keys.map((k) => `  <key>${k}</key>\n  ${val(ent[k])}`).join('\n');
  writeFileSync(
    file,
    `<?xml version="1.0" encoding="UTF-8"?>\n` +
      `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n` +
      `<plist version="1.0">\n<dict>\n${body}\n</dict>\n</plist>\n`
  );
  console.log(`  entl ← ${keys.join(', ')}`);
}

/** Stamp the App Tracking Transparency declarations into the store-readiness privacy manifest
 * (PrivacyInfo.xcprivacy). EXTENDS that single manifest — flips NSPrivacyTracking + fills
 * NSPrivacyTrackingDomains only when the `tracking` module is active, else leaves the template's
 * `false` + empty defaults. Idempotent both ways (a build that later drops the module resets them). */
function stampPrivacyManifest(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
  const file = join(outDir, 'App_Resources/iOS/PrivacyInfo.xcprivacy');
  if (!existsSync(file)) return;
  const active = req.activeOptionalGroups.includes('tracking');
  const next = stampPrivacyTracking(readFileSync(file, 'utf8'), active, cfg.trackingDomains ?? []);
  writeFileSync(file, next);
  if (active) console.log(`  priv ← NSPrivacyTracking=true${cfg.trackingDomains?.length ? ` (${cfg.trackingDomains.length} domain${cfg.trackingDomains.length > 1 ? 's' : ''})` : ''}`);
}

/** Copy active modules' native source (runtime/modules-native/<name>/) into native/ — only when the
 * module is active, so module native code stays stripped from builds that don't use it. */
function copyModuleNativeSrc(outDir: string, req: NativeReqs): void {
  for (const name of req.nativeSrc) {
    const src = join(MODULES_NATIVE_DIR, name);
    if (!existsSync(src)) { console.warn(`⚠ module nativeSrc not found: ${src}`); continue; }
    cpSync(src, outDir, { recursive: true, force: true });
    console.log(`  natv ← module '${name}' native source`);
  }
}

/** Enable Kotlin in the NS Android build when an active module ships Kotlin native source. Injects
 * useKotlin/kotlinVersion into before-plugins.gradle's project.ext (re-stamped from template each run). */
function stampKotlin(outDir: string, enable: boolean): void {
  const file = join(outDir, 'App_Resources/Android/before-plugins.gradle');
  if (!enable || !existsSync(file)) return;
  let src = readFileSync(file, 'utf8');
  if (!/^\s*useKotlin\s*=/m.test(src)) {
    src = src.replace(/(project\.ext\s*\{)/, `$1\n  useKotlin = true\n  kotlinVersion = "${MODULE_KOTLIN_VERSION}"`);
  }
  writeFileSync(file, src);
  console.log(`  ktln ← Kotlin enabled (${MODULE_KOTLIN_VERSION})`);
}

function parseArgs(argv: string[]) {
  const [command, ...rest] = argv;
  const flags: Record<string, string> = {};
  const positionals: string[] = [];
  for (let i = 0; i < rest.length; i++) {
    const t = rest[i];
    if (t?.startsWith('-')) {
      const key = t.replace(/^-+/, ''); // accept both --long and -short (e.g. -r)
      const next = rest[i + 1];
      // value flag (`--out native`) vs boolean flag (`--aab`, `-r`) → presence as ''
      if (next !== undefined && !next.startsWith('-')) {
        flags[key] = next;
        i++;
      } else {
        flags[key] = '';
      }
    } else if (t !== undefined) {
      positionals.push(t);
    }
  }
  return { command, flags, positionals };
}

/** Parse the PWA's web manifest (manifest.json / .webmanifest) from the dist dir, or null. */
function loadManifest(cwd: string, cfg: AppwrapConfig): WebManifest | null {
  const dist = resolve(cwd, cfg.pwaDist);
  for (const name of ['manifest.json', 'manifest.webmanifest']) {
    const mf = join(dist, name);
    if (!existsSync(mf)) continue;
    try {
      return JSON.parse(readFileSync(mf, 'utf8'));
    } catch (e: unknown) {
      console.warn(`⚠ Could not parse ${name}: ${e instanceof Error ? e.message : String(e)}`);
    }
  }
  return null;
}

/** Config filenames probed (in order) when `--config` is not passed. TS is preferred (typed,
 * autocomplete via `defineConfig`); `.js` then `.json` are supported as fallbacks. */
const CONFIG_CANDIDATES = ['appwrap.config.ts', 'appwrap.config.js', 'appwrap.json'] as const;

/** Load a `.ts`/`.js`/`.json` config. TS/JS are imported (Bun runs them natively — no transpile
 * step) and may `export default` (or a named `config`); JSON is parsed. Returns the raw object. */
async function readConfigFile(configPath: string): Promise<AppwrapConfig> {
  if (configPath.endsWith('.json')) {
    return JSON.parse(readFileSync(configPath, 'utf8')) as AppwrapConfig;
  }
  // .ts / .js — dynamic import (Bun runs it natively). Each CLI command is its own process, so the
  // ESM module cache never outlives a single run.
  const mod = await import(pathToFileURL(configPath).href);
  const cfg = mod.default ?? mod.config;
  if (!cfg || typeof cfg !== 'object') {
    console.error(`✖ ${configPath} must \`export default\` (or export \`config\`) an appwrap config object.`);
    process.exit(1);
  }
  return cfg as AppwrapConfig;
}

/** Resolve the user's appwrap config file path the CLI loads from: explicit --config wins;
 * otherwise probe ts → js → json (TS preferred). Single source of discovery (reused by the
 * team-id pin-to-config writer so it never re-invents the probe). */
function resolveConfigPath(cwd: string, flags: Record<string, string>): string {
  return flags.config
    ? resolve(cwd, flags.config)
    : (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
}

async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
  const configPath = resolveConfigPath(cwd, flags);
  if (!existsSync(configPath)) {
    console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
    process.exit(1);
  }
  const cfg = await readConfigFile(configPath);

  // Warn (never fail) on keys this appwrap doesn't recognize. A config authored for a NEWER appwrap
  // silently no-ops its unknown keys on an older install (e.g. `targetedDevices` before 0.39 → wrong
  // device family, no error). Turn that silent no-op into a signal.
  const stray = unknownConfigKeys(cfg as unknown as Record<string, unknown>);
  if (stray.length) {
    const ver = pkgVersion(resolve(import.meta.dir, '../package.json'));
    for (const k of stray) console.warn(`⚠ appwrap: unrecognized config key '${k}' — ignored. If you expect it to apply, your installed @livx.cc/appwrap (${ver}) may predate it — upgrade.`);
  }

  // Manifest as source: the appwrap config wins, the PWA manifest fills the gaps, template default last.
  // (DRY single-source — devs don't re-type identity already declared in the manifest.) See mergeManifest.
  if (cfg.pwaDist) mergeManifest(cfg, loadManifest(cwd, cfg));

  for (const key of ['id', 'name', 'version', 'pwaDist'] as const) {
    if (!cfg[key]) {
      console.error(`✖ config missing required field: ${key}` + (key === 'name' ? ' (and no name/short_name in the PWA manifest)' : ''));
      process.exit(1);
    }
  }

  // loader:'server' bakes serverUrl into the shell and loads it via NSURL/WKWebView. A scheme-less
  // value (e.g. "agf.circlesup.com") produces an unusable URL — the app silently fails to load (or
  // shows a stale page) with no error. Normalize to https:// when no scheme is present, and fail loud
  // on a genuinely malformed URL rather than shipping a broken build.
  if (cfg.serverUrl) {
    const raw = String(cfg.serverUrl).trim();
    const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
    try {
      new URL(withScheme);
    } catch {
      console.error(`✖ invalid serverUrl: ${JSON.stringify(cfg.serverUrl)} — must be an absolute http(s) URL (e.g. https://app.example.com)`);
      process.exit(1);
    }
    if (withScheme !== raw) console.log(`  serverUrl ← ${withScheme} (added https:// — scheme was missing)`);
    cfg.serverUrl = withScheme;
  }

  return cfg;
}

function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
  const content = `/**
 * Shell config — stamped by \`appwrap init\`/\`sync\` from the appwrap config. Do not edit.
 */
export const SHELL_CONFIG = {
  appId: ${JSON.stringify(cfg.id)},
  name: ${JSON.stringify(cfg.name)},
  version: ${JSON.stringify(cfg.version)},
  entry: ${JSON.stringify(cfg.entry ?? 'index.html')},
  backgroundColor: ${JSON.stringify(cfg.backgroundColor ?? '#ffffff')},
  themeColor: ${JSON.stringify(cfg.themeColor ?? '')},
  statusBarStyle: ${JSON.stringify(cfg.statusBarStyle ?? 'dark')} as 'light' | 'dark',
  orientation: ${JSON.stringify(cfg.orientation ?? '')} as '' | 'portrait' | 'landscape' | 'any',
  edgeToEdge: ${JSON.stringify(cfg.edgeToEdge ?? false)},
  loader: ${JSON.stringify(cfg.loader ?? 'app')} as 'app' | 'file' | 'server',
  serverUrl: ${JSON.stringify(cfg.serverUrl ?? '')},
  backendOrigin: ${JSON.stringify(cfg.backendOrigin ?? '')},
  debug: ${JSON.stringify(cfg.debug ?? false)},
  debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
  devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
  neutralizeServiceWorker: ${JSON.stringify(cfg.neutralizeServiceWorker ?? true)},
  appBoundDomains: ${JSON.stringify(cfg.appBoundDomains ?? [])} as string[],
  openNewWindowsInBrowser: ${JSON.stringify(cfg.openNewWindowsInBrowser ?? false)},
  pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
  pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
};
`;
  writeFileSync(join(outDir, 'app/shell/config.ts'), content);
}

function stampNativeScriptConfig(outDir: string, cfg: AppwrapConfig): void {
  const file = join(outDir, 'nativescript.config.ts');
  const src = readFileSync(file, 'utf8').replace(/id: '[^']*'/, `id: '${cfg.id}'`);
  writeFileSync(file, src);
}

function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
  const plist = join(outDir, 'App_Resources/iOS/Info.plist');
  if (!existsSync(plist)) return;
  let src = readFileSync(plist, 'utf8');
  const stamp = (key: string, value: string) => {
    const re = new RegExp(`(<key>${key}</key>\\s*<string>)[^<]*(</string>)`);
    src = re.test(src) ? src.replace(re, `$1${value}$2`) : src;
  };
  stamp('CFBundleDisplayName', cfg.name);
  stamp('CFBundleName', cfg.name);
  stamp('CFBundleShortVersionString', cfg.version); // marketing version (user-facing)
  stamp('CFBundleVersion', String(buildNumberOf(cfg))); // monotonic build — store re-uploads need it higher

  // Supported orientation (config > manifest) — rewrites both UISupportedInterfaceOrientations
  // arrays (iPhone + ~ipad). Skipped when unset → keep the template's free-rotation default.
  if (cfg.orientation) src = stampPlistOrientations(src, iosOrientations(cfg.orientation));

  // Headless background tasks (backgroundTask module): stamp BGTaskSchedulerPermittedIdentifiers +
  // fetch/processing background modes from `backgroundTasks`. Idempotent both ways — passing []/undefined
  // strips the block — so it no-ops (and cleans up) when the module is inactive or the field is absent.
  const bgActive = req.activeOptionalGroups.includes('backgroundTask');
  src = stampPlistBackgroundTasks(src, bgActive ? cfg.backgroundTasks : undefined);

  // iOS App-Bound Domains — gate for a WKWebView service worker (paired with
  // limitsNavigationsToAppBoundDomains in the shell config). Idempotent both ways: empty/undefined
  // strips the WKAppBoundDomains key, so it no-ops when the field is absent.
  src = stampAppBoundDomains(src, cfg.appBoundDomains);

  // Permission usage strings + URL scheme + export-compliance — idempotent: strip stamped block, re-add
  src = src.replace(/\s*<!-- appwrap:begin -->[\s\S]*?<!-- appwrap:end -->/g, '');
  const extras: string[] = [];
  // Export compliance: skips the per-upload encryption prompt. Default false; override in config.
  extras.push(`  <key>ITSAppUsesNonExemptEncryption</key>\n  <${cfg.usesNonExemptEncryption ? 'true' : 'false'}/>`);
  // Permissions: composed (deduped) from the active modules — legacy mode falls back to permissions{}.
  for (const { key, usage } of req.iosPlist) {
    extras.push(`  <key>${key}</key>\n  <string>${usage}</string>`);
  }
  if (cfg.urlScheme) {
    extras.push(
      `  <key>CFBundleURLTypes</key>\n  <array>\n    <dict>\n      <key>CFBundleTypeRole</key>\n      <string>Editor</string>\n      <key>CFBundleURLName</key>\n      <string>${cfg.id}</string>\n      <key>CFBundleURLSchemes</key>\n      <array>\n        <string>${cfg.urlScheme}</string>\n      </array>\n    </dict>\n  </array>`
    );
  }
  // Schemes kit.app.canOpenUrl() may probe → LSApplicationQueriesSchemes (iOS 9+ requires declaration
  // for custom schemes). No-op when absent.
  if (cfg.queryUrlSchemes?.length) {
    const items = cfg.queryUrlSchemes.map((s) => `    <string>${s}</string>`).join('\n');
    extras.push(`  <key>LSApplicationQueriesSchemes</key>\n  <array>\n${items}\n  </array>`);
  }
  if (extras.length) {
    src = src.replace(
      /<\/dict>\s*<\/plist>\s*$/,
      `  <!-- appwrap:begin -->\n${extras.join('\n')}\n  <!-- appwrap:end -->\n</dict>\n</plist>\n`
    );
  }

  // Remote push needs the `remote-notification` background mode. The template already ships a
  // UIBackgroundModes array (for `audio`), so MERGE in-place — a second <key> would be a duplicate
  // (invalid plist). Idempotent both ways: add when enabled+missing, strip when disabled.
  const iosPush = !!cfg.push?.enabled && cfg.push?.ios !== false;
  const bgArray = /(<key>UIBackgroundModes<\/key>\s*<array>)([\s\S]*?)(<\/array>)/;
  const hasRN = /<string>remote-notification<\/string>/.test(src);
  if (iosPush && !hasRN) {
    src = bgArray.test(src)
      ? src.replace(bgArray, (_m, open, inner, close) => `${open}${inner}\t<string>remote-notification</string>\n\t${close}`)
      : src.replace(/<\/dict>\s*<\/plist>\s*$/, `  <key>UIBackgroundModes</key>\n  <array>\n    <string>remote-notification</string>\n  </array>\n</dict>\n</plist>\n`);
  } else if (!iosPush && hasRN) {
    src = src.replace(/\s*<string>remote-notification<\/string>/, '');
  }

  writeFileSync(plist, src);
}

function stampTeamId(outDir: string, cfg: AppwrapConfig, ctx?: { cwd: string; configPath: string }): void {
  const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
  // Resolution order: a real (non-placeholder) cfg.teamId wins; else $APPWRAP_TEAM_ID (headless/CI);
  // else the enriched interactive picker (which then offers to pin its choice to the config). This
  // intercepts BEFORE `ns build` runs so NS never shows its plain, unenriched prompt.
  const isPlaceholder = !cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId);
  if (isPlaceholder) {
    const envTeam = process.env.APPWRAP_TEAM_ID?.trim();
    if (envTeam) {
      cfg.teamId = envTeam;
      console.log(`  team ← ${envTeam} (from $APPWRAP_TEAM_ID)`);
    } else if (!process.stdout.isTTY) {
      console.warn(`⚠ teamId is unset — set it in appwrap.config, set $APPWRAP_TEAM_ID, or run interactively to pick from your teams.`);
      return;
    } else {
      const picked = pickTeamIdInteractively();
      cfg.teamId = picked.teamId;
      // Offer to persist the choice so the user isn't re-prompted on every deploy. No-TTY/headless is
      // already handled above; promptYesNo additionally guards against a non-interactive stdin.
      if (ctx && promptYesNo(`  Pin "${picked.name} (${picked.teamId})" to appwrap.config so you're not asked again?`, true)) {
        pinTeamIdToConfig(ctx.configPath, picked.teamId);
      } else {
        console.log(`  ⓘ  To skip this prompt: set teamId: "${picked.teamId}" in appwrap.config (or set $APPWRAP_TEAM_ID).`);
      }
    }
  }
  if (!existsSync(xcconfig)) return;
  let src = readFileSync(xcconfig, 'utf8');
  src = /DEVELOPMENT_TEAM\s*=/.test(src)
    ? src.replace(/DEVELOPMENT_TEAM\s*=\s*[^;\n]*;?/, `DEVELOPMENT_TEAM = ${cfg.teamId};`)
    : src + `\nDEVELOPMENT_TEAM = ${cfg.teamId};\n`;
  writeFileSync(xcconfig, src);
}

/** Stamp TARGETED_DEVICE_FAMILY into build.xcconfig from cfg.targetedDevices. `'iphone'` → `1`
 * (iPhone-only → UIDeviceFamily=[1], so the App Store doesn't require iPad screenshots);
 * `'universal'`/unset → `1,2` (NativeScript's default). Idempotent: replaces any prior value. */
function stampDeviceFamily(outDir: string, cfg: AppwrapConfig): void {
  const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
  if (!existsSync(xcconfig)) return;
  const value = cfg.targetedDevices === 'iphone' ? '1' : '1,2';
  let src = readFileSync(xcconfig, 'utf8');
  src = /TARGETED_DEVICE_FAMILY\s*=/.test(src)
    ? src.replace(/TARGETED_DEVICE_FAMILY\s*=\s*[^;\n]*;?/, `TARGETED_DEVICE_FAMILY = ${value};`)
    : src + `\nTARGETED_DEVICE_FAMILY = ${value};\n`;
  writeFileSync(xcconfig, src);
}

/** Wire a StoreKit config file for LOCAL iOS IAP testing (no App Store Connect needed).
 * NativeScript copies App_Resources/iOS/* into the generated project and adds it as a file
 * reference — but it never points the scheme at it, so StoreKit has no catalog. We (1) drop the
 * .storekit into App_Resources/iOS so it's bundled + referenced, and (2) install an after-prepare
 * hook that injects `<StoreKitConfigurationFileReference>` into the scheme's LaunchAction (the
 * scheme is regenerated on every `ns prepare`, so a one-time edit won't stick). Only takes effect
 * when launched from Xcode (sim or device-from-Xcode), not a standalone devicectl sideload. */
function stampStoreKit(cwd: string, outDir: string, cfg: AppwrapConfig): void {
  if (!cfg.storekitConfig) return;
  const source = resolve(cwd, cfg.storekitConfig);
  if (!existsSync(source)) {
    console.warn(`⚠ config \`storekitConfig\` not found: ${source} — skipping StoreKit wiring`);
    return;
  }
  const base = source.split('/').pop()!;
  const iosRes = join(outDir, 'App_Resources/iOS');
  if (!existsSync(iosRes)) return;
  cpSync(source, join(iosRes, base));

  // after-prepare hook: resolve the .storekit's real location under platforms/ios at run time and
  // point each app scheme's LaunchAction at it via a path relative to the scheme file (Xcode's rule).
  const hookDir = join(outDir, 'hooks/after-prepare');
  mkdirSync(hookDir, { recursive: true });
  writeFileSync(join(hookDir, 'appwrap-storekit.js'), STOREKIT_HOOK(base));
  console.log(`  iap  ← StoreKit config (${base}) wired for local testing`);
}

/** The after-prepare hook source. Self-contained (no deps); zero-arg so NS's DI never chokes. */
const STOREKIT_HOOK = (base: string) => `// Generated by \`appwrap\` — wires ${base} into the iOS scheme for local StoreKit testing.
const fs = require('fs');
const path = require('path');
module.exports = function () {
  const iosDir = path.join(__dirname, '..', '..', 'platforms', 'ios');
  if (!fs.existsSync(iosDir)) return;
  const find = (dir, name) => {
    for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
      const p = path.join(dir, e.name);
      if (e.isDirectory()) { if (e.name === 'Pods') continue; const r = find(p, name); if (r) return r; }
      else if (e.name === name) return p;
    }
    return null;
  };
  const storekit = find(iosDir, ${JSON.stringify(base)});
  if (!storekit) return;
  for (const proj of fs.readdirSync(iosDir).filter((d) => d.endsWith('.xcodeproj') && d !== 'Pods.xcodeproj')) {
    const schemesDir = path.join(iosDir, proj, 'xcshareddata', 'xcschemes');
    if (!fs.existsSync(schemesDir)) continue;
    for (const s of fs.readdirSync(schemesDir).filter((f) => f.endsWith('.xcscheme'))) {
      const file = path.join(schemesDir, s);
      let xml = fs.readFileSync(file, 'utf8');
      if (xml.includes('StoreKitConfigurationFileReference')) continue;
      const id = path.relative(schemesDir, storekit);
      const ref = '      <StoreKitConfigurationFileReference\\n         identifier = "' + id + '">\\n      </StoreKitConfigurationFileReference>';
      xml = xml.replace(/(\\s*)<\\/LaunchAction>/, '\\n' + ref + '$1</LaunchAction>');
      fs.writeFileSync(file, xml);
      console.log('  appwrap: StoreKit config wired into ' + s + ' (' + id + ')');
    }
  }
};
`;

/** Remote-push native wiring (gated on `cfg.push.enabled`). iOS: the `aps-environment` entitlement —
 * NativeScript auto-detects `App_Resources/iOS/app.entitlements` and signs with it. Idempotent:
 * removes the file when push is disabled so a personal-team (no-push) build still signs. Android FCM
 * gradle plumbing is staged separately (needs google-services.json) — only the file is copied here. */
function stampPush(cwd: string, outDir: string, cfg: AppwrapConfig): void {
  const androidPush = !!cfg.push?.enabled && cfg.push?.android !== false;
  // iOS aps-environment entitlement is emitted by stampEntitlements (unified with module entitlements).

  // Android FCM. We deliberately AVOID the `com.google.gms.google-services` gradle plugin: injecting
  // its buildscript classpath via NS's `apply from:` scripts doesn't reach the module's plugin
  // resolver (Gradle scoping → "plugin not found"). The plugin only generates string resources from
  // google-services.json that Firebase auto-init (FirebaseInitProvider) reads — so we generate those
  // resources directly + add the firebase-messaging dep. Same result, no plugin, no classpath fight.
  // Token-only register() works on auto-init; inbound onMessage/onTap to JS needs a
  // FirebaseMessagingService (the @nativescript/firebase-messaging plugin) — 1b.
  let fcmVals: Record<string, string> | null = null;
  if (androidPush && cfg.push?.googleServicesJson) {
    const src = resolve(cwd, cfg.push.googleServicesJson);
    if (existsSync(src)) {
      fcmVals = readGoogleServices(src);
      if (fcmVals) console.log(`  push ← Android FCM wired (firebase resources for ${fcmVals.project_id}, no plugin)`);
      else console.warn(`⚠ Could not parse ${src} — skipping Android FCM`);
    } else {
      console.warn(`⚠ config \`push.googleServicesJson\` not found: ${src} — skipping Android FCM`);
    }
  }
  stampAndroidFcm(outDir, fcmVals);
}

/** Extract the values Firebase auto-init needs from a google-services.json (the subset the
 * google-services plugin would otherwise codegen). Returns null if the shape is unexpected. */
function readGoogleServices(src: string): Record<string, string> | null {
  try {
    const j = JSON.parse(readFileSync(src, 'utf8'));
    const client = (j.client ?? [])[0];
    const vals: Record<string, string> = {
      google_app_id: client?.client_info?.mobilesdk_app_id ?? '',
      gcm_defaultSenderId: j.project_info?.project_number ?? '',
      google_api_key: (client?.api_key ?? [])[0]?.current_key ?? '',
      project_id: j.project_info?.project_id ?? '',
      google_storage_bucket: j.project_info?.storage_bucket ?? '',
    };
    return vals.google_app_id && vals.gcm_defaultSenderId ? vals : null;
  } catch {
    return null;
  }
}

/** Wire (or strip) Android FCM without the google-services plugin: write the firebase string
 * resources Firebase auto-init reads + add the firebase-messaging dependency. Idempotent. */
function stampAndroidFcm(outDir: string, vals: Record<string, string> | null): void {
  const resXml = join(outDir, 'App_Resources/Android/src/main/res/values/appwrap-firebase.xml');
  const appGradle = join(outDir, 'App_Resources/Android/app.gradle');
  const beforePlugins = join(outDir, 'App_Resources/Android/before-plugins.gradle');
  const stripBlock = (s: string) => s.replace(/\n*\/\/ appwrap-fcm:begin[\s\S]*?\/\/ appwrap-fcm:end\n*/g, '\n');

  // before-plugins: ensure any prior plugin-classpath block is gone (we no longer use it).
  if (existsSync(beforePlugins)) writeFileSync(beforePlugins, stripBlock(readFileSync(beforePlugins, 'utf8')).trimEnd() + '\n');

  // Inbound delivery wiring (gated by `vals` = FCM actually wired): the FirebaseMessagingService for
  // foreground/data onMessage. Declaring the <service> + importing the (Firebase-extending) shell
  // class only when FCM is present keeps a non-push build from compiling a class with an absent base.
  stampFcmService(outDir, !!vals);

  if (vals) {
    const strings = Object.entries(vals)
      .filter(([, v]) => v)
      .map(([k, v]) => `  <string name="${k}" translatable="false">${v}</string>`)
      .join('\n');
    writeFileSync(resXml, `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n${strings}\n</resources>\n`);
    if (existsSync(appGradle)) {
      let s = stripBlock(readFileSync(appGradle, 'utf8')).trimEnd() + '\n';
      s += `\n// appwrap-fcm:begin (remote push — gated by appwrap.json.push + google-services.json)\ndependencies {\n  implementation platform("com.google.firebase:firebase-bom:33.7.0")\n  implementation "com.google.firebase:firebase-messaging"\n}\n// appwrap-fcm:end\n`;
      writeFileSync(appGradle, s);
    }
  } else {
    rmSync(resXml, { force: true });
    rmSync(join(outDir, 'App_Resources/Android/google-services.json'), { force: true });
    if (existsSync(appGradle)) writeFileSync(appGradle, stripBlock(readFileSync(appGradle, 'utf8')).trimEnd() + '\n');
  }
}

/** Wire (or strip) the inbound FCM FirebaseMessagingService: import the shell service via the
 * generated bootstrap + declare the <service> in AndroidManifest — both ONLY when FCM is wired
 * (`on`), so non-push builds never compile/declare a Firebase-extending class. Idempotent. */
function stampFcmService(outDir: string, on: boolean): void {
  writeFileSync(
    join(outDir, 'app/shell/fcm-bootstrap.generated.ts'),
    `/** Generated by \`appwrap\` — imports the FCM messaging service only when push is wired. Do not edit. */\n` +
      (on ? `import './fcm-service'; // side-effect: registers AppwrapMessagingService (JavaProxy)\n` : ``)
  );

  const manifest = join(outDir, 'App_Resources/Android/src/main/AndroidManifest.xml');
  if (!existsSync(manifest)) return;
  const service = on
    ? `\n\t\t<service\n\t\t\tandroid:name="cc.livx.appwrap.AppwrapMessagingService"\n\t\t\tandroid:exported="false">\n\t\t\t<intent-filter>\n\t\t\t\t<action android:name="com.google.firebase.MESSAGING_EVENT" />\n\t\t\t</intent-filter>\n\t\t</service>\n\t\t`
    : '';
  const src = readFileSync(manifest, 'utf8').replace(
    /<!-- appwrap:fcm -->[\s\S]*?<!-- \/appwrap:fcm -->/,
    `<!-- appwrap:fcm -->${service}<!-- /appwrap:fcm -->`
  );
  writeFileSync(manifest, src);
}

function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
  // Write res/values/strings.xml defining app_name = cfg.name. The NS template ships NO strings.xml
  // (the default app_name/activity title resolve to "native" from @nativescript/core), so a regex
  // replace was a no-op and the launcher showed "native". Write the file so app_name is authoritative
  // (the manifest's <application> AND launcher <activity> both label off @string/app_name). XML-escape.
  const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  const stringsDir = join(outDir, 'App_Resources/Android/src/main/res/values');
  mkdirSync(stringsDir, { recursive: true });
  writeFileSync(
    join(stringsDir, 'strings.xml'),
    `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n    <string name="app_name">${esc(cfg.name)}</string>\n    <string name="title_activity_kimera">${esc(cfg.name)}</string>\n</resources>\n`
  );
  const manifest = join(outDir, 'App_Resources/Android/src/main/AndroidManifest.xml');
  if (existsSync(manifest)) {
    let src = readFileSync(manifest, 'utf8');
    if (cfg.urlScheme) src = src.replace(/android:scheme="[^"]*"/, `android:scheme="${cfg.urlScheme}"`);
    // Supported orientation (config > manifest) on the main <activity>. Skipped when unset → keep
    // the template default (free); 'any' removes the attribute, so re-sync stays idempotent.
    if (cfg.orientation) src = stampAndroidOrientation(src, androidScreenOrientation(cfg.orientation));
    // Permissions — idempotent: rewrite the marker block from the active modules (deduped).
    const perms = req.androidPerms.map((p) => `\t<uses-permission android:name="${p}"/>`);
    src = src.replace(
      /<!-- appwrap:permissions -->[\s\S]*?<!-- \/appwrap:permissions -->/,
      `<!-- appwrap:permissions -->\n${perms.join('\n')}\n\t<!-- /appwrap:permissions -->`
    );
    // <application> XML from active modules (activities/providers/intent-filters) — idempotent marker.
    src = src.replace(
      /<!-- appwrap:application -->[\s\S]*?<!-- \/appwrap:application -->/,
      `<!-- appwrap:application -->\n\t\t${req.androidManifestApp.join('\n\t\t')}\n\t\t<!-- /appwrap:application -->`
    );
    // <queries> for kit.app.canOpenUrl() visibility probes (API 30+) — idempotent marker. queryPackages
    // → explicit <package>; queryUrlSchemes → a VIEW <intent> per scheme (symmetric with iOS's
    // LSApplicationQueriesSchemes). See stampAndroidQueries.
    src = stampAndroidQueries(src, cfg.queryPackages, cfg.queryUrlSchemes);
    writeFileSync(manifest, src);
  }
}

/** Stamp Android marketing version (versionName) + monotonic build (versionCode) into app.gradle. */
function stampAndroidVersion(outDir: string, cfg: AppwrapConfig): void {
  const gradle = join(outDir, 'App_Resources/Android/app.gradle');
  if (!existsSync(gradle)) return;
  let src = readFileSync(gradle, 'utf8');
  src = src.replace(/versionCode\s+\d+/, `versionCode ${buildNumberOf(cfg)}`);
  src = src.replace(/versionName\s+"[^"]*"/, `versionName "${cfg.version}"`);
  writeFileSync(gradle, src);
}

/** Locate the icon source: explicit cfg.icon, else the largest icon in the PWA manifest. */
function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
  if (cfg.icon) {
    const p = resolve(cwd, cfg.icon);
    if (existsSync(p)) return p;
    console.warn(`⚠ config \`icon\` not found: ${p}`);
    return null;
  }
  const dist = resolve(cwd, cfg.pwaDist);
  const icons: Array<{ src: string; sizes?: string }> = loadManifest(cwd, cfg)?.icons ?? [];
  const best = icons
    .map((i) => ({ src: i.src, px: parseInt(i.sizes ?? '0', 10) || 0 }))
    .sort((a, b) => b.px - a.px)[0];
  if (best) {
    const p = join(dist, best.src);
    if (existsSync(p)) return p;
  }
  return null;
}

/**
 * Locate the maskable icon source for the Android adaptive-icon foreground (full-bleed, content in
 * the safe zone). Prefers a manifest icon with purpose "maskable"; falls back to the main source so
 * non-maskable icons still get a real foreground (just edge-cropped by the launcher mask).
 */
function findMaskableSource(cwd: string, cfg: AppwrapConfig): string | null {
  const dist = resolve(cwd, cfg.pwaDist);
  const icons: Array<{ src: string; sizes?: string; purpose?: string }> = loadManifest(cwd, cfg)?.icons ?? [];
  const maskable = icons
    .filter((i) => (i.purpose ?? '').split(/\s+/).includes('maskable'))
    .map((i) => ({ src: i.src, px: parseInt(i.sizes ?? '0', 10) || 0 }))
    .sort((a, b) => b.px - a.px)[0];
  if (maskable) {
    const p = join(dist, maskable.src);
    if (existsSync(p)) return p;
  }
  return findIconSource(cwd, cfg);
}

/** Square-resize/probe abstraction over `sips` (macOS) or ImageMagick (`magick` v7 / `convert` v6),
 * so icon generation works on Linux/CI too — not just macOS. Returns null if neither tool is present. */
function imageRasterizer(): { width: (src: string) => number; resize: (src: string, px: number, dest: string) => void } | null {
  const has = (cmd: string) => {
    try { execFileSync('which', [cmd], { stdio: 'ignore' }); return true; } catch { return false; }
  };
  if (has('sips')) {
    return {
      width: (src) => parseInt(execFileSync('sips', ['-g', 'pixelWidth', src]).toString().match(/(\d+)\s*$/)?.[1] ?? '0', 10),
      resize: (src, px, dest) => execFileSync('sips', ['-z', String(px), String(px), src, '--out', dest], { stdio: 'ignore' }),
    };
  }
  const magick = has('magick') ? 'magick' : has('convert') ? 'convert' : null;
  if (magick) {
    // v7: `magick identify` / `magick <src> -resize`. v6: `identify` / `convert <src> -resize`. `!` forces exact (square) dims.
    return {
      width: (src) => {
        const args = magick === 'magick' ? ['identify', '-format', '%w', src] : ['-format', '%w', src];
        const bin = magick === 'magick' ? 'magick' : 'identify';
        return parseInt(execFileSync(bin, args).toString().trim() || '0', 10);
      },
      resize: (src, px, dest) => execFileSync(magick, [src, '-resize', `${px}x${px}!`, dest], { stdio: 'ignore' }),
    };
  }
  return null;
}

/** Generate iOS appiconset + Android mipmaps from the PWA's icon (via sips on macOS or ImageMagick on CI). */
function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
  const source = findIconSource(cwd, cfg);
  if (!source) {
    console.warn('⚠ No app icon source found (manifest icons or config `icon`) — keeping template icons');
    return;
  }
  // Pick an image rasterizer: `sips` (macOS) OR ImageMagick (`magick` v7 / `convert` v6) so icons are
  // generated on Linux/CI too (GitHub ubuntu runners ship ImageMagick) — NOT just macOS. Previously CI
  // skipped icon gen entirely → the default NativeScript "N" icon shipped. If NEITHER tool exists, keep
  // the template icons rather than failing the build.
  const ras = imageRasterizer();
  if (!ras) {
    console.warn('⚠ no image tool (sips / ImageMagick) — keeping template icons; install ImageMagick or build on macOS');
    return;
  }
  const w = ras.width(source);
  if (w && w < 512) console.warn(`⚠ Icon source is ${w}px — below the 512px App Store minimum (using it anyway)`);

  const resize = (px: number, dest: string) => ras.resize(source, px, dest);

  const iconset = join(outDir, 'App_Resources/iOS/Assets.xcassets/AppIcon.appiconset');
  if (existsSync(iconset)) {
    const contents = JSON.parse(readFileSync(join(iconset, 'Contents.json'), 'utf8'));
    for (const img of contents.images as Array<{ size: string; scale: string; filename: string }>) {
      const px = Math.round(parseFloat(img.size) * parseFloat(img.scale));
      resize(px, join(iconset, img.filename));
    }
  }

  const ANDROID_DENSITIES: Record<string, number> = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
  const res = join(outDir, 'App_Resources/Android/src/main/res');
  for (const [density, px] of Object.entries(ANDROID_DENSITIES)) {
    const dir = join(res, `mipmap-${density}`);
    if (existsSync(dir)) resize(px, join(dir, 'ic_launcher.png'));
  }

  // Android 8+ (API 26+) renders the ADAPTIVE icon — mipmap-anydpi-v26/ic_launcher.xml's
  // <foreground>, NOT ic_launcher.png. The NS template ships a vector foreground (the default "N"),
  // so without this the launcher icon stays the template's. Generate full-bleed foreground rasters
  // (108dp per density) from the maskable icon and repoint the adaptive XML at them.
  const fgSource = findMaskableSource(cwd, cfg);
  const ADAPTIVE_DP = 108;
  const adaptiveXml = join(res, 'mipmap-anydpi-v26/ic_launcher.xml');
  if (fgSource && existsSync(adaptiveXml)) {
    const resizeFrom = (src: string, px: number, dest: string) => ras.resize(src, px, dest);
    for (const [density, baseline] of Object.entries(ANDROID_DENSITIES)) {
      const dir = join(res, `mipmap-${density}`);
      if (!existsSync(dir)) continue;
      const px = Math.round((baseline / 48) * ADAPTIVE_DP); // scale 48dp baseline → 108dp foreground
      resizeFrom(fgSource, px, join(dir, 'ic_launcher_foreground.png'));
    }
    let xml = readFileSync(adaptiveXml, 'utf8');
    xml = xml.replace(/(<foreground[^>]*android:drawable=")[^"]*(")/, '$1@mipmap/ic_launcher_foreground$2');
    writeFileSync(adaptiveXml, xml);
  }
  console.log(`  icon ← ${source} (${w}px)`);
}

/** Tint the iOS launch screen to the configured background color. */
function stampLaunchScreen(outDir: string, cfg: AppwrapConfig): void {
  if (!cfg.backgroundColor) return;
  const hex = cfg.backgroundColor.replace('#', '');
  if (!/^[0-9a-fA-F]{6}$/.test(hex)) return;

  // iOS: tint the storyboard launch background.
  const storyboard = join(outDir, 'App_Resources/iOS/LaunchScreen.storyboard');
  if (existsSync(storyboard)) {
    const ch = (i: number) => (parseInt(hex.slice(i, i + 2), 16) / 255).toFixed(4);
    const src = readFileSync(storyboard, 'utf8').replace(
      /<color key="backgroundColor"[^/]*\/>/g,
      `<color key="backgroundColor" red="${ch(0)}" green="${ch(2)}" blue="${ch(4)}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>`
    );
    writeFileSync(storyboard, src);
  }

  // Android: replace the default NativeScript splash (background bitmap + NS logo) with a solid fill
  // of the app's backgroundColor — parity with the iOS solid launch screen, and no NS branding flash.
  const splash = join(outDir, 'App_Resources/Android/src/main/res/drawable-nodpi/splash_screen.xml');
  if (existsSync(splash)) {
    writeFileSync(splash,
      `<?xml version="1.0" encoding="utf-8"?>\n` +
      `<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:gravity="fill">\n` +
      `    <item>\n` +
      `        <shape android:shape="rectangle">\n` +
      `            <solid android:color="#${hex.toUpperCase()}" />\n` +
      `        </shape>\n` +
      `    </item>\n` +
      `</layer-list>\n`
    );
  }
}

const VERSION_FILE = '.appwrap-version';

/** Read a package.json version, or '?' if unreadable. */
function pkgVersion(pkgPath: string): string {
  try {
    return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? '?';
  } catch {
    return '?';
  }
}

/** Stamp `.appwrap-version` into the wrapper — the provenance record that makes `native/`
 * a disposable, regenerable artifact: which CLI/shell/protocol generated it, from which app.
 * Its presence also marks the dir as appwrap-managed (so re-`init` regenerates it safely). */
function stampVersionManifest(outDir: string, cfg: AppwrapConfig): void {
  const manifest = {
    cli: pkgVersion(resolve(import.meta.dir, '../package.json')),
    shell: pkgVersion(join(TEMPLATE_DIR, 'package.json')),
    protocol: 1,
    app: { id: cfg.id, version: cfg.version, build: buildNumberOf(cfg) },
    note: 'Generated by `appwrap init` — this directory is DISPOSABLE. Gitignore it; regenerate with `appwrap init`. Custom native code goes in your `overrides/` dir, not here.',
  };
  writeFileSync(join(outDir, VERSION_FILE), JSON.stringify(manifest, null, 2) + '\n');
}

/** Pure-native escape hatch: copy the consumer's overrides dir OVER the generated wrapper, last,
 * so it wins. For legacy/custom native code the declarative config can't express. */
function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig): void {
  const dir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
  if (!existsSync(dir)) return;
  cpSync(dir, outDir, { recursive: true, force: true });
  console.log(`  over ← ${dir} (native overrides applied)`);
}

function copyPwa(cwd: string, outDir: string, cfg: AppwrapConfig): void {
  // Stage the PWA OUTSIDE appPath ('app') — in a sibling `www-src/` — so NativeScript's webpack
  // never runs its loaders (css2json etc.) over real web CSS/assets. webpack.config.js copies
  // `www-src` → the bundle's `www` verbatim; the app:// scheme handler serves it at runtime.
  const www = join(outDir, 'www-src');
  const legacyWww = join(outDir, 'app/www'); // clear any pre-isolation staging
  rmSync(legacyWww, { recursive: true, force: true });
  // server loader loads `serverUrl` live — the bundle is unused. Don't copy it; and clear any stale
  // www so it isn't shipped.
  if (cfg.loader === 'server') {
    rmSync(www, { recursive: true, force: true });
    // Loudly surface the live URL the app will load — the single most deploy-critical value for a
    // server loader (easy to get wrong via env/scheme/cache). Visible in every sync/deploy output.
    console.log(`  🌐 serverUrl → ${cfg.serverUrl}  (the app loads this live; verify it before shipping)`);
    console.log('  www  ← skipped (loader:server loads serverUrl)');
    return;
  }
  const dist = resolve(cwd, cfg.pwaDist);
  const entry = join(dist, cfg.entry ?? 'index.html');
  if (!existsSync(entry)) {
    console.error(`✖ PWA entry not found: ${entry} — build your PWA first`);
    process.exit(1);
  }
  rmSync(www, { recursive: true, force: true });
  mkdirSync(www, { recursive: true });
  cpSync(dist, www, { recursive: true });
  console.log(`  www  ← ${dist}`);
  vendorBackendAssets(www, cfg);
}

/** Fetch backend-served static assets (cfg.vendorPaths) into the bundle so they resolve offline at
 * app://. Pins them to the build — re-fetched on every init/sync. Synchronous via curl. */
function vendorBackendAssets(www: string, cfg: AppwrapConfig): void {
  if (!cfg.vendorPaths?.length) return;
  if (!cfg.backendOrigin) {
    console.error('✖ vendorPaths requires backendOrigin in the appwrap config');
    process.exit(1);
  }
  const origin = cfg.backendOrigin.replace(/\/+$/, '');
  for (const p of cfg.vendorPaths) {
    const rel = p.replace(/^\/+/, '');
    const url = `${origin}/${rel}`;
    const dest = join(www, rel);
    mkdirSync(dirname(dest), { recursive: true });
    const tmp = `${dest}.tmp`;
    // Fetch to a temp file so a transient failure never truncates a previously-vendored asset.
    // Retry a couple times (the backend can briefly reset under deploy/cold-start), and on total
    // failure fall back to the cached copy if one exists rather than breaking the whole sync.
    let ok = false;
    let lastErr: unknown;
    for (let attempt = 0; attempt < 3 && !ok; attempt++) {
      try {
        execFileSync('curl', ['-fsSL', '--retry', '2', url, '-o', tmp], { stdio: 'pipe' });
        cpSync(tmp, dest);
        ok = true;
      } catch (e: unknown) {
        lastErr = e;
      }
    }
    rmSync(tmp, { force: true });
    if (ok) {
      console.log(`  vendor ← ${url}`);
    } else if (existsSync(dest) && readFileSync(dest).length > 0) {
      console.warn(`⚠ vendor fetch failed: ${url} — using cached copy (backend unreachable).`);
      const err = execErrText(lastErr);
      if (err) console.warn(`  ${err.trim()}`);
    } else {
      console.error(`✖ vendor fetch failed: ${url} (backend reachable? path correct?) — no cached copy to fall back to`);
      const err = execErrText(lastErr);
      if (err) console.error(err.trim());
      process.exit(1);
    }
  }
}

/** Walk up from `start` to the git repo root (dir containing `.git`); fall back to `start`. */
function gitRoot(start: string): string {
  let dir = start;
  while (true) {
    if (existsSync(join(dir, '.git'))) return dir;
    const parent = dirname(dir);
    if (parent === dir) return start; // reached filesystem root, no .git found
    dir = parent;
  }
}

/** True when `root` is the appwrap framework monorepo itself (not an external consumer project).
 * Running `appwrap init` on an in-repo example (examples/*) resolves `gitRoot` to the framework root,
 * so scaffolding consumer CI workflows there pollutes the framework's OWN `.github/workflows` with a
 * stray app-template workflow each init. The framework manages its own CI — skip the workflow scaffold. */
export function isFrameworkRepo(root: string): boolean {
  return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
}

/** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/).
 * GH workflows are never overwritten (users may customize them). The fastlane lane IS appwrap-managed
 * (the release recipe, not for hand-editing — see AGENTS.md), so it is RE-EMITTED on `--force` to keep
 * the recipe current after a framework upgrade; without --force it's still first-time-only. */
function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false): void {
  if (!existsSync(CI_TEMPLATE_DIR)) return;
  const repoRoot = gitRoot(cwd);
  // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
  // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
  // [from, to, overwritable]
  const targets: Array<[string, string, boolean]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane'), force]];
  // …but if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold consumer
  // workflows into the framework's .github — that's the stray-workflow-each-init bug.
  if (isFrameworkRepo(repoRoot)) {
    console.log('  ci   ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
  } else {
    targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows'), false]);
  }
  for (const [from, to, overwrite] of targets) {
    mkdirSync(to, { recursive: true });
    cpSync(from, to, { recursive: true, force: overwrite, errorOnExist: false });
  }
  // Pin the emitted workflow's `bunx @livx.cc/appwrap@^x.y.z` to THIS CLI's version floor, so a CI run
  // using a freshly-emitted workflow can't silently resolve an older published build that lacks the
  // `init`/`release` commands (it would 404 loudly instead). Idempotent: re-init finds no placeholder.
  if (!isFrameworkRepo(repoRoot)) {
    const wf = join(repoRoot, '.github/workflows/appwrap-release-ios.yml');
    if (existsSync(wf)) writeFileSync(wf, readFileSync(wf, 'utf8').replaceAll('__APPWRAP_VERSION__', CLI_VERSION));
  }
  // Stamp the app id + team into the emitted fastlane (signing needs them; the templates ship
  // `__APP_ID__`/`__TEAM_ID__` placeholders). Idempotent: re-init finds no placeholders → no-op.
  const fastlaneDir = join(outDir, 'fastlane');
  for (const file of ['Fastfile', 'Matchfile']) {
    const p = join(fastlaneDir, file);
    if (!existsSync(p)) continue;
    const stamped = readFileSync(p, 'utf8')
      .replaceAll('__APP_ID__', cfg.id)
      .replaceAll('__TEAM_ID__', cfg.teamId ?? '');
    writeFileSync(p, stamped);
  }
  const ci = isFrameworkRepo(repoRoot)
    ? '  ci   ← fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
    : '  ci   ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers';
  console.log(ci);
}

/**
 * Reproduce native/ from source — the shared core of `init` and `sync`. Copies the runtime shell
 * template, re-stamps EVERY config artifact, and re-copies the built PWA. `native/` is disposable, so a
 * full copy every time is correct — and is what keeps `sync` from silently shipping stale runtime/config
 * (the old split made `sync` skip the template + nsconfig id + version manifest → three drift footguns).
 * Excludes the first-time scaffold (managed-guard, CI, .gitignore) + overrides/version-manifest, which the
 * callers sequence around this so overrides win LAST and the marker writes after.
 */
function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean; flags?: Record<string, string> } = {}): void {
  const req = nativeReqs(cfg);
  if (opts.firstRun && !req.explicit) {
    console.log('  ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
  }
  cpSync(TEMPLATE_DIR, outDir, {
    recursive: true,
    force: true, // explicit: Bun's cpSync does not overwrite existing files by default
    // modules-native/ is copied selectively per active module (copyModuleNativeSrc), not wholesale.
    // Match RELATIVE to TEMPLATE_DIR — when installed from npm, TEMPLATE_DIR itself sits under
    // node_modules/, so testing the absolute path would wrongly exclude the entire template.
    filter: (src) => !/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(TEMPLATE_DIR.length)),
  });
  stampShellConfig(outDir, cfg);
  stampNativeScriptConfig(outDir, cfg);
  stampIOSDisplayName(outDir, cfg, req);
  stampTeamId(outDir, cfg, { cwd, configPath: resolveConfigPath(cwd, opts.flags ?? {}) });
  stampDeviceFamily(outDir, cfg);
  stampAndroidAppName(outDir, cfg, req);
  stampAndroidVersion(outDir, cfg);
  stampAndroidGradleDeps(outDir, req.androidGradleDeps);
  stampKotlin(outDir, req.androidKotlin);
  generateModuleArtifacts(outDir, req);
  copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
  stampLaunchScreen(outDir, cfg);
  stampStoreKit(cwd, outDir, cfg);
  stampPush(cwd, outDir, cfg);
  stampEntitlements(outDir, cfg, req); // unified app.entitlements: module entitlements + push aps-environment
  stampPrivacyManifest(outDir, cfg, req); // ATT tracking declarations into the store-readiness privacy manifest
  generateIcons(cwd, outDir, cfg);
  copyPwa(cwd, outDir, cfg);
}

async function init(cwd: string, flags: Record<string, string>): Promise<void> {
  const cfg = await loadConfig(cwd, flags);
  const outDir = resolve(cwd, flags.out ?? 'native');

  if (!existsSync(TEMPLATE_DIR)) {
    console.error(`✖ Runtime template not found at ${TEMPLATE_DIR}`);
    process.exit(1);
  }

  // Managed-model guard: re-`init` regenerates an appwrap-managed wrapper freely (it's disposable),
  // but refuse to clobber a directory we didn't generate unless --force is passed.
  if (existsSync(outDir)) {
    const managed = existsSync(join(outDir, VERSION_FILE));
    const nonEmpty = readdirSync(outDir).length > 0;
    if (nonEmpty && !managed && !('force' in flags)) {
      console.error(
        `✖ ${outDir} exists and is not an appwrap-managed wrapper (no ${VERSION_FILE}).\n` +
          `  Re-run with --force to overwrite it, or choose a different --out.`
      );
      process.exit(1);
    }
  }

  console.log(`🎁 appwrap init → ${outDir}`);
  mkdirSync(outDir, { recursive: true });
  regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
  copyCiTemplates(cwd, outDir, cfg, 'force' in flags); // GH workflows: first-time only; fastlane lane: re-emit on --force
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
  stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
  console.log(`✓ Wrapper ready (generated — gitignore \`${flags.out ?? 'native'}/\`, regenerate with \`appwrap init\`).\n  Run it: appwrap dev ios   (or: appwrap dev android)`);
}

// `sync` = the same regenerate as `init`, minus the first-time guard/scaffold. It is a TRUE refresh from
// source (shell + config + PWA), so runtime/config edits never silently lag behind. `native/` is
// disposable; re-copying the shell costs ~ms (the real cost is the later `ns build`, which both share).
async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: AppwrapConfig): Promise<void> {
  const cfg = cfgOverride ?? await loadConfig(cwd, flags);
  const outDir = resolve(cwd, flags.out ?? 'native');
  if (!existsSync(outDir)) {
    console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
    process.exit(1);
  }
  regenerateCore(cwd, outDir, cfg, { flags });
  applyOverrides(cwd, outDir, cfg); // overrides win last
  stampVersionManifest(outDir, cfg); // keep the managed-marker / provenance current
  console.log('✓ Synced.');
}

/** First non-internal IPv4 — so a physical device on the LAN can reach the dev server (localhost won't). */
function lanIp(): string | null {
  for (const addrs of Object.values(networkInterfaces())) {
    for (const a of addrs ?? []) {
      if (a.family === 'IPv4' && !a.internal) return a.address;
    }
  }
  return null;
}

/** Resolve the `--url <devserver>` / `--port <p>` dev-server URL, or null when neither is given.
 * Explicit `--url` wins; else `http://<lan-ip>:<port>` (port default 5173). Exits if no LAN IP. */
function resolveDevUrl(flags: Record<string, string>): string | null {
  if (!('url' in flags) && !('port' in flags)) return null;
  if (flags.url) return flags.url;
  const ip = lanIp();
  if (!ip) {
    console.error('✖ Could not detect a LAN IP — pass --url http://<host>:<port> explicitly');
    process.exit(1);
  }
  return `http://${ip}:${flags.port ?? '5173'}`;
}

/** `--debug` fold-in: open the on-device WebView inspector. Android adb-forwards the devtools socket →
 * chrome://inspect; iOS prints the Safari Web Inspector path. Best-effort (a non-debug build / not-running
 * app just gets a hint). Shared by `dev --debug` and the `debug` back-compat alias. */
function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platform: 'ios' | 'android', outDir: string): void {
  if (platform === 'android') {
    const adb = androidAdb();
    const device = resolveDevice(outDir, 'android', flags).id;
    const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
    if (pid) {
      try {
        execFileSync(adb, ['-s', device, 'forward', 'tcp:9222', `localabstract:webview_devtools_remote_${pid}`], { stdio: 'pipe' });
        console.log('✓ WebView devtools forwarded → open chrome://inspect (or http://localhost:9222) in desktop Chrome to inspect the page.');
      } catch {
        console.log('⚠ Could not forward the devtools socket — open chrome://inspect and look for the device there.');
      }
    } else {
      console.log(`⚠ ${cfg.id} not running yet — open chrome://inspect once it launches.`);
    }
    console.log('  (Needs a DEBUG build — `appwrap dev`/`deploy android` installs one with the inspector enabled.)\n');
  } else {
    console.log('▶ iOS WebView inspector: Safari → Develop → [your iPhone] → [the app]. Enable it first in iOS Settings → Safari → Advanced → Web Inspector.\n');
  }
}

/** `appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]` — the
 * live-dev loop. Subsumes the old `run`/`debug` verbs AND the old `dev` (loader:server stamp).
 *
 *  Default target = the physical DEVICE: clean deploy (== `deploy`, the shared path — NOT reimplemented)
 *  → stay ATTACHED streaming the WebView console + watch project sources → rebuild+reinstall on save.
 *  We MUST NOT use `ns run` livesync on a device — it throws `Invalid version … Got type "object"`, an
 *  ns-internal semver bug we can't fix; so device-dev is deploy + logs + a plain rebuild watch loop.
 *
 *  Flags:
 *   --sim       → emulator/simulator via `ns run` (HMR is reliable there); `--debug` → `ns debug`.
 *   --url/--port→ stamp loader:'server' at that dev-server URL (web hot-reloads inside the WebView), deploy + attach.
 *   --detached  → deploy + exit (install & launch only; don't attach/watch).
 *   --debug     → also open the WebView inspector (chrome://inspect / Safari), then attach.
 */
async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
  const platform = positionals[0];
  const sim = 'sim' in flags || positionals[1] === 'sim';
  const wantDebug = 'debug' in flags;
  if (platform !== 'ios' && platform !== 'android') {
    console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]');
    process.exit(1);
  }
  const cfg = await loadConfig(cwd, flags);
  const outDir = resolve(cwd, flags.out ?? 'native');
  if (!existsSync(outDir)) {
    console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
    process.exit(1);
  }

  // `--url`/`--port` → point the wrapper at a live dev server (loader:'server', web HMR inside the WebView).
  const devUrl = resolveDevUrl(flags);
  // The cfg the deploy/sim path stamps: server-loader when a dev URL is given, else the bundled config.
  // debug:true here = keep-awake + WebView inspector + the dev-server SSL bypass (LAN self-signed certs).
  const effectiveCfg: AppwrapConfig = devUrl
    ? { ...cfg, loader: 'server', serverUrl: devUrl, debug: true }
    : cfg;
  if (devUrl) {
    console.log(`✓ Dev loader → ${devUrl} (web hot-reloads from the dev server inside the WebView)`);
    console.log('  Dev server must bind 0.0.0.0 (vite: `server.host: true` / `--host`) so the device can reach it.');
    if (devUrl.startsWith('https:') && platform === 'android') {
      console.log("  ⚠ Android: serve the dev server over HTTP, not HTTPS — the WebView can't bypass wss TLS errors (page loads, HMR won't).");
    }
  }

  // ── --sim: emulator/simulator → ns run (HMR) / ns debug. Reliable there; refresh the wrapper first. ──
  if (sim) {
    // Preserve an already-active dev loader (stamped by a prior `dev --url`) if no URL was passed now.
    const stamped = !devUrl ? readStampedLoader(outDir) : null;
    const simCfg: AppwrapConfig = devUrl
      ? effectiveCfg
      : stamped?.loader === 'server'
        ? { ...cfg, loader: 'server', serverUrl: stamped.serverUrl, debug: stamped.debug }
        : cfg;
    regenerateCore(cwd, outDir, simCfg, { flags });
    applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
    stampVersionManifest(outDir, simCfg);
    console.log(simCfg.loader === 'server' ? `✓ Refreshed wrapper (dev loader → ${simCfg.serverUrl})` : '✓ Refreshed wrapper from template + PWA');
    runNs(outDir, [wantDebug ? 'debug' : 'run', platform, ...(flags.device ? ['--device', flags.device] : [])]);
    return;
  }

  // ── device: clean deploy (the shared `deploy` path — NO ns livesync) ──
  await deploy(cwd, flags, [platform], devUrl ? effectiveCfg : undefined);
  // Follow-ups reuse the just-deployed device from last-device memory — drop an interactive `-d`.
  const followFlags = { ...flags }; delete followFlags.d;

  if (wantDebug) openInspector(effectiveCfg, followFlags, platform, outDir);

  if ('detached' in flags) {
    console.log('\n✓ --detached — installed & launched; not attaching/watching.');
    return;
  }

  // Attach: stream the WebView console. With a bundled loader we ALSO watch sources → rebuild+reinstall
  // on save. With a dev-server loader (--url) the web hot-reloads from the server INSIDE the WebView, so
  // a native rebuild is pointless (and would re-stamp the bundled loader) — just stream the console.
  if (devUrl) {
    console.log('\n▶ dev: web hot-reloads from the dev server inside the WebView; streaming the console. Ctrl-C to stop.');
    await logs(cwd, followFlags, [platform]);
    return;
  }
  console.log(`\n▶ dev: streaming WebView console + watching sources (edit a file → rebuild+reinstall). Ctrl-C to stop.`);
  const logArgs = [import.meta.path, 'logs', platform];
  if (followFlags.device) logArgs.push('--device', followFlags.device);
  if (followFlags.out) logArgs.push('--out', followFlags.out);
  if (followFlags.config) logArgs.push('--config', followFlags.config);
  // `detached: true` puts the log child in its OWN process group so we can kill the WHOLE group —
  // the child is `bun … logs`, which itself spawns `adb logcat`; `logChild.kill()` would only reap the
  // `bun` and orphan the `adb logcat` grandchild when the signal hits the leader pid (e.g. `kill <pid>`
  // / a supervisor, not interactive Ctrl-C which signals the group). `process.kill(-pid)` reaps both.
  const logChild = spawn('bun', logArgs, { stdio: 'inherit', detached: true });
  const stop = () => { try { if (logChild.pid) process.kill(-logChild.pid); } catch { /* already gone */ } };
  process.on('exit', stop);
  process.on('SIGINT', () => { stop(); process.exit(0); });
  await watchAndRedeploy(cwd, followFlags, platform);
}

/** Ensure the wrapper's deps are installed (bun — honoring the repo's package manager, so callers
 * never `cd native` or remember the tool) then exec an `ns` subcommand in it with inherited stdio.
 * The single place appwrap shells out to ns for the interactive run loop. Best-effort bun: we only
 * install when node_modules is absent — if ns later reinstalls on package.json drift it uses npm
 * (NativeScript has no bun package-manager mode), so trustedDependencies in the shell package.json
 * is what keeps the bun-installed tree behaving like npm's (parcel/watcher, ns CLI hooks). */
/** Resolve a usable Android SDK dir: an already-valid ANDROID_HOME/ANDROID_SDK_ROOT, else the first
 * common install location that actually contains an SDK. Lets `deploy/run android` work without the
 * user having exported ANDROID_HOME in their shell (the #1 "ns can't find the SDK" foot-gun). */
function resolveAndroidSdk(): string | undefined {
  const env = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
  if (env && existsSync(env)) return env;
  const home = process.env.HOME || '';
  const candidates = [
    join(home, 'Library/Android/sdk'),    // macOS (Android Studio default)
    join(home, 'Library/Android/Sdk'),
    join(home, 'Android/Sdk'),            // Linux (Android Studio default)
    '/usr/local/share/android-sdk',       // homebrew cask
    '/opt/android-sdk',
  ];
  // A usable SDK has platform-tools (adb) or installed platforms; cmdline-tools-only dirs don't count.
  return candidates.find((d) => existsSync(join(d, 'platform-tools')) || existsSync(join(d, 'platforms')));
}

function runNs(outDir: string, args: string[]): void {
  const env: NodeJS.ProcessEnv = { ...process.env };
  // Android: inject a discovered SDK so `ns` finds it even when the user's shell never exported
  // ANDROID_HOME (new terminal not sourced, conda base shell, etc.) — the common deploy blocker.
  if (args.includes('android')) {
    const sdk = resolveAndroidSdk();
    if (sdk) {
      env.ANDROID_HOME = sdk;
      env.ANDROID_SDK_ROOT = sdk;
      env.PATH = [process.env.PATH, join(sdk, 'platform-tools'), join(sdk, 'emulator')].filter(Boolean).join(':');
      if (!process.env.ANDROID_HOME) console.log(`  android SDK ← ${sdk}  (ANDROID_HOME not set; auto-detected)`);
    } else {
      console.warn('  ⚠ No Android SDK found (set ANDROID_HOME). Install Android Studio or the command-line tools, then `sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"`.');
    }
  }
  if (!existsSync(join(outDir, 'node_modules'))) {
    console.log(`▶ bun install  (cwd: ${outDir})`);
    execFileSync('bun', ['install'], { cwd: outDir, stdio: 'inherit', env });
  }
  console.log(`▶ ns ${args.join(' ')}  (cwd: ${outDir})`);
  execFileSync('ns', args, { cwd: outDir, stdio: 'inherit', env });
}

/** Read the loader currently stamped into the generated shell (app/shell/config.ts). Used to
 * preserve an ACTIVE dev loader across a `dev --sim` refresh. Returns null if the
 * generated config is absent/unreadable — the caller then falls back to the appwrap config. */
function readStampedLoader(outDir: string): { loader: string; serverUrl: string; debug: boolean } | null {
  try {
    const src = readFileSync(join(outDir, 'app/shell/config.ts'), 'utf8');
    const loader = src.match(/loader:\s*"([^"]*)"/)?.[1];
    if (!loader) return null;
    return {
      loader,
      serverUrl: src.match(/serverUrl:\s*"([^"]*)"/)?.[1] ?? '',
      debug: /debug:\s*true/.test(src),
    };
  } catch {
    return null;
  }
}

/** Lean watch loop for `dev <platform>`: re-run the clean deploy path whenever a project
 * source file changes (debounced). Skips generated/output dirs. NOT ns livesync — a full rebuild+
 * reinstall, which is the only device-safe path (see `run`'s note). macOS recursive fs.watch. */
async function watchAndRedeploy(cwd: string, flags: Record<string, string>, platform: 'ios' | 'android'): Promise<void> {
  const { watch } = await import('fs');
  const ignore = /(^|\/)(native|node_modules|dist|\.git|\.appwrap)(\/|$)/;
  console.log(`\n👀 watching ${cwd} for changes → rebuild+reinstall on save (Ctrl-C to stop).`);
  let timer: ReturnType<typeof setTimeout> | undefined;
  let busy = false;
  watch(cwd, { recursive: true }, (_evt, file) => {
    if (!file || ignore.test(String(file)) || busy) return;
    clearTimeout(timer);
    timer = setTimeout(async () => {
      busy = true;
      console.log(`\n🔁 change: ${file} → redeploying…`);
      try { await deploy(cwd, flags, [platform]); } catch (e) { console.error(`⚠ redeploy failed: ${(e as Error).message}`); }
      busy = false;
    }, 600);
  });
  await new Promise<void>(() => { /* run until Ctrl-C */ });
}

/** `appwrap build <ios|android> [--release] [--aab]` — store-readiness build path. Re-stamps config,
 * re-copies the PWA, then delegates the actual compile to NativeScript with the right flags. Release
 * Android signing comes from env (APPWRAP_ANDROID_KEYSTORE[_PASSWORD|_ALIAS|_ALIAS_PASSWORD]) — secrets
 * never live in the appwrap config. iOS distribution signing/upload is the fastlane release lane's job (the
 * cicd templates); `--release` here just builds the Release config for the device. */
async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
  const platform = positionals[0];
  if (platform !== 'ios' && platform !== 'android') {
    console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config <path>] [--out native]');
    process.exit(1);
  }
  const outDir = resolve(cwd, flags.out ?? 'native');
  if (!existsSync(outDir)) {
    console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
    process.exit(1);
  }
  // --build-number → process.env.APPWRAP_BUILD_NUMBER before sync() stamps the plist/gradle (same
  // mechanism `release` uses). Env-var path still honored by sync()'s buildNumberOf when no flag.
  try {
    applyBuildNumberFlag(flags['build-number']);
  } catch (e) {
    console.error(`✖ ${(e as Error).message}`);
    process.exit(1);
  }
  // Make sure the wrapper reflects the latest config + PWA before compiling (also validates the config).
  await sync(cwd, flags);

  const release = 'release' in flags;
  const args = ['build', platform];
  if (release) args.push('--release');
  if (platform === 'ios' && release) args.push('--for-device');
  if (platform === 'android' && 'aab' in flags) args.push('--aab');

  if (platform === 'android' && release) {
    const ks = process.env.APPWRAP_ANDROID_KEYSTORE;
    if (!ks) {
      console.error(
        '✖ Release Android build needs a signing keystore. Set:\n' +
          '    APPWRAP_ANDROID_KEYSTORE=/abs/path/to.keystore\n' +
          '    APPWRAP_ANDROID_KEYSTORE_PASSWORD=…  APPWRAP_ANDROID_KEYSTORE_ALIAS=…  APPWRAP_ANDROID_KEYSTORE_ALIAS_PASSWORD=…\n' +
          '  (generate a throwaway one with `keytool -genkeypair -keystore upload.keystore -alias upload -keyalg RSA -keysize 2048 -validity 10000`).'
      );
      process.exit(1);
    }
    args.push(
      '--key-store-path', ks,
      '--key-store-password', process.env.APPWRAP_ANDROID_KEYSTORE_PASSWORD ?? '',
      '--key-store-alias', process.env.APPWRAP_ANDROID_KEYSTORE_ALIAS ?? '',
      '--key-store-alias-password', process.env.APPWRAP_ANDROID_KEYSTORE_ALIAS_PASSWORD ?? ''
    );
  }

  console.log(`▶ ns ${args.join(' ').replace(/(--key-store-password|--key-store-alias-password) [^ ]*/g, '$1 ****')}  (cwd: ${outDir})`);
  execFileSync('ns', args, { cwd: outDir, stdio: 'inherit' });
  if (platform === 'ios' && release) {
    console.log('ℹ App Store distribution (archive + upload) goes through the fastlane release lane (native/fastlane) — needs a paid team + ASC API key.');
  }
}

/** `appwrap release ios` — the ONE build+sign+upload-to-TestFlight command, identical locally and in CI.
 *
 * It re-stamps the config + PWA (`sync`) and then delegates the full archive/sign/upload to the emitted
 * fastlane lane (`native/fastlane` `:beta`) — the SINGLE source of truth for the iOS release recipe (the
 * lane runs `ns prepare ios --release` → match signing → build_app → upload_to_testflight). CI is a thin
 * wrapper that just calls this. Keeping the recipe in fastlane (not duplicated in TS) means local and CI
 * run byte-identical steps.
 *
 * Knobs (all optional; mirror the workflow):
 *   --server-url <url>   override loader:'server' serverUrl for this release (lab vs prod backend)
 *   --env <name>         convenience: resolve serverUrl from cfg.envs[name] when present (see config)
 *   --build-number <n>   set the store CFBundleVersion (sets APPWRAP_BUILD_NUMBER for the lane)
 *
 * Signing/ASC config is read from env by the lane (ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 /
 * MATCH_GIT_URL / MATCH_PASSWORD) — secrets never live in appwrap.config.
 *
 * `appwrap submit ios` reuses this same body with `lane: 'release'` → fastlane `:release`
 * (`upload_to_app_store`): a binary-only App Store production promote. Metadata/screenshots stay in
 * ASC; pass `--submit-for-review` to also submit the build for review. */
async function release(cwd: string, flags: Record<string, string>, positionals: string[], lane: 'beta' | 'release' = 'beta'): Promise<void> {
  const submit = lane === 'release';
  const cmd = submit ? 'submit' : 'release';
  const platform = positionals[0];
  if (platform !== 'ios') {
    console.error(`Usage: appwrap ${cmd} ios [--server-url <url>] [--env <name>] [--build-number <n>]` +
      (submit ? ' [--submit-for-review]' : '') + ' [--config <path>] [--out native]\n' +
      '  (Android: `appwrap build android --release --aab` then `fastlane android beta`.)');
    process.exit(1);
  }
  const cfg = await loadConfig(cwd, flags);
  const outDir = resolve(cwd, flags.out ?? 'native');
  if (!existsSync(outDir)) {
    console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first (CI must \`init\`, native/ is gitignored).`);
    process.exit(1);
  }
  const fastfile = join(outDir, 'fastlane', 'Fastfile');
  if (!existsSync(fastfile)) {
    console.error(`✖ No fastlane lane at ${fastfile} — run \`appwrap init\` to emit it (it carries the release recipe).`);
    process.exit(1);
  }

  // Optional backend-url override for loader:'server' apps (lab vs prod). --server-url wins; else
  // --env resolves from cfg.envs[name] when the consumer config declares it.
  let serverUrl = flags['server-url'] || undefined;
  if (!serverUrl && flags.env) {
    serverUrl = (cfg as { envs?: Record<string, string> }).envs?.[flags.env];
    if (!serverUrl) {
      console.error(`✖ --env ${flags.env} given but cfg.envs[${flags.env}] is not set in the config.`);
      process.exit(1);
    }
  }
  const stampCfg = serverUrl ? { ...cfg, loader: 'server' as const, serverUrl } : cfg;

  // Build number: explicit flag → APPWRAP_BUILD_NUMBER. Stamping (sync → stampIOSDisplayName →
  // buildNumberOf) reads process.env.APPWRAP_BUILD_NUMBER, and sync() runs BEFORE fastlane archives
  // the (already-stamped) Info.plist — so the flag must land on process.env *here*, before sync(),
  // not only on the child env. In CI the workflow sets APPWRAP_BUILD_NUMBER itself (env path).
  try {
    applyBuildNumberFlag(flags['build-number']);
  } catch (e) {
    console.error(`✖ ${(e as Error).message}`);
    process.exit(1);
  }
  const env = { ...process.env };
  // `submit ios --submit-for-review` → also submit the binary for App Store review (the lane defaults
  // to a safe binary-only promote). The flag is boolean (presence = '' via parseArgs).
  if (submit && 'submit-for-review' in flags) env.APPWRAP_SUBMIT_FOR_REVIEW = 'true';

  // Re-stamp config + copy the latest PWA into native/ so the lane archives current sources. (The lane
  // also runs `ns prepare ios --release`; sync here makes the wrapper config/PWA authoritative first.)
  await sync(cwd, flags);
  if (serverUrl) {
    stampShellConfig(outDir, stampCfg);
    console.log(`✓ ${submit ? 'Submit' : 'Release'} loader → ${serverUrl}${flags.env ? ` (env: ${flags.env})` : ''}`);
  }

  console.log(`▶ fastlane ios ${lane}  (cwd: ${outDir}/fastlane → native/)${env.APPWRAP_BUILD_NUMBER ? `  build #${env.APPWRAP_BUILD_NUMBER}` : ''}`);
  try {
    execFileSync('fastlane', ['ios', lane], { cwd: outDir, stdio: 'inherit', env });
  } catch {
    console.error(`\n✖ ${submit ? 'App Store submit' : 'TestFlight release'} failed. Common causes:\n` +
      '  • Missing ASC/match env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 (base64), MATCH_GIT_URL, MATCH_PASSWORD.\n' +
      '  • Certs/profiles not seeded — run `fastlane match appstore` once against MATCH_GIT_URL.\n' +
      '  • CFBundleVersion already used for this marketing version → pass a higher --build-number.');
    process.exit(1);
  }
  console.log(submit
    ? `✓ Binary promoted to the App Store (production)${env.APPWRAP_SUBMIT_FOR_REVIEW === 'true' ? ' and submitted for review' : ' — submit for review in App Store Connect (or pass --submit-for-review)'}.`
    : '✓ Uploaded to TestFlight (App Store Connect processing — check the build list / wait for the email).');
}

interface AppleTeam { teamId: string; name: string; email?: string; paid: boolean }
interface DeviceInfo { id: string; name: string; model: string; transport: string }

/** The subset of a `xcrun devicectl list devices --json-output` device entry appwrap reads. */
interface DevicectlDevice {
  identifier?: string;
  deviceProperties?: { name?: string };
  hardwareProperties?: { platform?: string; marketingName?: string; productType?: string };
  connectionProperties?: { tunnelState?: string; transportType?: string };
}

/** Read Apple team metadata from provisioning profiles + distribution certs in the keychain.
 * Provisioning profiles give us the reliable teamId↔teamName mapping; distribution certs
 * often embed the account email in the display name. */
function detectAppleTeams(): AppleTeam[] {
  const teams = new Map<string, AppleTeam>();

  // 1. Provisioning profiles → teamId + teamName (most reliable)
  const profilesDir = join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles');
  if (existsSync(profilesDir)) {
    try {
      const files = readdirSync(profilesDir).filter((f) => f.endsWith('.mobileprovision'));
      for (const f of files) {
        try {
          const raw = execFileSync('security', ['cms', '-D', '-i', join(profilesDir, f)],
            { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
          const idMatch = raw.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/);
          const nameMatch = raw.match(/<key>TeamName<\/key>\s*<string>([^<]+)<\/string>/);
          if (idMatch && nameMatch) {
            const teamId = idMatch[1];
            const name = nameMatch[1];
            const free = /personal team/i.test(name);
            if (!teams.has(teamId)) teams.set(teamId, { teamId, name, paid: !free });
          }
        } catch { /* skip unreadable profile */ }
      }
    } catch { /* skip if dir unreadable */ }
  }

  // 2. Keychain distribution/Developer-ID certs → teamId + possible email in name
  try {
    const out = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'],
      { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
    for (const line of out.split('\n')) {
      const m = line.match(/"(?:Apple Distribution|Developer ID Application): (.+?) \(([A-Z0-9]{10})\)"/);
      if (!m) continue;
      const [, label, teamId] = m;
      const email = label.includes('@') ? label.trim() : undefined;
      const existing = teams.get(teamId);
      if (existing) {
        if (email && !existing.email) existing.email = email;
      } else {
        teams.set(teamId, { teamId, name: label.trim(), email, paid: !/personal team/i.test(label) });
      }
    }
  } catch { /* keychain unavailable */ }

  return [...teams.values()];
}

/** Arrow-key interactive selector. Returns the index of the chosen item. */
function arrowSelect(prompt: string, items: string[]): number {
  const tty = openSync('/dev/tty', 'r+');
  const write = (s: string) => writeSync(tty, s);
  const ESC = '\x1b';

  write(`${prompt}\n`);
  let idx = 0;
  const HINT = '\x1b[2m  ↑↓ / j k to move · Enter to confirm\x1b[0m';
  const render = (clear: boolean) => {
    if (clear) write(`\x1b[${items.length + 1}A`); // +1 for the hint line
    for (let i = 0; i < items.length; i++)
      write(`\r\x1b[K${i === idx ? '❯ ' : '  '}${items[i]}\n`);
    write(`\r\x1b[K${HINT}\n`);
  };
  render(false);

  // raw mode via stty
  execFileSync('stty', ['-icanon', '-echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
  const buf = Buffer.alloc(6);
  try {
    for (;;) {
      const n = readSync(tty, buf, 0, 6, null);
      const key = buf.slice(0, n).toString();
      if (key === `${ESC}[A` || key === 'k') { idx = (idx - 1 + items.length) % items.length; render(true); }
      else if (key === `${ESC}[B` || key === 'j') { idx = (idx + 1) % items.length; render(true); }
      else if (key === '\r' || key === '\n') break;
      else if (key === '\x03') { write('\n'); process.exit(1); } // Ctrl-C
    }
  } finally {
    execFileSync('stty', ['icanon', 'echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
    // Erase the hint line so the selected value prints cleanly after
    write(`\x1b[1A\r\x1b[K`);
    write('\n');
    closeSync(tty);
  }
  return idx;
}

/** Interactively prompt for an Apple team when teamId is unset. Shows enriched metadata
 * (email, paid/free) sourced from local keychain + provisioning profiles. */
function pickTeamIdInteractively(): { teamId: string; name: string } {
  const teams = detectAppleTeams();
  if (teams.length === 0) {
    console.error('✖ No Apple signing teams found in keychain/provisioning profiles.\n' +
      '  Sign into Xcode → Settings → Accounts, then re-run.');
    process.exit(1);
  }
  if (teams.length === 1) {
    const t = teams[0];
    console.log(`  team ← ${t.name} (${t.teamId})${t.email ? ` <${t.email}>` : ''} [${t.paid ? 'paid' : 'free'}] (only option)`);
    return { teamId: t.teamId, name: t.name };
  }
  const items = teams.map((t) => {
    const badge = t.paid ? '✓ paid' : '○ free';
    const email = t.email ? ` <${t.email}>` : '';
    return `${t.name} (${t.teamId})${email}  [${badge}]`;
  });
  const idx = arrowSelect('Found multiple Apple teams — pick one to use for signing:', items);
  return { teamId: teams[idx].teamId, name: teams[idx].name };
}

/** Y/n confirmation on the TTY (default-yes here). Reuses the global `prompt` primitive. A
 * non-interactive / piped stdin returns null → falls back to `def` ONLY when there's a real TTY;
 * a fully headless run never reaches here (callers gate on `process.stdout.isTTY` first), but be
 * defensive: if stdin can't be read, do NOT pin (safer to re-ask than to silently mutate config). */
function promptYesNo(message: string, def: boolean): boolean {
  if (!process.stdin.isTTY) return false;
  const suffix = def ? ' [Y/n] ' : ' [y/N] ';
  const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(message + suffix);
  if (ans == null) return false;
  const a = ans.trim().toLowerCase();
  if (a === '') return def;
  return a === 'y' || a === 'yes';
}

/** Persist `teamId` into the user's appwrap config so the interactive picker isn't re-run every
 * deploy. Pure string surgery (returns the new file content) so it's unit-testable across both
 * supported formats:
 *  - `.json` — set/replace the top-level `"teamId"` property (preserves 2-space indent).
 *  - `.ts`/`.js` — replace an existing `teamId:` field value (incl. the `YOUR_APPLE_TEAM_ID`
 *    placeholder), else insert a new `teamId: '<id>',` line near the other top-level fields
 *    (after `id:`, matching its indentation/quote style). If the shape is unexpected, returns
 *    `null` so the caller skips the write rather than corrupting the file. */
export function pinTeamIdInConfigSource(src: string, teamId: string, isJson: boolean): string | null {
  if (isJson) {
    let obj: Record<string, unknown>;
    try { obj = JSON.parse(src) as Record<string, unknown>; } catch { return null; }
    if (typeof obj !== 'object' || obj == null) return null;
    obj.teamId = teamId;
    return JSON.stringify(obj, null, 2) + (src.endsWith('\n') ? '\n' : '');
  }
  // TS/JS: replace an existing teamId field value, preserving its quote style.
  const existing = /(\bteamId\s*:\s*)(['"`])[^'"`]*\2/;
  if (existing.test(src)) {
    return src.replace(existing, (_m, lead: string, q: string) => `${lead}${q}${teamId}${q}`);
  }
  // No teamId field — insert after the `id:` field (mirroring its indentation + quote style).
  const idLine = /^([ \t]*)id\s*:\s*(['"`])[^'"`]*\2\s*,?[ \t]*$/m;
  const m = idLine.exec(src);
  if (!m) return null; // unfamiliar shape — don't risk corrupting it
  const indent = m[1];
  const quote = m[2];
  return src.slice(0, m.index + m[0].length)
    + `\n${indent}teamId: ${quote}${teamId}${quote},`
    + src.slice(m.index + m[0].length);
}

/** Write the pinned teamId to the resolved config file (thin IO wrapper over the pure helper). */
function pinTeamIdToConfig(configPath: string, teamId: string): void {
  if (!existsSync(configPath)) {
    console.warn(`  ⚠ could not pin teamId — config not found at ${configPath}`);
    return;
  }
  const src = readFileSync(configPath, 'utf8');
  const next = pinTeamIdInConfigSource(src, teamId, configPath.endsWith('.json'));
  if (next == null) {
    console.warn(`  ⚠ couldn't safely edit ${configPath} (unexpected shape) — leaving it untouched. Set teamId: '${teamId}' manually.`);
    return;
  }
  writeFileSync(configPath, next);
  console.log(`  ✓ pinned teamId: '${teamId}' to ${configPath}`);
}

// ── Build fingerprint for smart resume ───────────────────────────────────────────────────────────

/** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config.
 * App_Resources/ is intentionally excluded — sync() rewrites it every run, so its mtime always
 * changes and would make the fingerprint permanently stale.
 * Collision risk is acceptable — a false "match" just skips a redundant build, not a correctness bug. */
function buildFingerprint(cwd: string, cfg: { pwaDist?: string }, _outDir: string): string {
  const mtime = (p: string): number => {
    if (!existsSync(p)) return 0;
    try {
      const s = statSync(p);
      if (s.isDirectory()) {
        let sum = 0;
        for (const e of readdirSync(p, { withFileTypes: true }))
          sum += mtime(join(p, e.name));
        return sum;
      }
      return s.mtimeMs;
    } catch { return 0; }
  };
  const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
  const parts = [mtime(distDir), mtime(join(cwd, 'appwrap.config.ts'))];
  // Simple djb2-style hash — good enough for a build-skip check (not cryptographic).
  let h = 5381;
  for (const n of parts) h = (((h << 5) + h) ^ (n | 0)) >>> 0;
  return h.toString(36);
}

// Per-platform build cache (iOS .ipa / Android .apk) — separate files so the two don't clobber each
// other's fingerprint (that's what enables the build-skip on BOTH platforms).
const buildCacheFile = (platform: string) => `.appwrap-build-cache-${platform}.json`;
interface BuildCache { fingerprint: string; artifactPath: string; builtAt: string }

function readBuildCache(outDir: string, platform: string): BuildCache | null {
  try { return JSON.parse(readFileSync(join(outDir, buildCacheFile(platform)), 'utf8')); } catch { return null; }
}
function writeBuildCache(outDir: string, platform: string, cache: BuildCache): void {
  try { writeFileSync(join(outDir, buildCacheFile(platform)), JSON.stringify(cache, null, 2)); } catch { /* non-fatal */ }
}

/** Discover usable physical iOS devices via devicectl (USB + network). Excludes 'unavailable'
 * tunnels and non-iOS (watch). Returns [] if none. */
function listIosDevices(): DeviceInfo[] {
  const out = join(tmpdir(), `appwrap-devices-${process.pid}.json`);
  try {
    execFileSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', out], { stdio: 'pipe' });
    const j = JSON.parse(readFileSync(out, 'utf8')) as { result?: { devices?: DevicectlDevice[] } };
    rmSync(out, { force: true });
    return (j?.result?.devices ?? [])
      .filter((d) => d?.hardwareProperties?.platform === 'iOS'
        && d?.connectionProperties?.tunnelState !== 'unavailable')
      .map((d) => ({
        id: d.identifier ?? '',
        name: d?.deviceProperties?.name ?? '(unknown)',
        model: d?.hardwareProperties?.marketingName ?? d?.hardwareProperties?.productType ?? '',
        transport: d?.connectionProperties?.transportType ?? '',
      }));
  } catch {
    return [];
  }
}

// ─── Shared device resolver ───────────────────────────────────────────────────────────────────────
// One helper used by deploy/run/debug/logs/publish so every command shares the SAME device-selection
// UX + last-device memory. Resolution order:
//   --device <id|name> → exact match (error if not connected)
//   -d                 → always show the interactive list + number prompt
//   else               → the LAST chosen device (persisted) if still connected; else the only one;
//                        else the interactive list; none → clear error. The choice is persisted on
//                        success so the next command (e.g. `run`→`logs`) reuses it without re-asking.
// Persist the last device under the wrapper outDir (already gitignored by consumers, like the build
// cache) — not the consumer root, so it never shows up as a stray untracked file.
const lastDeviceFile = (outDir: string, platform: string) => join(outDir, `.appwrap-last-device-${platform}`);
function readLastDevice(outDir: string, platform: string): string | null {
  try { return readFileSync(lastDeviceFile(outDir, platform), 'utf8').trim() || null; } catch { return null; }
}
function writeLastDevice(outDir: string, platform: string, id: string): void {
  try { writeFileSync(lastDeviceFile(outDir, platform), id); } catch { /* non-fatal */ }
}

/** Enumerate connected devices for a platform as a uniform DeviceInfo[] (iOS via devicectl, Android
 * via adb — adb serials enriched with the product model for a readable picker). */
function listDevices(platform: 'ios' | 'android'): DeviceInfo[] {
  if (platform === 'ios') return listIosDevices();
  const adb = androidAdb();
  return listAndroidDevices(adb).map((serial) => {
    let model = '';
    try { model = execFileSync(adb, ['-s', serial, 'shell', 'getprop', 'ro.product.model'], { encoding: 'utf8' }).trim(); } catch { /* offline */ }
    return { id: serial, name: model || serial, model, transport: 'usb' };
  });
}

/** Interactive number-prompt picker over a device list. */
function pickInteractively(devices: DeviceInfo[]): DeviceInfo {
  // No TTY (CI / piped) → Bun's prompt() returns null → "Invalid selection". Give a clear directive instead.
  if (!process.stdout.isTTY) {
    console.error(`✖ ${devices.length} devices connected and no TTY to prompt — pass --device <id>. Connected: ${devices.map((d) => d.id).join(', ')}`);
    process.exit(1);
  }
  console.log('Connected devices:');
  devices.forEach((d, i) => console.log(`  ${i + 1}) ${d.name}${d.model && d.model !== d.name ? ` — ${d.model}` : ''}${d.transport ? ` [${d.transport}]` : ''}  (${d.id})`));
  const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(`Select device [1-${devices.length}]: `);
  const idx = Number(ans) - 1;
  if (!Number.isInteger(idx) || idx < 0 || idx >= devices.length) { console.error('✖ Invalid selection.'); process.exit(1); }
  return devices[idx];
}

/** Resolve the target device for a platform command (the reusable core). Persists the choice under
 * `outDir` so the next command (e.g. `run`→`logs`) reuses it. */
function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Record<string, string>): DeviceInfo {
  const devices = listDevices(platform);
  const noneMsg = platform === 'ios'
    ? '✖ No connected iOS device found. Plug in via USB (unlocked, "Trust") or pair over Wi-Fi.'
    : '✖ No authorized Android device. Connect via USB + accept the "Allow USB debugging" prompt (check with `adb devices`).';

  // --device <id|name> — exact (or unambiguous prefix) match against connected devices.
  if (flags.device) {
    const m = devices.find((d) => d.id === flags.device || d.name === flags.device) ?? devices.find((d) => d.id.startsWith(flags.device));
    if (!m) { console.error(`✖ --device "${flags.device}" not connected/authorized. Connected: ${devices.map((d) => d.id).join(', ') || '(none)'}`); process.exit(1); }
    writeLastDevice(outDir, platform, m.id);
    return m;
  }
  if (devices.length === 0) { console.error(noneMsg); process.exit(1); }

  // -d → always prompt. Otherwise prefer the remembered device, then the sole device.
  if (!('d' in flags)) {
    const last = readLastDevice(outDir, platform);
    const remembered = last ? devices.find((d) => d.id === last) : undefined;
    if (remembered) { console.log(`📱 Using ${remembered.name} (${remembered.id}) — last used.`); return remembered; }
    if (devices.length === 1) { console.log(`📱 Using ${devices[0].name} (${devices[0].id}) — only device connected.`); writeLastDevice(outDir, platform, devices[0].id); return devices[0]; }
  }
  const picked = pickInteractively(devices);
  writeLastDevice(outDir, platform, picked.id);
  return picked;
}

/** `appwrap deploy <ios|android> [--device <id|name>] [--no-launch]` — build for a device, auto-pick
 * the connected phone (USB or network; prompts if several), install + launch. Debug build (no
 * distribution signing) — for testing on your own device. Run the PWA build first (or via the script).
 * iOS has a bespoke Debug-IPA path (below); Android uses the adb toolchain (build → install → launch).
 * `dev` calls THIS shared path for its clean device deploy — deploy is the one-shot ship primitive. */
/** The project's web-build command: explicit `webBuild` in config, else `bun run build` if the
 * project's package.json has a "build" script. null when there's nothing to run. */
function detectWebBuildCmd(cwd: string, cfg: AppwrapConfig): string[] | null {
  const explicit = (cfg as { webBuild?: string }).webBuild;
  if (explicit) return explicit.trim().split(/\s+/);
  try {
    const pj = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
    if (pj.scripts?.build) return ['bun', 'run', 'build'];
  } catch { /* no package.json */ }
  return null;
}

/** Build the web bundle before a device deploy, but ONLY for a bundled loader — a `loader:'server'`
 * app loads the live serverUrl, so its dist is unused (building it would be wasteful + misleading).
 * Prints exactly what it's doing either way. `--no-web-build` skips it (ship the current dist as-is,
 * e.g. to hit the native build-skip fast-path when only the wrapper changed). */
function buildWebIfBundled(cwd: string, cfg: AppwrapConfig, flags: Record<string, string>): void {
  if ((cfg.loader ?? 'app') === 'server') {
    console.log('ℹ loader:server — NOT building the web (the app loads serverUrl live; the bundle is unused).');
    return;
  }
  if ('no-web-build' in flags) {
    console.log('ℹ --no-web-build — shipping the CURRENT dist/ as-is (web NOT rebuilt).');
    return;
  }
  const cmd = detectWebBuildCmd(cwd, cfg);
  if (!cmd) {
    console.warn('⚠ Bundled loader but no web-build command (no package.json "build" script / `webBuild` config). Shipping the CURRENT dist/ — it may be STALE.');
    return;
  }
  console.log(`▶ ${cmd.join(' ')}  (bundled loader → fresh web bundle so dist isn't stale)`);
  execFileSync(cmd[0], cmd.slice(1), { cwd, stdio: 'inherit' });
}

/** Resolve adb: $ANDROID_HOME/$ANDROID_SDK_ROOT platform-tools, else `adb` on PATH. */
function androidAdb(): string {
  const home = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
  if (home) {
    const p = join(home, 'platform-tools', 'adb');
    if (existsSync(p)) return p;
  }
  return 'adb';
}

/** Authorized (`device` state) adb serials. Skips `unauthorized`/`offline`. */
function listAndroidDevices(adb: string): string[] {
  try {
    return execFileSync(adb, ['devices'], { encoding: 'utf8' })
      .split('\n').slice(1)
      .filter((l) => /\tdevice\b/.test(l))
      .map((l) => l.split('\t')[0].trim())
      .filter(Boolean);
  } catch {
    return [];
  }
}

/** `appwrap deploy android` — ONE-SHOT device deploy, the Android twin of `deploy ios`: sync + debug
 * config → `ns build android` (debug APK) → `adb install -r` to the device → launch (unless --no-launch)
 * → exit. Unlike `run android` (ns watch-mode), it doesn't stay attached. NOTE: like `deploy ios`, it
 * ships the CURRENT `dist/` — build the web first (`bun run build`, or use the `bun run android` script). */
async function deployAndroid(cwd: string, flags: Record<string, string>, cfgOverride?: AppwrapConfig): Promise<void> {
  const cfg = cfgOverride ?? await loadConfig(cwd, flags);
  const outDir = resolve(cwd, flags.out ?? 'native');
  if (!existsSync(outDir)) {
    console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
    process.exit(1);
  }
  const adb = androidAdb();
  const device = resolveDevice(outDir, 'android', flags).id; // shared resolver (fail fast before the build)

  buildWebIfBundled(cwd, cfg, flags);                  // bundled → fresh web bundle; server → skip (both printed)
  await sync(cwd, flags, cfgOverride);                 // re-stamp config + copy latest PWA dist
  stampShellConfig(outDir, { ...cfg, debug: true });   // debug: keep-awake + WebView inspector (parity with deploy ios)

  const apk = join(outDir, 'platforms/android/app/build/outputs/apk/debug/app-debug.apk');
  // Fingerprint build-skip (parity with deploy ios): skip the gradle build when dist + config are
  // unchanged since the last build. --force/-f always rebuilds. (Rebuilding the web above usually
  // bumps the fingerprint; pair with --no-web-build to actually hit this fast-path.)
  const force = 'force' in flags || 'f' in flags;
  const fp = buildFingerprint(cwd, cfg, outDir);
  const cache = readBuildCache(outDir, 'android');
  if (!force && existsSync(apk) && cache?.fingerprint === fp && cache?.artifactPath === apk) {
    console.log(`⚡ Skipping build — inputs unchanged since last build (${apk.split('/').pop()})`);
  } else {
    console.log(`▶ ns build android (debug)${force ? '  [--force]' : ''}`);
    runNs(outDir, ['build', 'android']);               // bun-installs deps if needed, then gradle build
    if (!existsSync(apk)) { console.error(`✖ No APK produced at ${apk}`); process.exit(1); }
    writeBuildCache(outDir, 'android', { fingerprint: fp, artifactPath: apk, builtAt: new Date().toISOString() });
  }

  console.log(`▶ installing → ${device}`);
  try {
    // `--user 0` (primary user) is the robust install target: on MIUI/Xiaomi a BARE `adb install` is
    // silently auto-denied ("user is restricted from installing apps" — no popup), but scoping it to
    // user 0 succeeds. On normal single-user devices it's a no-op (install already targets user 0).
    // Capture (not inherit) so we can still surface guidance if it fails for another reason.
    const out = execFileSync(adb, ['-s', device, 'install', '-r', '--user', '0', apk], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
    process.stdout.write(out);
  } catch (e: unknown) {
    const log = execErrText(e);
    process.stderr.write(log);
    if (/INSTALL_FAILED_USER_RESTRICTED|user is restricted|canceled by user/i.test(log)) {
      console.error(
        '\n✖ Install blocked/canceled by the device (MIUI/Xiaomi restriction — NOT a build problem).\n' +
        '  → Unlock the phone and watch for the "Install via USB?" prompt → tap OK/Install.\n' +
        '  → Developer options: enable "Install via USB" AND "USB debugging (Security settings)".\n' +
        '    (Toggling these pings Xiaomi servers — needs mobile data + a SIM, no VPN.)\n' +
        `  The built APK is ready: ${apk}`
      );
    }
    process.exit(1);
  }

  if (!('no-launch' in flags)) {
    console.log(`▶ launching ${cfg.id}`);
    try {
      execFileSync(adb, ['-s', device, 'shell', 'monkey', '-p', cfg.id, '-c', 'android.intent.category.LAUNCHER', '1'], { stdio: 'ignore' });
    } catch {
      console.error('⚠ Launch failed — the app is installed; tap its icon.');
    }
  }
  console.log(`✓ Deployed to ${device}.`);
}

async function deploy(cwd: string, flags: Record<string, string>, positionals: string[], cfgOverride?: AppwrapConfig): Promise<void> {
  const platform = positionals[0];
  if (platform === 'android') {
    // Parity with `deploy ios`: a clean ONE-SHOT build → install-to-device → launch → exit (NOT `ns run`,
    // which is watch-mode + hangs on some devices). Mirrors the iOS path with the adb toolchain.
    return deployAndroid(cwd, flags, cfgOverride);
  }
  if (platform !== 'ios') {
    console.error('Usage: appwrap deploy <ios|android> [--device <id|name>] [--no-launch]');
    process.exit(1);
  }
  const cfg = cfgOverride ?? await loadConfig(cwd, flags);
  const outDir = resolve(cwd, flags.out ?? 'native');
  if (!existsSync(outDir)) {
    console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
    process.exit(1);
  }
  // Pick the device up front so we fail fast before a long build if nothing's connected.
  const device = resolveDevice(outDir, 'ios', flags);

  buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle (no stale dist); server → skip (printed)
  await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
  stampShellConfig(outDir, { ...cfg, debug: true });

  const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');

  // Smart resume: skip ns build (pod install + xcodebuild) when inputs haven't changed.
  // --resume (-r): opt in to fingerprint-based skip (same logic as auto, but explicit — useful when
  //   the auto check has no prior cache yet and you want to force a skip on first run after a manual build).
  // Auto: always checks fingerprint; never skips if sources/deps changed.
  const resume = 'resume' in flags || 'r' in flags;
  const force = 'force' in flags || 'f' in flags;
  const fp = buildFingerprint(cwd, cfg, outDir);
  const cache = readBuildCache(outDir, 'ios');
  const existingIpa = existsSync(ipaDir)
    ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
    : undefined;
  const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.artifactPath === join(ipaDir, existingIpa);
  // --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
  // but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
  const noCache = existingIpa && !cache;
  const canSkipBuild = !force && (fingerprintMatch || (resume && noCache));

  if (canSkipBuild) {
    const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
    console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
  } else {
    console.log(`▶ ns build ios --for-device  (debug: keep-awake + inspector on)${force ? '  [--force: skipping cache]' : ''}`);
    try {
      execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
    } catch (e) {
      // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
      console.error(
        '\n✖ Device build failed — if the errors above mention signing:\n' +
          `  • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
          '    registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
          '    unique string and re-deploy.\n' +
          '  • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
          '    team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
          '  • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
      );
      process.exit(1);
    }
  }

  const builtIpa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
  const ipa = builtIpa;
  if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
  const ipaPath = join(ipaDir, ipa);
  if (!canSkipBuild) writeBuildCache(outDir, 'ios', { fingerprint: fp, artifactPath: ipaPath, builtAt: new Date().toISOString() });

  console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
  let installedViaUsbmux = false;
  try {
    // Capture (not inherit) so we can recognize specific failures; echo it for visibility.
    // Wrapped so a LOCKED device waits-and-retries instead of hard-failing (the common annoyance).
    // --timeout: devicectl has no first-class "wait for unlock", but its overall-timeout lets a single
    // attempt tolerate a brief locked/unavailable window before erroring; the outer retry covers the
    // fail-fast case + the unlock prompt. (Verified against `devicectl --help`; community wraps it too.)
    const out = withUnlockRetry('Install', () =>
      execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', '25', '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
    );
    process.stdout.write(out);
  } catch (e: unknown) {
    const log = execErrText(e);
    process.stderr.write(log);
    if (/maximum number of installed apps|MIInstallerErrorDomain error 13|ApplicationVerificationFailed/.test(log)) {
      // Free Apple developer profile caps a device at 3 app IDs — a sibling appwrap/WDA build often eats a slot.
      const ids = [...log.matchAll(/"([A-Z0-9]{10}\.[^"]+)"/g)].map((m) => m[1]);
      console.error(
        "\n✖ Install blocked: this device hit the FREE developer profile's 3-app limit (not a lock).\n" +
          (ids.length ? `  Installed under this team: ${ids.join(', ')}\n` : '') +
          '  → Uninstall one you don\'t need, then re-run:\n' +
          `      xcrun devicectl device uninstall app --device ${device.id} <bundleId>\n` +
          '  (A paid Apple Developer account removes this limit.)'
      );
      process.exit(1);
    } else if (/Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) {
      // devicectl/CoreDevice is stuck (a connection hang, NOT a lock). usbmux (ideviceinstaller) is a
      // separate stack that usually still works — auto-fall-back instead of chasing a phantom "unlock".
      console.error('\n⚠ devicectl/CoreDevice is stuck (connection hang, not a lock) — falling back to ideviceinstaller (usbmux)…');
      if (usbmuxInstall(ipaPath)) {
        installedViaUsbmux = true;
      } else {
        console.error(
          '✖ usbmux fallback unavailable.\n' +
            '  → Re-plug the USB cable (re-establishes the CoreDevice tunnel) and re-run, OR\n' +
            '    `brew install ideviceinstaller` for a usbmux install path.\n' +
            `  The built .ipa is ready: ${ipaPath}`
        );
        process.exit(1);
      }
    } else {
      console.error(
        '✖ Install failed (device still locked after waiting, or only on Wi-Fi).\n' +
          '  → Unlock the phone (and plug in USB for a reliable connection), then re-run.\n' +
          `  The built .ipa is ready: ${ipaPath}`
      );
      process.exit(1);
    }
  }

  // devicectl process-launch is also stuck when we fell back to usbmux — skip it; the user taps the icon.
  if (!('no-launch' in flags) && !installedViaUsbmux) {
    console.log(`▶ launching ${cfg.id} (terminating any running instance first)`);
    try {
      // --terminate-existing: kill a suspended/running copy before launching, so the FRESHLY installed
      // binary actually runs. Without it, iOS resumes the old process and you see stale config (e.g. an
      // old serverUrl) despite a correct new build — masquerading as a build/cache bug. (A reinstall
      // over the top does NOT replace a running process; this avoids the manual uninstall dance.)
      withUnlockRetry('Launch', () =>
        execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout', '25', '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
      );
    } catch {
      console.error('⚠ Launch failed (still locked after waiting). The app is installed — unlock and tap it, or re-run.');
    }
  }
  console.log(installedViaUsbmux
    ? `✓ Installed to ${device.name} via usbmux (devicectl was stuck). Tap the app icon to open it.`
    : `✓ Deployed to ${device.name}.`);
}

/** Install an .ipa over usbmux via ideviceinstaller — a separate stack from devicectl/CoreDevice, so it
 * works when the CoreDevice tunnel is stuck. Returns false if ideviceinstaller is absent or the install
 * fails (the caller then prints the re-plug / brew-install remedy). */
function usbmuxInstall(ipaPath: string): boolean {
  try {
    const out = execFileSync('ideviceinstaller', ['install', ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
    process.stdout.write(out);
    return /Complete|Installed/i.test(out);
  } catch (e: unknown) {
    process.stderr.write(execErrText(e));
    return false;
  }
}

/** Run a devicectl op; if it fails because the device is LOCKED (or transiently unavailable), prompt
 * once and poll-retry until it succeeds or the budget runs out — instead of hard-failing. A free-team
 * 3-app-limit error is NOT a lock, so it's re-thrown immediately for the caller's specific handling.
 * (We can't auto-unlock — that needs the passcode by design — but we can wait gracefully.) */
function withUnlockRetry<T>(label: string, run: () => T, tries = 40, delayMs = 3000): T {
  for (let i = 0; ; i++) {
    try {
      return run();
    } catch (e: unknown) {
      const log = execErrText(e);
      if (/maximum number of installed apps|MIInstallerErrorDomain error 13|ApplicationVerificationFailed/.test(log)) throw e;
      // devicectl/CoreDevice tunnel STUCK (it switches to a [wired] path and hangs) is NOT a lock —
      // retrying as "waiting for unlock" is pointless + misleading. Re-throw so the caller can fall back.
      if (/Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) throw e;
      if (i >= tries) throw e;
      if (i === 0) process.stdout.write(`\n🔒 ${label}: device unavailable — unlock your iPhone. Waiting (auto-retries every ${delayMs / 1000}s, up to ${Math.round((tries * delayMs) / 1000)}s)…\n`);
      else process.stdout.write(`  …waiting for unlock (${i}/${tries})\n`);
      try { execFileSync('sleep', [String(delayMs / 1000)], { stdio: 'ignore' }); } catch { /* sleep interrupted */ }
    }
  }
}

/** First connected libimobiledevice UDID (USB, then network). Distinct from devicectl's identifier. */
function libimobiledeviceUdid(): { udid: string; network: boolean } | null {
  for (const [args, network] of [[['-l'], false], [['-n'], true]] as const) {
    try {
      const out = execFileSync('idevice_id', args, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
      const first = out.split('\n').map((s) => s.trim()).filter(Boolean)[0];
      if (first) return { udid: first.split(/\s+/)[0], network };
    } catch { /* idevice_id missing or no device */ }
  }
  return null;
}

/** `appwrap logs ios` — read the WebView's forwarded console + errors. In debug builds the shell
 * forwards them to a file in the app container (NS `console.log`/`NSLog` do NOT surface to devicectl
 * or idevicesyslog on a device build — a file is the reliable channel), which this pulls via
 * `devicectl device copy`. DEFAULT: watch (poll the file ~every 3s, print new lines). `--once`:
 * one snapshot. `--native`: the OS-level app syslog firehose via idevicesyslog (native crashes; USB).
 * Headless-friendly: redirect to a file and read it. */
async function logs(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
  const platform = positionals[0] ?? 'ios';
  if (platform !== 'ios' && platform !== 'android') {
    console.error('Usage: appwrap logs <ios|android> [--once] [--native] [--device <id|name>]');
    process.exit(1);
  }
  const cfg = await loadConfig(cwd, flags);
  const outDir = resolve(cwd, flags.out ?? 'native'); // for last-device memory (shared resolver)

  // ── Android: adb logcat — WebView console (chromium tag) by default; --native = full app logcat ──
  if (platform === 'android') {
    const adb = androidAdb();
    const device = resolveDevice(outDir, 'android', flags).id;
    const once = 'once' in flags;
    if ('native' in flags) {
      const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
      console.log(`▶ logcat for ${cfg.id}${pid ? ` (pid ${pid})` : ' (not running — full stream)'}${once ? ' [once]' : ''} — Ctrl-C to stop.`);
      try { execFileSync(adb, ['-s', device, 'logcat', ...(once ? ['-d'] : []), ...(pid ? ['--pid', pid] : [])], { stdio: 'inherit' }); } catch { process.exit(1); }
      return;
    }
    console.log(`▶ WebView console (chromium) for ${cfg.id}${once ? ' [once]' : ''} — Ctrl-C to stop.  (--native for full app logcat)`);
    try { execFileSync(adb, ['-s', device, 'logcat', ...(once ? ['-d'] : []), '-s', 'chromium:I'], { stdio: 'inherit' }); } catch { process.exit(1); }
    return;
  }

  if ('native' in flags) {
    const li = libimobiledeviceUdid();
    if (!li) {
      console.error('✖ No device via libimobiledevice (need USB, or `brew install libimobiledevice`).');
      process.exit(1);
    }
    console.log(`▶ native OS syslog for ${cfg.id} (idevicesyslog -p native) — Ctrl-C to stop.`);
    try {
      execFileSync('idevicesyslog', ['-u', li.udid, ...(li.network ? ['-n'] : []), '-p', 'native'], { stdio: 'inherit' });
    } catch {
      process.exit(1);
    }
    return;
  }

  const device = resolveDevice(outDir, 'ios', flags);
  const dest = join(tmpdir(), `appwrap-weblog-${process.pid}.log`);
  const pull = (): string => {
    try {
      execFileSync(
        'xcrun',
        ['devicectl', 'device', 'copy', 'from', '--device', device.id, '--domain-type', 'appDataContainer',
          '--domain-identifier', cfg.id, '--source', 'Documents/appwrap-web.log', '--destination', dest],
        { stdio: ['ignore', 'ignore', 'ignore'] }
      );
      return readFileSync(dest, 'utf8');
    } catch {
      return ''; // not yet created (app hasn't logged) or not a debug build
    }
  };

  if ('once' in flags) {
    process.stdout.write(pull() || '(no web log yet — debug build? has the app logged anything?)\n');
    return;
  }

  console.log(`▶ watching web logs from ${cfg.id} on ${device.name} (pull every 3s) — Ctrl-C to stop.`);
  console.log('  [appwrap-web] = forwarded WebView console/errors. (--once = snapshot, --native = OS firehose.)');
  let shown = 0;
  for (;;) {
    const all = pull();
    if (all.length < shown) shown = 0; // app relaunched → file reset; reprint
    if (all.length > shown) { process.stdout.write(all.slice(shown)); shown = all.length; }
    try { execFileSync('sleep', ['3']); } catch { break; }
  }
}

/** `appwrap publish <ios|android> [prod]` — distribution. DEFAULT = BETA (iOS TestFlight via the
 * proven `release` lane; Android → Play internal track via the mcp-appstores `android-upload` CLI).
 * `prod` → store (iOS App Store via `submit`; Android Play production track). Consolidates the existing
 * `release`/`submit` (kept as aliases). Android upload reuses the same contract as the CI release
 * workflow: a signed AAB from `build android --release --aab` + `APPSTORES_REGISTRY` env for the Play
 * service-account/package mapping (see the emitted appwrap-release-android.yml). */
async function publish(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
  const platform = positionals[0];
  const prod = positionals[1] === 'prod';
  if (platform !== 'ios' && platform !== 'android') {
    console.error('Usage: appwrap publish <ios|android> [prod]   (default: beta — TestFlight / Play internal)');
    process.exit(1);
  }
  if (platform === 'ios') {
    // iOS rides the proven fastlane path unchanged: beta → TestFlight, prod → App Store promote.
    return release(cwd, flags, ['ios'], prod ? 'release' : 'beta');
  }

  // ── Android: build a signed AAB, then upload via the mcp-appstores CLI (Play Developer API). ──
  const track = flags.track || (prod ? 'production' : 'internal');
  console.log(`▶ appwrap build android --release --aab  (for Play ${track} track)`);
  await build(cwd, { ...flags, release: '', aab: '' }, ['android']);
  const aab = join(resolve(cwd, flags.out ?? 'native'), 'platforms/android/app/build/outputs/bundle/release/app-release.aab');
  if (!existsSync(aab)) { console.error(`✖ No AAB produced at ${aab}`); process.exit(1); }

  if (!process.env.APPSTORES_REGISTRY) {
    console.error(
      '\n✖ Android publish needs the Play upload contract (same as the CI release workflow):\n' +
      '  • APPSTORES_REGISTRY env — JSON mapping org→serviceAccountPath + app→packageName.\n' +
      '  • A Play service-account JSON + the app already created in the Play Console (one prior manual release).\n' +
      `  The signed AAB is ready: ${aab}\n` +
      '  Then: APPSTORES_ALLOW_WRITES=true bunx @livx.cc/mcp-appstores android-upload \\\n' +
      `      --org <org> --app <app> --file "${aab}" --track ${track} --status completed`
    );
    process.exit(1);
  }
  const org = flags.org || (await loadConfig(cwd, flags)).id;
  const app = flags.app || 'app';
  console.log(`▶ bunx @livx.cc/mcp-appstores android-upload --org ${org} --app ${app} --track ${track}`);
  try {
    execFileSync('bunx', ['@livx.cc/mcp-appstores', 'android-upload', '--org', org, '--app', app, '--file', aab, '--track', track, '--status', 'completed'],
      { cwd, stdio: 'inherit', env: { ...process.env, APPSTORES_ALLOW_WRITES: process.env.APPSTORES_ALLOW_WRITES ?? 'true' } });
  } catch {
    console.error(`\n✖ Play upload failed. Check APPSTORES_REGISTRY (org "${org}", app "${app}") + the service-account permissions. The AAB is ready: ${aab}`);
    process.exit(1);
  }
  console.log(`✓ Uploaded to Play ${track} track.`);
}

async function main(): Promise<void> {
  const { command, flags, positionals } = parseArgs(process.argv.slice(2));
  const cwd = process.cwd();

  switch (command) {
    case 'init':
      await init(cwd, flags);
      break;
    case 'sync':
      await sync(cwd, flags);
      break;
    case 'dev':
      await dev(cwd, flags, positionals);
      break;
    case 'run': // hidden back-compat alias → dev
      await dev(cwd, flags, positionals);
      break;
    case 'build':
      await build(cwd, flags, positionals);
      break;
    case 'deploy':
      await deploy(cwd, flags, positionals);
      break;
    case 'publish':
      await publish(cwd, flags, positionals);
      break;
    case 'release': // alias: publish <ios|android> (beta)
      await release(cwd, flags, positionals, 'beta');
      break;
    case 'submit': // alias: publish <ios> prod
      await release(cwd, flags, positionals, 'release');
      break;
    case 'logs':
      await logs(cwd, flags, positionals);
      break;
    case 'debug': // hidden back-compat alias → dev --debug
      await dev(cwd, { ...flags, debug: '' }, positionals);
      break;
    default:
      console.log('Usage: appwrap <init|sync|dev|build|deploy|publish|logs> [--config <path>] [--out native]\n' +
        '  config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
        '  Device selection (dev/deploy/logs/publish): --device <id|name> | -d (pick from a list) | else last-used / sole device.\n\n' +
        '  dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]\n' +
        '       live-dev: DEVICE → clean deploy + stream console + watch sources (rebuild on save).\n' +
        '       --sim = ns run/HMR on emulator; --url/--port = web HMR from a dev server inside the WebView;\n' +
        '       --detached = install & launch then exit; --debug = also open the WebView inspector.\n' +
        '  deploy <ios|android> [--no-launch] [--no-web-build] [-f]   (clean ship-once: build → install → launch → exit)\n' +
        '  publish <ios|android> [prod]              (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
        '  build <ios|android> [--release] [--aab]   (store artifact only — no install/upload)\n' +
        '  logs <ios|android> [--once] [--native]    (stream WebView console; --native = full OS log)\n' +
        '  aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
      process.exit(command ? 1 : 0);
  }
}

if (import.meta.main) {
  main();
}
