/**
 * NERF Session Lock — OS-level exclusive locking for auto-mode sessions.
 *
 * Prevents multiple NERF processes from running auto-mode concurrently on
 * the same project. Uses proper-lockfile for OS-level file locking (flock/
 * lockfile) which eliminates the TOCTOU race condition that existed with
 * the old advisory JSON lock approach.
 *
 * The lock file (.nerf/auto.lock) contains JSON metadata (PID, start time,
 * unit info) for diagnostics, but the actual exclusion is enforced by the
 * OS-level lock held via proper-lockfile.
 *
 * Lifecycle:
 *   acquireSessionLock()  — called at the START of bootstrapAutoSession
 *   validateSessionLock() — called periodically during dispatch to detect takeover
 *   releaseSessionLock()  — called on clean stop/pause
 */
import { createRequire } from "node:module";
import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { nerfRoot } from "./paths.js";
import { atomicWriteSync } from "./atomic-write.js";
const _require = createRequire(import.meta.url);
// ─── Module State ───────────────────────────────────────────────────────────
/** Release function from proper-lockfile — calling it releases the OS lock. */
let _releaseFunction = null;
/** The path we currently hold a lock on. */
let _lockedPath = null;
/** Our PID at lock acquisition time. */
let _lockPid = 0;
/** Set to true when proper-lockfile fires onCompromised (mtime drift, sleep, etc.). */
let _lockCompromised = false;
/** Whether we've already registered a process.on('exit') handler. */
let _exitHandlerRegistered = false;
/** Registry of all nerfDir paths where locks were created during this session.
 *  The exit handler cleans ALL of these, not just the current nerfRoot(). (#1578) */
const _lockDirRegistry = new Set();
/** Snapshotted lock file path — captured at acquireSessionLock time to avoid
 *  nerfRoot() resolving differently in worktree vs project root contexts (#1363). */
let _snapshotLockPath = null;
/** Timestamp when the session lock was acquired — used to detect false-positive
 *  onCompromised events from event loop stalls within the stale window (#1362). */
let _lockAcquiredAt = 0;
const LOCK_FILE = "auto.lock";
/**
 * Derive the effective lock file name for the current process.
 * In parallel worker mode (NERF_PARALLEL_WORKER + NERF_MILESTONE_LOCK),
 * each worker uses a per-milestone lock file (`auto-<milestoneId>.lock`)
 * to avoid contending on the shared `.nerf/auto.lock` (#2184).
 */
export function effectiveLockFile() {
    const mid = process.env.NERF_PARALLEL_WORKER ? process.env.NERF_MILESTONE_LOCK : null;
    return mid ? `auto-${mid}.lock` : LOCK_FILE;
}
/**
 * Derive the OS-level lock target directory for the current process.
 * In parallel worker mode, uses `.nerf/parallel/<milestoneId>/` instead of
 * `.nerf/` so workers don't contend on the same proper-lockfile directory (#2184).
 */
export function effectiveLockTarget(nerfDir) {
    const mid = process.env.NERF_PARALLEL_WORKER ? process.env.NERF_MILESTONE_LOCK : null;
    return mid ? join(nerfDir, "parallel", mid) : nerfDir;
}
function lockPath(basePath) {
    // If we have a snapshotted path from acquisition, use it for consistency
    if (_snapshotLockPath)
        return _snapshotLockPath;
    return join(nerfRoot(basePath), effectiveLockFile());
}
// ─── Stray Lock Cleanup ─────────────────────────────────────────────────────
/**
 * Remove numbered lock file variants (e.g. "auto 2.lock", "auto 3.lock")
 * that accumulate from macOS file conflict resolution (iCloud/Dropbox/OneDrive)
 * or other filesystem-level copy-on-conflict behavior (#1315).
 *
 * Also removes stray proper-lockfile directories beyond the canonical `.nerf.lock/`.
 */
export function cleanupStrayLockFiles(basePath) {
    const nerfDir = nerfRoot(basePath);
    // Clean numbered auto lock files inside .nerf/
    try {
        if (existsSync(nerfDir)) {
            for (const entry of readdirSync(nerfDir)) {
                // Match "auto <N>.lock" or "auto (<N>).lock" variants but NOT the canonical "auto.lock"
                if (entry !== LOCK_FILE && /^auto\s.+\.lock$/i.test(entry)) {
                    try {
                        unlinkSync(join(nerfDir, entry));
                    }
                    catch { /* best-effort */ }
                }
            }
        }
    }
    catch { /* non-fatal: directory read failure */ }
    // Clean stray proper-lockfile directories (e.g. ".nerf 2.lock/")
    // The canonical one is ".nerf.lock/" — anything else is stray.
    try {
        const parentDir = dirname(nerfDir);
        const nerfDirName = nerfDir.split("/").pop() || ".nerf";
        if (existsSync(parentDir)) {
            for (const entry of readdirSync(parentDir)) {
                // Match ".nerf <N>.lock" or ".nerf (<N>).lock" directories but NOT ".nerf.lock"
                if (entry !== `${nerfDirName}.lock` && entry.startsWith(nerfDirName) && entry.endsWith(".lock")) {
                    const fullPath = join(parentDir, entry);
                    try {
                        const stat = statSync(fullPath);
                        if (stat.isDirectory()) {
                            rmSync(fullPath, { recursive: true, force: true });
                        }
                    }
                    catch { /* best-effort */ }
                }
            }
        }
    }
    catch { /* non-fatal */ }
}
/**
 * Register a single process exit handler that cleans up lock state.
 * Uses module-level references so it always operates on current state.
 * Only registers once — subsequent calls are no-ops.
 */
function ensureExitHandler(_nerfDir) {
    // Register the nerfDir so exit cleanup covers it
    _lockDirRegistry.add(_nerfDir);
    if (_exitHandlerRegistered)
        return;
    _exitHandlerRegistered = true;
    process.once("exit", () => {
        try {
            if (_releaseFunction) {
                _releaseFunction();
                _releaseFunction = null;
            }
        }
        catch { /* best-effort */ }
        // Clean ALL registered lock paths, not just the current one (#1578).
        // Lock files accumulate across main project .nerf/, worktree .nerf/,
        // and projects registry paths — cleanup must cover all of them.
        for (const dir of _lockDirRegistry) {
            try {
                const lockFile = join(dir, LOCK_FILE);
                if (existsSync(lockFile))
                    unlinkSync(lockFile);
            }
            catch { /* best-effort */ }
            try {
                const lockDir = join(dir + ".lock");
                if (existsSync(lockDir))
                    rmSync(lockDir, { recursive: true, force: true });
            }
            catch { /* best-effort */ }
        }
    });
}
// ─── Lock Acquisition Helpers ───────────────────────────────────────────────
/**
 * Create the onCompromised callback for proper-lockfile.
 *
 * proper-lockfile fires onCompromised when it detects mtime drift (system sleep,
 * event loop stall, etc.). The default handler throws inside setTimeout — an
 * uncaught exception that crashes or corrupts process state.
 *
 * False-positive suppression (#1362): If we're still within the stale window
 * (30 min since acquisition), the mtime mismatch is from an event loop stall
 * during a long LLM call — not a real takeover. Log and continue.
 *
 * PID ownership check (#1578): Past the stale window, check if the lock file
 * still contains our PID before declaring compromise. Retry reads tolerate
 * transient filesystem hiccups (NFS/CIFS latency, APFS snapshots, etc.) (#2324).
 */
function createLockCompromisedHandler(lockFilePath) {
    return () => {
        const elapsed = Date.now() - _lockAcquiredAt;
        if (elapsed < 1_800_000) {
            process.stderr.write(`[nerf] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`);
            return;
        }
        const existing = readExistingLockDataWithRetry(lockFilePath);
        if (existing && existing.pid === process.pid) {
            process.stderr.write(`[nerf] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`);
            return;
        }
        _lockCompromised = true;
        _releaseFunction = null;
    };
}
/**
 * Assign module-level lock state after a successful lock acquisition.
 */
function assignLockState(basePath, release, lockFilePath) {
    _releaseFunction = release;
    _lockedPath = basePath;
    _lockPid = process.pid;
    _lockCompromised = false;
    _lockAcquiredAt = Date.now();
    _snapshotLockPath = lockFilePath;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
 * Attempt to acquire an exclusive session lock for the given project.
 *
 * This uses proper-lockfile for OS-level file locking. If another process
 * already holds the lock, this returns { acquired: false } with details.
 *
 * The lock file also contains JSON metadata about the session for
 * diagnostic purposes (PID, unit info, etc.).
 */
export function acquireSessionLock(basePath) {
    const lp = lockPath(basePath);
    // Re-entrant acquire on the same path: release our current OS lock first so
    // proper-lockfile clears its update timer before we acquire a fresh lock.
    if (_releaseFunction && _lockedPath === basePath) {
        try {
            _releaseFunction();
        }
        catch { /* may already be released */ }
        _releaseFunction = null;
        _lockedPath = null;
        _lockPid = 0;
        _lockCompromised = false;
    }
    // Ensure the directory exists
    mkdirSync(dirname(lp), { recursive: true });
    // Clean up numbered lock file variants from cloud sync conflicts (#1315)
    cleanupStrayLockFiles(basePath);
    // Write our lock data first (the content is informational; the OS lock is the real guard)
    const lockData = {
        pid: process.pid,
        startedAt: new Date().toISOString(),
        unitType: "starting",
        unitId: "bootstrap",
        unitStartedAt: new Date().toISOString(),
    };
    let lockfile;
    try {
        lockfile = _require("proper-lockfile");
    }
    catch {
        // proper-lockfile not available — fall back to PID-based check
        return acquireFallbackLock(basePath, lp, lockData);
    }
    const nerfDir = nerfRoot(basePath);
    const lockTarget = effectiveLockTarget(nerfDir);
    try {
        // Try to acquire an exclusive OS-level lock on the lock target.
        // We lock a directory since proper-lockfile works best on directories,
        // and the lock file itself may not exist yet.
        // In parallel worker mode, lockTarget is .nerf/parallel/<MID>/ (#2184).
        mkdirSync(lockTarget, { recursive: true });
        const release = lockfile.lockSync(lockTarget, {
            realpath: false,
            stale: 1_800_000, // 30 minutes — safe for laptop sleep / long event loop stalls
            update: 10_000, // Update lock mtime every 10s to prove liveness
            onCompromised: createLockCompromisedHandler(lp),
        });
        assignLockState(basePath, release, lp);
        // Safety net: clean up lock dir on process exit if _releaseFunction
        // wasn't called (e.g., normal exit after clean completion) (#1245).
        ensureExitHandler(lockTarget);
        // Write the informational lock data
        atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
        return { acquired: true };
    }
    catch (err) {
        // Lock is held by another process — or the .nerf.lock/ directory is stranded.
        // Check: if auto.lock is gone and no process is alive, the lock dir is stale.
        const existingData = readExistingLockData(lp);
        const existingPid = existingData?.pid;
        // If no lock file or no alive process, try to clean up and re-acquire (#1245)
        if (!existingData || (existingPid && !isPidAlive(existingPid))) {
            try {
                const lockDir = join(lockTarget + ".lock");
                if (existsSync(lockDir))
                    rmSync(lockDir, { recursive: true, force: true });
                if (existsSync(lp))
                    unlinkSync(lp);
                // Retry acquisition after cleanup
                const release = lockfile.lockSync(lockTarget, {
                    realpath: false,
                    stale: 1_800_000, // 30 minutes — match primary lock settings
                    update: 10_000,
                    onCompromised: createLockCompromisedHandler(lp),
                });
                assignLockState(basePath, release, lp);
                // Safety net — uses centralized handler to avoid double-registration
                ensureExitHandler(lockTarget);
                atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
                return { acquired: true };
            }
            catch {
                // Retry also failed — fall through to the error path
            }
        }
        const reason = existingPid
            ? `Another auto-mode session (PID ${existingPid}) appears to be running.\nStop it with \`kill ${existingPid}\` before starting a new session.`
            : `Another auto-mode session is already running on this project.`;
        return { acquired: false, reason, existingPid };
    }
}
/**
 * Fallback lock acquisition when proper-lockfile is not available.
 * Uses PID-based liveness checking (the old approach, but with the lock
 * written BEFORE initialization rather than after).
 */
function acquireFallbackLock(basePath, lp, lockData) {
    // Check if an existing lock is held by a live process
    const existing = readExistingLockData(lp);
    if (existing && existing.pid !== process.pid) {
        if (isPidAlive(existing.pid)) {
            return {
                acquired: false,
                reason: `Another auto-mode session (PID ${existing.pid}) is already running on this project.`,
                existingPid: existing.pid,
            };
        }
        // Stale lock from dead process — we can take over
    }
    // Write our lock data
    atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
    _lockedPath = basePath;
    _lockPid = process.pid;
    return { acquired: true };
}
/**
 * Update the lock file metadata (called on each unit dispatch).
 * Does NOT re-acquire the OS lock — just updates the JSON content.
 */
export function updateSessionLock(basePath, unitType, unitId, sessionFile) {
    if (_lockedPath !== basePath && _lockedPath !== null)
        return;
    const lp = lockPath(basePath);
    try {
        const data = {
            pid: process.pid,
            startedAt: new Date().toISOString(),
            unitType,
            unitId,
            unitStartedAt: new Date().toISOString(),
            sessionFile,
        };
        atomicWriteSync(lp, JSON.stringify(data, null, 2));
    }
    catch {
        // Non-fatal: lock update failure
    }
}
/**
 * Validate that we still own the session lock.
 *
 * Returns true if we still hold the lock, false if another process
 * has taken over (indicating we should gracefully stop).
 *
 * This is called periodically during the dispatch loop.
 */
export function getSessionLockStatus(basePath) {
    // Lock was compromised by proper-lockfile (mtime drift from sleep, stall, etc.)
    if (_lockCompromised) {
        // Recovery gate (#1512): Before declaring the lock lost, check if the lock
        // file still contains our PID. If it does, no other process took over — the
        // onCompromised fired from benign mtime drift (laptop sleep, event loop stall
        // beyond the stale window). Attempt re-acquisition instead of giving up.
        const lp = lockPath(basePath);
        // Retry reads to tolerate transient filesystem hiccups (#2324).
        const existing = readExistingLockDataWithRetry(lp);
        if (existing && existing.pid === process.pid) {
            // Lock file still ours — try to re-acquire the OS lock
            try {
                const result = acquireSessionLock(basePath);
                if (result.acquired) {
                    process.stderr.write(`[nerf] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`);
                    return { valid: true, recovered: true };
                }
            }
            catch {
                // Re-acquisition failed — fall through to return false
            }
        }
        return {
            valid: false,
            failureReason: "compromised",
            existingPid: existing?.pid,
            expectedPid: process.pid,
        };
    }
    // If we have an OS-level lock, we're still the owner
    if (_releaseFunction && _lockedPath === basePath) {
        return { valid: true };
    }
    // Fallback: check the lock file PID
    const lp = lockPath(basePath);
    const existing = readExistingLockData(lp);
    if (!existing) {
        // Lock file was deleted — we lost ownership
        return {
            valid: false,
            failureReason: "missing-metadata",
            expectedPid: process.pid,
        };
    }
    if (existing.pid !== process.pid) {
        return {
            valid: false,
            failureReason: "pid-mismatch",
            existingPid: existing.pid,
            expectedPid: process.pid,
        };
    }
    return { valid: true };
}
export function validateSessionLock(basePath) {
    return getSessionLockStatus(basePath).valid;
}
/**
 * Release the session lock. Called on clean stop/pause.
 */
export function releaseSessionLock(basePath) {
    // Release the OS-level lock
    if (_releaseFunction) {
        try {
            _releaseFunction();
        }
        catch {
            // Lock may already be released
        }
        _releaseFunction = null;
    }
    // Remove the lock file at the current path
    const lp = lockPath(basePath);
    try {
        if (existsSync(lp))
            unlinkSync(lp);
    }
    catch {
        // Non-fatal
    }
    // Remove the proper-lockfile directory for the current lock target.
    // In parallel worker mode, this is .nerf/parallel/<MID>.lock/ (#2184).
    const nerfDir = nerfRoot(basePath);
    const lockTarget = effectiveLockTarget(nerfDir);
    try {
        const lockDir = join(lockTarget + ".lock");
        if (existsSync(lockDir))
            rmSync(lockDir, { recursive: true, force: true });
    }
    catch {
        // Non-fatal
    }
    // Also clean the per-milestone parallel directory itself if it exists
    if (lockTarget !== nerfDir) {
        try {
            if (existsSync(lockTarget))
                rmSync(lockTarget, { recursive: true, force: true });
        }
        catch {
            // Non-fatal
        }
    }
    // Clean ALL registered lock paths (#1578) — lock files accumulate across
    // main project .nerf/, worktree .nerf/, and projects registry paths.
    for (const dir of _lockDirRegistry) {
        try {
            const lockFile = join(dir, LOCK_FILE);
            if (existsSync(lockFile))
                unlinkSync(lockFile);
        }
        catch { /* best-effort */ }
        try {
            const lockDir = join(dir + ".lock");
            if (existsSync(lockDir))
                rmSync(lockDir, { recursive: true, force: true });
        }
        catch { /* best-effort */ }
    }
    _lockDirRegistry.clear();
    // Clean up numbered lock file variants from cloud sync conflicts (#1315)
    cleanupStrayLockFiles(basePath);
    _lockedPath = null;
    _lockPid = 0;
    _lockCompromised = false;
    _lockAcquiredAt = 0;
    _snapshotLockPath = null;
}
/**
 * Check if a session lock exists and return its data (for crash recovery).
 * Does NOT acquire the lock.
 */
export function readSessionLockData(basePath) {
    return readExistingLockData(lockPath(basePath));
}
/**
 * Check if the process that wrote the lock is still alive.
 */
export function isSessionLockProcessAlive(data) {
    return isPidAlive(data.pid);
}
/**
 * Returns true if we currently hold a session lock for the given path.
 */
export function isSessionLockHeld(basePath) {
    return _lockedPath === basePath && _lockPid === process.pid;
}
/**
 * Returns a snapshot of the registered lock directory paths for diagnostics.
 * Exported for tests only.
 */
export function _getRegisteredLockDirs() {
    return [..._lockDirRegistry];
}
// ─── Internal Helpers ───────────────────────────────────────────────────────
function readExistingLockData(lp) {
    try {
        if (!existsSync(lp))
            return null;
        const raw = readFileSync(lp, "utf-8");
        return JSON.parse(raw);
    }
    catch {
        return null;
    }
}
export function readExistingLockDataWithRetry(lp, options) {
    const maxAttempts = options?.maxAttempts ?? 3;
    const delayMs = options?.delayMs ?? 200;
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        const data = readExistingLockData(lp);
        if (data !== null)
            return data;
        if (attempt < maxAttempts) {
            // Synchronous busy-wait — onCompromised runs in a sync callback context
            // and the delays are short (200ms default).
            const start = Date.now();
            while (Date.now() - start < delayMs) {
                // busy-wait
            }
        }
    }
    return null;
}
function isPidAlive(pid) {
    if (!Number.isInteger(pid) || pid <= 0)
        return false;
    if (pid === process.pid)
        return false;
    try {
        process.kill(pid, 0);
        return true;
    }
    catch (err) {
        if (err.code === "EPERM")
            return true;
        return false;
    }
}
