/**
 * `olam upgrade` CLI lock — atomic create-or-fail with stale-lock recovery.
 *
 * Lock file: ~/.olam/.upgrade.lock
 * Contents:  {"pid": <int>, "startTs": <epoch-ms>}
 *
 * Stale-lock semantics:
 *   - File unreadable / parse error / empty  → stale
 *   - PID not alive (process.kill(pid, 0) throws) → stale
 *   - PID alive but command name is not node/olam → stale (avoids macOS PID-recycling false-positives)
 *   - start_ts > 30 min ago → stale
 *   - Otherwise → live; second invocation refuses with exit 1.
 *
 * Atomicity: fs.openSync(path, 'wx') is atomic create-or-fail per POSIX. No TOCTOU window.
 */
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
export const LOCK_FILE_PATH = path.join(os.homedir(), '.olam', '.upgrade.lock');
// 5 min — bounds the false-refusal blast-radius after a crash. Real upgrades
// should always finish well within this window (3-22 min spec; if a build
// genuinely runs longer the operator gets a 'wait or rm' message, which is
// fine UX). Per audit A1-004: was 30 min; shortened to 5 min.
export const STALE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
/**
 * Read lock file contents. Returns null on:
 *   - file missing
 *   - empty file
 *   - JSON parse error
 *   - shape mismatch (missing pid or startTs as numbers)
 *
 * Caller treats null as "stale lock; can recover."
 */
export function readLockFile(lockPath) {
    try {
        if (!fs.existsSync(lockPath))
            return null;
        const raw = fs.readFileSync(lockPath, 'utf-8').trim();
        if (raw.length === 0)
            return null;
        const parsed = JSON.parse(raw);
        if (typeof parsed.pid !== 'number' || typeof parsed.startTs !== 'number')
            return null;
        return { pid: parsed.pid, startTs: parsed.startTs };
    }
    catch {
        return null;
    }
}
/** Returns true if process exists, false on ESRCH or any other error. */
export function isPidAlive(pid) {
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
}
/**
 * Sentinel returned when `ps` itself is unavailable (binary missing, fork
 * pressure, container without procfs). Distinct from "ps ran but the PID
 * doesn't exist" which returns the empty string equivalent (null).
 *
 * Callers MUST treat this sentinel as "command unknown — assume live, refuse
 * to recover" so a missing `ps` binary cannot stealth-break the lock.
 *
 * Per audit A1-003: previously returned null on both branches and isStaleLock
 * treated null as stale, which let `ps`-unavailable invocations silently
 * unlink a live olam upgrade's lock and corrupt the docker layer cache.
 */
export const PS_UNAVAILABLE = '__ps_unavailable__';
/**
 * Read the command name of a PID via `ps -p <pid> -o comm=`.
 *
 * Returns:
 *   - The command name (e.g. 'node', 'olam') when `ps` ran AND the PID exists.
 *   - null when `ps` ran but the PID does not exist (ps exit status 1; output empty).
 *   - PS_UNAVAILABLE sentinel when `ps` failed to spawn (ENOENT, EAGAIN, signalled).
 */
export function getPidCommand(pid) {
    const result = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'ignore'],
    });
    // Spawn-level failure (status === null + error populated; binary missing,
    // fork pressure, killed by signal). Distinct from "ps reported pid absent."
    if (result.status === null || result.error !== undefined)
        return PS_UNAVAILABLE;
    if (result.status !== 0)
        return null;
    const out = result.stdout.trim();
    return out.length === 0 ? null : out;
}
/**
 * Match command names that legitimately hold the upgrade lock.
 *
 * Accepts:
 *   - `node`, `olam`, `olam-cli` (canonical)
 *   - path-prefixed forms (`/usr/local/bin/node` — strip via basename)
 *   - worker-pool suffixed forms (`node (vitest 1)` — strip parenthesized suffix)
 *
 * Rejects unrelated commands (bash, zsh, python3, etc.) so PID recycling to a
 * non-node process correctly classifies the lock as stale.
 */
export function isOlamUpgradeCommand(comm) {
    if (!comm)
        return false;
    if (comm === PS_UNAVAILABLE)
        return false;
    const base = comm.split('/').pop() ?? comm;
    const stripped = base.replace(/\s*\(.*\)\s*$/, '').trim();
    return stripped === 'node' || stripped === 'olam' || stripped === 'olam-cli';
}
/**
 * Stale-lock test. Returns true if the lock should be recovered (deleted + retried).
 *
 * Inputs:
 *   - content: parsed lock contents (null → empty/parse-error/missing → stale)
 *   - nowMs: current epoch ms (defaults to Date.now(); injected for test determinism)
 */
export function isStaleLock(content, nowMs = Date.now()) {
    if (!content)
        return true;
    if (nowMs - content.startTs > STALE_LOCK_TIMEOUT_MS)
        return true;
    if (!isPidAlive(content.pid))
        return true;
    const comm = getPidCommand(content.pid);
    // Audit A1-003 fail-live invariant: if `ps` is unavailable we cannot prove
    // the PID isn't an olam process. Treat as live (not stale) — operator gets
    // a refusal message and can `rm ~/.olam/.upgrade.lock` if they're confident
    // the lock is stale. Better than silently deleting a live lock.
    if (comm === PS_UNAVAILABLE)
        return false;
    if (!isOlamUpgradeCommand(comm))
        return true;
    return false;
}
/**
 * Atomically acquire the upgrade lock.
 *
 * Returns { acquired: true } on success, or { acquired: false, reason } when a live lock exists.
 * Stale locks are auto-recovered (delete + retry once).
 *
 * Side effects:
 *   - mkdir -p path.dirname(lockPath) (ensures ~/.olam exists)
 *   - writes JSON {pid, startTs} to lockPath
 */
export function acquireLock(lockPath = LOCK_FILE_PATH, nowMs = Date.now()) {
    const dir = path.dirname(lockPath);
    fs.mkdirSync(dir, { recursive: true });
    for (let attempt = 0; attempt < 2; attempt++) {
        try {
            const fd = fs.openSync(lockPath, 'wx', 0o644);
            try {
                const content = { pid: process.pid, startTs: nowMs };
                fs.writeSync(fd, JSON.stringify(content));
            }
            finally {
                fs.closeSync(fd);
            }
            return { acquired: true, lockPath };
        }
        catch (err) {
            const code = err.code;
            if (code !== 'EEXIST')
                throw err;
            const existing = readLockFile(lockPath);
            if (isStaleLock(existing, nowMs)) {
                try {
                    fs.unlinkSync(lockPath);
                }
                catch (unlinkErr) {
                    const ucode = unlinkErr.code;
                    if (ucode !== 'ENOENT')
                        throw unlinkErr;
                }
                continue;
            }
            return {
                acquired: false,
                reason: 'live',
                ...(existing?.pid !== undefined && { existingPid: existing.pid }),
                ...(existing?.startTs !== undefined && { existingStartTs: existing.startTs }),
            };
        }
    }
    // Both retry attempts hit a non-stale lock. Per audit A1-001: surface this
    // as 'live' (the user's correct response is "wait or rm if stale"), not
    // 'race' (which sounded like a transient that warrants retry). Operationally
    // identical — both branches return acquired:false — but the message text
    // matches the operator's mental model.
    const existing = readLockFile(lockPath);
    return {
        acquired: false,
        reason: 'live',
        ...(existing?.pid !== undefined && { existingPid: existing.pid }),
        ...(existing?.startTs !== undefined && { existingStartTs: existing.startTs }),
    };
}
/** Release the lock. Idempotent — no error if file already removed. */
export function releaseLock(lockPath = LOCK_FILE_PATH) {
    try {
        fs.unlinkSync(lockPath);
    }
    catch (err) {
        const code = err.code;
        if (code !== 'ENOENT')
            throw err;
    }
}
/** Format a human-readable refusal message for the operator. */
export function formatRefusalMessage(result, lockPath = LOCK_FILE_PATH) {
    const pidStr = result.existingPid !== undefined ? ` (pid ${result.existingPid})` : '';
    const lines = [
        `Upgrade in progress${pidStr}.`,
        'Wait for the running upgrade to finish, or:',
        '  - Check progress: olam upgrade --history',
        `  - If stale (crashed CLI): rm ${lockPath}`,
    ];
    return lines.join('\n');
}
//# sourceMappingURL=upgrade-lock.js.map