/**
 * ci-watch/lock.mjs — per-PR O_EXCL atomic lock (D-05).
 *
 * Mirrors Claude Code's `cronTasksLock.ts`: a per-PR `.aperant/ci-watches/{pr}.lock`
 * file created with `openSync(..., 'wx')` (O_EXCL). Two concurrent callers
 * cannot both create the file; the loser reads the existing lock and decides
 * whether to declare BUSY or recover a stale lock.
 *
 * Stale-recovery (spec.md §8): a lock is recoverable iff
 *   (a) holder PID is dead (`!isPidAlive`), AND
 *   (b) `Date.now() - acquiredAt > ttl_seconds*1000`.
 * Live PID alone is NEVER recoverable, even past TTL — TTL alone is not
 * enough. Per D-05: PID-based stale recovery.
 *
 * Lock file shape (spec.md §8):
 *   { sessionId, pid, acquiredAt, ttl_seconds }
 *
 * Return shapes:
 *   acquireLock → { status: 'ok', lock: {...} }
 *              OR { status: 'busy', holder: {...} }
 *   releaseLock → boolean (true on release, false if owner mismatch / missing)
 *   readLock   → lock object or null
 */
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync, } from 'node:fs';
import { join, resolve } from 'node:path';
import { isPidAlive } from '../lock/pid.mjs';
const TTL_SECONDS = 1800; // 30 min — D-05
function watchesDir(root) {
    return join(resolve(root), '.aperant', 'ci-watches');
}
function lockPath(root, pr) {
    return join(watchesDir(root), `${pr}.lock`);
}
/**
 * @param {string} root
 * @param {number|string} pr
 */
export function readLock(root, pr) {
    const path = lockPath(root, pr);
    if (!existsSync(path))
        return null;
    try {
        return JSON.parse(readFileSync(path, 'utf-8'));
    }
    catch {
        // A corrupted lock is treated as no lock — re-acquire will overwrite.
        return null;
    }
}
function writeLockFile(path, payload) {
    mkdirSync(resolve(path, '..'), { recursive: true });
    const fd = openSync(path, 'wx');
    try {
        writeSync(fd, `${JSON.stringify(payload, null, 2)}\n`, 0, 'utf-8');
    }
    finally {
        closeSync(fd);
    }
}
/**
 * Try to acquire the per-PR lock. On EEXIST, parse the existing lock — if the
 * holder PID is dead AND TTL has elapsed, unlink + retry once. Otherwise
 * return `{status:'busy', holder}`.
 *
 * @param {string} root
 * @param {number|string} pr
 * @param {string} sessionId
 * @param {number} [pid=process.pid]
 */
export function acquireLock(root, pr, sessionId, pid = process.pid) {
    const path = lockPath(root, pr);
    const payload = {
        sessionId,
        pid,
        acquiredAt: new Date().toISOString(),
        ttl_seconds: TTL_SECONDS,
    };
    try {
        writeLockFile(path, payload);
        return { status: 'ok', lock: payload };
    }
    catch (err) {
        if (err.code !== 'EEXIST')
            throw err;
    }
    // Lock file exists — inspect.
    const existing = readLock(root, pr);
    if (!existing) {
        // Corrupted lock — treat as recoverable, unlink + retry once.
        try {
            unlinkSync(path);
        }
        catch {
            /* race — another caller cleaned up */
        }
        try {
            writeLockFile(path, payload);
            return { status: 'ok', lock: payload };
        }
        catch (err) {
            if (err.code === 'EEXIST') {
                return { status: 'busy', holder: readLock(root, pr) };
            }
            throw err;
        }
    }
    if (isStale(existing)) {
        try {
            unlinkSync(path);
        }
        catch {
            /* race — another caller cleaned up */
        }
        try {
            writeLockFile(path, payload);
            return { status: 'ok', lock: payload };
        }
        catch (err) {
            if (err.code === 'EEXIST') {
                return { status: 'busy', holder: readLock(root, pr) };
            }
            throw err;
        }
    }
    return { status: 'busy', holder: existing };
}
/**
 * Release the per-PR lock. Owner must match (`sessionId` equality). Returns
 * `true` if released, `false` if the lock is missing or owned by someone
 * else.
 *
 * @param {string} root
 * @param {number|string} pr
 * @param {string} sessionId
 */
export function releaseLock(root, pr, sessionId) {
    const path = lockPath(root, pr);
    if (!existsSync(path))
        return false;
    const existing = readLock(root, pr);
    if (!existing) {
        // Corrupted — fail open (let next caller clean up).
        return false;
    }
    if (existing.sessionId !== sessionId)
        return false;
    try {
        unlinkSync(path);
        return true;
    }
    catch {
        return false;
    }
}
/**
 * Is the given lock stale (i.e., recoverable by a new caller)? Per D-05,
 * BOTH conditions are required: dead PID AND past TTL.
 *
 * Exposed for unit tests; the orchestrator does not call this directly.
 *
 * @param {{ pid: number, acquiredAt: string, ttl_seconds: number }} lockData
 */
export function isStale(lockData) {
    if (!lockData || typeof lockData !== 'object')
        return false;
    const ttlMs = (lockData.ttl_seconds || TTL_SECONDS) * 1000;
    const age = Date.now() - new Date(lockData.acquiredAt).getTime();
    if (age <= ttlMs)
        return false;
    return !isPidAlive(lockData.pid);
}
//# sourceMappingURL=lock.mjs.map