"use strict";
/**
 * DistributedLock — file-based cross-process lock with TTL + heartbeat.
 *
 * Story 6.4 — OpenLife v1.2 Royal Stack.
 *
 * Use case: prevent two OpenLife instances (e.g. dev + autonomous
 * daemon) from running the same WorkflowEngine run or mission
 * concurrently. The lock is a file under `<stateDir>/locks/<key>.lock`
 * containing `{pid, host, lockId, ts, ttlMs}`. Crash safety: if the
 * holder dies, the next contender takes over either (a) after `ttlMs`
 * elapses, or (b) immediately if the holder ran on THIS host and its
 * pid is provably gone (liveness reclaim) — a SIGKILL'd daemon does not
 * lock its own restart out for a full TTL. Reclaim is fail-safe: a live
 * pid, an unprobeable (cross-host) pid, or a reused pid all fall back to
 * TTL-only, so we never steal from a living holder.
 *
 * Atomicity: lock creation uses `O_EXCL | O_CREAT` so two processes
 * racing for the same key see exactly one winner. AtomicWriter is used
 * for renewals.
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.DistributedLock = void 0;
const crypto = __importStar(require("crypto"));
const fs = __importStar(require("fs"));
const os = __importStar(require("os"));
const path = __importStar(require("path"));
const AtomicWriter_1 = require("./AtomicWriter");
/**
 * Probe whether a local pid is currently running. Uses signal 0, which
 * performs permission/existence checks without delivering a signal:
 *   - success            → process exists and is signalable → alive
 *   - EPERM              → process exists but owned by another user → alive
 *   - ESRCH (or other)   → no such process → dead
 * Only meaningful for pids on the local host.
 */
function isProcessAlive(pid) {
    if (!Number.isInteger(pid) || pid <= 0)
        return false;
    try {
        process.kill(pid, 0);
        return true;
    }
    catch (err) {
        return err.code === 'EPERM';
    }
}
class DistributedLock {
    baseDir;
    /** In-process registry of held locks, keyed by lockId. */
    heldByLockId = new Map();
    constructor(opts = {}) {
        this.baseDir = opts.baseDir
            || path.join(process.env.OPENLIFE_STATE_DIR || path.join(process.cwd(), '.openlife'), 'locks');
    }
    /**
     * Attempt to acquire the lock for `key`. Succeeds if (a) no lock file
     * exists, or (b) the existing lock has expired (ts + ttlMs <= now).
     *
     * If `startHeartbeat: true` (default true), the caller's lock TTL is
     * auto-renewed at ttlMs/3 intervals via setInterval. Call release()
     * to stop the heartbeat.
     */
    acquire(key, ttlMs, opts = {}) {
        if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
            return { ok: false, error: 'io_error', message: 'ttlMs must be a positive number' };
        }
        this.ensureBaseDir();
        const filePath = this.lockPath(key);
        const lockId = this.newLockId();
        const now = Date.now();
        const record = {
            pid: process.pid,
            host: os.hostname(),
            lockId,
            key,
            acquiredAt: now,
            ts: now,
            ttlMs,
        };
        // First attempt: exclusive create
        try {
            const fd = fs.openSync(filePath, 'wx');
            try {
                fs.writeFileSync(fd, JSON.stringify(record, null, 2), 'utf-8');
            }
            finally {
                fs.closeSync(fd);
            }
            this.trackHeld(lockId, filePath, ttlMs, opts.startHeartbeat !== false);
            return { ok: true, lockId, filePath };
        }
        catch (err) {
            const code = err.code;
            if (code !== 'EEXIST') {
                return { ok: false, error: 'io_error', message: err instanceof Error ? err.message : String(err) };
            }
            // Lock file exists — check if it's expired
        }
        // Second attempt: takeover if the existing lock is reclaimable
        const existing = this.readLock(filePath);
        if (existing && this.isReclaimable(existing, now)) {
            // Overwrite atomically with our record
            (0, AtomicWriter_1.writeJsonAtomic)(filePath, record);
            this.trackHeld(lockId, filePath, ttlMs, opts.startHeartbeat !== false);
            return { ok: true, lockId, filePath };
        }
        return { ok: false, error: 'collision', heldBy: existing ?? undefined };
    }
    /**
     * Decide whether an existing lock can be taken over. Two independent
     * reclaim conditions, both fail-safe:
     *
     *   1. TTL expiry — `now - ts > ttlMs`. The original heartbeat-based
     *      rule: a holder that stopped renewing eventually goes stale.
     *   2. Liveness reclaim — the holder ran on THIS host and its pid is
     *      provably gone. The heartbeat can never renew a dead pid's `ts`,
     *      so the lock is dead weight; stealing it avoids waiting out the
     *      full TTL on a daemon that was SIGKILL'd mid-mission.
     *
     * Condition 2 is gated on same-host because `process.kill(pid, 0)` can
     * only probe local pids. A live pid (signal succeeds or EPERM), a
     * cross-host pid (unprobeable), or a reused pid (signal succeeds) all
     * fail this check and fall back to TTL-only — we never steal from a
     * living holder.
     */
    isReclaimable(existing, now) {
        if (now - existing.ts > existing.ttlMs)
            return true;
        if (existing.host === os.hostname() &&
            existing.pid !== process.pid &&
            !isProcessAlive(existing.pid)) {
            return true;
        }
        return false;
    }
    /**
     * Update the heartbeat (ts field) on a lock we hold. Returns true if
     * the renewal succeeded (file still exists and our lockId is intact),
     * false if the lock was stolen or removed.
     */
    renew(lockId) {
        const tracked = this.heldByLockId.get(lockId);
        if (!tracked)
            return false;
        const existing = this.readLock(tracked.filePath);
        if (!existing || existing.lockId !== lockId) {
            // Lock was stolen or removed — stop our heartbeat
            this.stopHeartbeat(lockId);
            return false;
        }
        existing.ts = Date.now();
        try {
            (0, AtomicWriter_1.writeJsonAtomic)(tracked.filePath, existing);
            return true;
        }
        catch {
            return false;
        }
    }
    /**
     * Release the lock we hold. Removes the lock file (if we still own it)
     * and stops the heartbeat. Idempotent.
     */
    release(lockId) {
        const tracked = this.heldByLockId.get(lockId);
        if (!tracked)
            return;
        this.stopHeartbeat(lockId);
        const existing = this.readLock(tracked.filePath);
        if (existing && existing.lockId === lockId) {
            try {
                fs.unlinkSync(tracked.filePath);
            }
            catch { /* race or already gone */ }
        }
        this.heldByLockId.delete(lockId);
    }
    /**
     * Inspect the current holder for `key` (or null if unlocked).
     */
    peek(key) {
        return this.readLock(this.lockPath(key));
    }
    /**
     * Release all locks currently held by this process. Useful for clean
     * shutdown of a daemon.
     */
    releaseAll() {
        for (const lockId of Array.from(this.heldByLockId.keys())) {
            this.release(lockId);
        }
    }
    // ─────────────────────────────────────────────────────────
    // Internals
    // ─────────────────────────────────────────────────────────
    trackHeld(lockId, filePath, ttlMs, startHeartbeat) {
        let heartbeat = null;
        if (startHeartbeat) {
            const interval = Math.max(50, Math.floor(ttlMs / 3));
            heartbeat = setInterval(() => {
                this.renew(lockId);
            }, interval);
            // Don't keep the process alive just for the heartbeat
            if (typeof heartbeat.unref === 'function')
                heartbeat.unref();
        }
        this.heldByLockId.set(lockId, { filePath, heartbeat });
    }
    stopHeartbeat(lockId) {
        const tracked = this.heldByLockId.get(lockId);
        if (tracked?.heartbeat) {
            clearInterval(tracked.heartbeat);
            tracked.heartbeat = null;
        }
    }
    readLock(filePath) {
        if (!fs.existsSync(filePath))
            return null;
        try {
            const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
            if (typeof raw?.lockId === 'string' && typeof raw?.ts === 'number')
                return raw;
            return null;
        }
        catch {
            return null;
        }
    }
    ensureBaseDir() {
        if (!fs.existsSync(this.baseDir)) {
            fs.mkdirSync(this.baseDir, { recursive: true });
        }
    }
    lockPath(key) {
        return path.join(this.baseDir, `${this.sanitize(key)}.lock`);
    }
    sanitize(key) {
        return key.replace(/[^a-zA-Z0-9._-]/g, '_');
    }
    newLockId() {
        return `${process.pid}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
    }
    /** Exposed for tests so they can assert resolved storage location. */
    _baseDir() {
        return this.baseDir;
    }
}
exports.DistributedLock = DistributedLock;
