/**
 * Stable install location for the signed macOS Keychain helper.
 *
 * Why a stable path: every npm publish re-signs `Agents CLI.app` with a fresh
 * timestamp, producing a new code signature. macOS Keychain trusted-app ACLs
 * are pinned to the exact signature, so the ACL invalidates on every release
 * when the helper lives inside the npm package directory. Copying the .app
 * once to `~/Library/Application Support/agents-cli/` gives it a path that
 * survives `npm i -g`, `scripts/install.sh`, version bumps, etc.
 *
 * This module is the SINGLE SOURCE OF TRUTH for the helper path. Other
 * modules in `src/lib/secrets/` must import `getKeychainHelperPath()` rather
 * than recomputing it.
 */
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
import { downloadKeychainHelperApp } from './download-keychain.js';
import { helperFloor } from '../helper-versions.js';
const APP_BUNDLE_NAME = 'Agents CLI.app';
const INSTALL_DIR_NAME = 'agents-cli';
let installRootOverride = null;
/** Redirect the install root (test only). Returns the previous override so callers can restore. */
export function setInstallRootForTest(dir) {
    const prev = installRootOverride;
    installRootOverride = dir;
    return prev;
}
/** Absolute path to the installed `.app` bundle directory (not the executable). */
function installedAppPath() {
    const root = installRootOverride ?? os.homedir();
    return path.join(root, 'Library', 'Application Support', INSTALL_DIR_NAME, APP_BUNDLE_NAME);
}
/** Absolute path to the executable inside the installed `.app` bundle. */
function installedExecutablePath() {
    return path.join(installedAppPath(), 'Contents', 'MacOS', 'Agents CLI');
}
/**
 * Locate the source `.app` bundle shipped alongside the compiled JS.
 *
 * Resolution order:
 *   1. dist/lib/secrets/Agents CLI.app — sibling of this compiled file (npm install layout)
 *   2. <repo>/bin/Agents CLI.app       — raw working tree (`bun run dev`, tsx from src/)
 *
 * Throws if neither exists.
 */
function sourceAppPath() {
    const candidates = [];
    try {
        const here = path.dirname(fileURLToPath(import.meta.url));
        candidates.push(path.join(here, APP_BUNDLE_NAME));
        // tsx/src case: src/lib/secrets/install-helper.ts -> ../../../bin/Agents CLI.app
        candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
    }
    catch { /* import.meta.url unavailable */ }
    for (const c of candidates) {
        if (fs.existsSync(c))
            return c;
    }
    throw new Error(`Source ${APP_BUNDLE_NAME} not found. Looked in:\n  ${candidates.join('\n  ')}\n` +
        'The npm package shipped without the bundled keychain helper. Run `agents setup secrets` to ' +
        'download the signed helper for this version, or reinstall agents-cli.');
}
/** The bundled/local source `.app`, or null when absent (no throw) — the async
 *  download path resolves the missing bundle instead of failing. */
function bundledSourceAppPathOrNull() {
    try {
        return sourceAppPath();
    }
    catch {
        return null;
    }
}
function assertDarwin() {
    if (process.platform !== 'darwin') {
        throw new Error('Keychain helper is macOS only.');
    }
}
function sha256File(filePath) {
    return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
/**
 * True when the installed helper executable differs byte-for-byte from the
 * bundled source helper. A stale-but-validly-signed helper is exactly how the
 * broken 1.20.4 build (signed, but missing the keychain-access-groups
 * entitlement) survived upgrades: codesign --verify passes on it, so the
 * "exists and verifies" early-return kept it installed forever. Returns false
 * when there is no bundled source to compare against (dev installs) or no
 * installed copy yet — both cases are decided by the existence checks in the
 * callers, not by staleness.
 */
function installedHelperIsStale() {
    let srcApp;
    try {
        srcApp = sourceAppPath();
    }
    catch {
        return false;
    }
    const srcExec = path.join(srcApp, 'Contents', 'MacOS', 'Agents CLI');
    const destExec = installedExecutablePath();
    if (!fs.existsSync(srcExec) || !fs.existsSync(destExec))
        return false;
    return sha256File(srcExec) !== sha256File(destExec);
}
function codesignVerify(appPath) {
    const r = spawnSync('codesign', ['--verify', '--deep', '--strict', appPath], {
        stdio: ['ignore', 'pipe', 'pipe'],
        encoding: 'utf-8',
    });
    return { ok: r.status === 0, output: (r.stderr || r.stdout || '').toString().trim() };
}
function spctlAssess(appPath) {
    const r = spawnSync('spctl', ['--assess', '--type', 'execute', '--verbose=2', appPath], {
        stdio: ['ignore', 'pipe', 'pipe'],
        encoding: 'utf-8',
    });
    return { ok: r.status === 0, output: (r.stderr || r.stdout || '').toString().trim() };
}
/**
 * Idempotent install. Copies the bundled `.app` to the stable user path. Skips
 * if the destination already exists, `codesign --verify` passes, AND the
 * installed executable matches the bundled one byte-for-byte — a valid
 * signature alone is not enough, because an outdated helper signs clean too.
 * `forceReinstall=true` skips all checks and always copies.
 *
 * Notarization is checked via `spctl --assess` after install — a failure is
 * logged as a warning but does NOT throw. Notarization checks require network
 * access (Gatekeeper ticket lookup) and are not load-bearing for the helper's
 * keychain ACL semantics.
 */
export function ensureKeychainHelperInstalled(opts = {}) {
    assertDarwin();
    installKeychainHelperFromSource(sourceAppPath(), opts);
}
/**
 * ASYNC, download-capable install — the EXPLICIT onboarding/upgrade path
 * (`agents setup secrets`, the bootstrap upgrade self-heal). When a bundled or
 * locally-built `.app` ships (the common case, and always true this release
 * stage), it installs that copy with NO network. When the tarball lacks the
 * bundle (a fresh `npm i -g` whose tarball dropped it), it fetches the signed +
 * notarized release asset for the running CLI version — sha256 + codesign +
 * Team + notarization verified before install — and installs from the cached
 * copy. The sync `getKeychainHelperPath()` never routes here: it stays sync +
 * network-free and fails loud toward this command when no source exists.
 */
export async function ensureKeychainHelperInstalledAsync(opts = {}) {
    assertDarwin();
    const bundled = bundledSourceAppPathOrNull();
    const src = bundled ?? (await downloadKeychainHelperApp(helperFloor('keychain')));
    installKeychainHelperFromSource(src, opts);
}
/**
 * Copy `src` (a verified `.app` bundle) to the stable user path, then verify.
 * Shared by the sync (bundled-source) and async (download-capable) installers.
 */
function installKeychainHelperFromSource(src, opts = {}) {
    const dest = installedAppPath();
    const upToDate = () => fs.existsSync(dest) && codesignVerify(dest).ok && !installedHelperIsStale();
    // Fast path: already installed, valid, and current — no lock, no copy.
    if (!opts.forceReinstall && upToDate())
        return;
    // Serialize the install so concurrent `agents` invocations don't race the
    // atomic swap (and don't each run a redundant `cp`). The re-check inside the
    // lock means a burst of callers copies once, not N times — the stampede that
    // transiently corrupted the bundle and tripped the "damaged" dialog.
    withInstallLock(dest, (heartbeat) => {
        if (!opts.forceReinstall && upToDate())
            return;
        copyAppBundle(src, dest);
        heartbeat(); // cp -R done; keep the lock fresh across the codesign/spctl spawns
        const verify = codesignVerify(dest);
        if (!verify.ok) {
            throw new Error(`Installed helper failed codesign verification at ${dest}.\n${verify.output}\n` +
                'The bundle may be corrupted. Reinstall agents-cli to restore it; the helper reinstalls automatically on the next secrets use.');
        }
        heartbeat(); // codesign done; spctl does a network Gatekeeper lookup — refresh again
        const assess = spctlAssess(dest);
        if (!assess.ok) {
            // Warn, do not fail. Gatekeeper ticket lookup needs network; offline
            // installs and CI runners commonly fail this check. The ACL semantics
            // we care about depend on codesign, not spctl.
            process.stderr.write(`agents-cli: notarization check (spctl) did not pass for ${dest}: ${assess.output}\n`);
        }
    });
}
/**
 * Return the absolute path to the helper executable. If the installed bundle
 * is missing, or is stale relative to the bundled source helper, performs a
 * lazy (re)install first. The staleness check is what lets an upgraded CLI
 * replace a helper a previous version installed — the install never runs on
 * `npm i -g`, so this call site is the only one every machine is guaranteed to
 * pass through.
 *
 * SYNC and network-free by design: this is the secrets hot path, called by
 * every keychain op. It never downloads. When no bundled/local source exists
 * (a tarball that dropped the bundle), the reinstall's `sourceAppPath()` throws
 * loud, directing the user to `agents setup secrets` — the explicit, async,
 * download-capable path (`ensureKeychainHelperInstalledAsync`). Throws on
 * non-darwin.
 */
export function getKeychainHelperPath() {
    assertDarwin();
    const exec = installedExecutablePath();
    if (!fs.existsSync(exec) || installedHelperIsStale()) {
        ensureKeychainHelperInstalled();
    }
    return exec;
}
export function getKeychainHelperStatus() {
    assertDarwin();
    const destApp = installedAppPath();
    let src = null;
    try {
        src = sourceAppPath();
    }
    catch { /* missing source is reported as null */ }
    const installed = fs.existsSync(destApp);
    if (!installed) {
        return {
            source: src,
            destination: destApp,
            installed: false,
            codesignOk: false,
            codesignOutput: 'not installed',
            spctlOk: false,
            spctlOutput: 'not installed',
        };
    }
    const cs = codesignVerify(destApp);
    const sp = spctlAssess(destApp);
    return {
        source: src,
        destination: destApp,
        installed: true,
        codesignOk: cs.ok,
        codesignOutput: cs.output || 'ok',
        spctlOk: sp.ok,
        spctlOutput: sp.output || 'ok',
    };
}
