import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
/**
 * Claim the workspace (or take over a stale lock) and return a handle.
 *
 * Throws if another live dipclaw owns this workspace, or if contention is
 * pathological and 20 attempts fail. On any internal failure between
 * claim and return, the lock is released before throwing so we don't
 * orphan our own lock file.
 */
export async function acquireWorkspaceLock(userDataDir) {
    const lockFile = path.join(userDataDir, ".dipclaw.lock");
    const pidFile = path.join(userDataDir, ".chrome.pid");
    await claimWorkspaceLock(lockFile);
    return new FileWorkspaceLockHandle(lockFile, pidFile);
}
class FileWorkspaceLockHandle {
    lockFile;
    pidFile;
    released = false;
    constructor(lockFile, pidFile) {
        this.lockFile = lockFile;
        this.pidFile = pidFile;
    }
    writePid(pid) {
        try {
            fs.writeFileSync(this.pidFile, String(pid), "utf-8");
        }
        catch {
            // Best-effort: a missing pid record only costs us orphan detection
            // on the next launch.
        }
    }
    clearPid() {
        try {
            fs.unlinkSync(this.pidFile);
        }
        catch { }
    }
    async killOrphanChrome(userDataDir) {
        await killOrphanChrome(this.pidFile, userDataDir);
    }
    release() {
        if (this.released)
            return;
        this.released = true;
        this.clearPid();
        try {
            fs.unlinkSync(this.lockFile);
        }
        catch { }
    }
}
async function claimWorkspaceLock(lockFile) {
    // Race-free design:
    //   - wx (O_EXCL) on lockFile for claiming the workspace.
    //   - mkdir-based mutex for stale-lock recovery, serializing the
    //     "read lock, judge stale, delete" sequence so concurrent recoverers
    //     can't unlink each other's just-claimed locks.
    //
    // Earlier attempts (rename, token+link) all had residual races when 3+
    // processes contended on a stale lock. mkdir is atomic on POSIX and
    // gives a clean mutex; the only remaining failure mode is a holder
    // crashing inside the (sub-millisecond) critical section, which is
    // handled below via a generous stale-takeover.
    const MAX_ATTEMPTS = 20;
    const mutexDir = `${lockFile}.recovery-lock`;
    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
        if (tryClaimLock(lockFile))
            return;
        // EEXIST on lockFile — read and decide before paying for the mutex.
        const observed = readLockFile(lockFile);
        // Re-acquisition of our own lock (e.g., relaunch after Chrome died but
        // dipclaw kept running). Treat as no-op.
        if (observed?.ownerPid === process.pid)
            return;
        if (observed && isProcessAlive(observed.ownerPid)) {
            throw makeBusyError(observed.ownerPid, lockFile);
        }
        // Stale or corrupt — recover under mutex protection.
        await acquireRecoveryMutex(mutexDir);
        try {
            // Re-evaluate INSIDE the mutex. State may have changed between our
            // outer read and acquiring the mutex; another recoverer may have
            // already cleaned up, or the workspace may now be live-owned.
            const current = readLockFile(lockFile);
            if (!current) {
                // readLockFile returns null for both "missing" and "corrupt JSON
                // / wrong shape". Disambiguate via existsSync: if the file is
                // still there, the content is corrupt and we treat it as stale.
                if (fs.existsSync(lockFile)) {
                    console.log(`⚠️  Corrupt workspace lock at ${lockFile}, removing...`);
                    try {
                        fs.unlinkSync(lockFile);
                    }
                    catch { }
                }
                continue;
            }
            if (current.ownerPid === process.pid)
                return;
            if (isProcessAlive(current.ownerPid)) {
                throw makeBusyError(current.ownerPid, lockFile);
            }
            console.log(`⚠️  Stale workspace lock (previous owner pid ${current.ownerPid} is gone), recovering...`);
            try {
                fs.unlinkSync(lockFile);
            }
            catch { }
        }
        finally {
            try {
                fs.rmdirSync(mutexDir);
            }
            catch { }
        }
    }
    throw new Error(`Failed to acquire workspace lock at ${lockFile} after ${MAX_ATTEMPTS} attempts (high contention?)`);
}
/** Atomic mutex via mkdir(2). Holders rmdir to release. If a holder
 *  crashes inside the (sub-millisecond) critical section, the mutex is
 *  forcibly taken over after STALE_TIMEOUT_MS via atomic rename. */
async function acquireRecoveryMutex(mutexDir) {
    const STALE_TIMEOUT_MS = 30_000;
    const POLL_MS = 100;
    const MAX_WAIT_MS = STALE_TIMEOUT_MS * 2;
    const start = Date.now();
    while (true) {
        try {
            fs.mkdirSync(mutexDir);
            return;
        }
        catch (e) {
            if (e?.code !== "EEXIST")
                throw e;
        }
        if (Date.now() - start > MAX_WAIT_MS) {
            throw new Error(`Recovery mutex at ${mutexDir} held longer than ${MAX_WAIT_MS}ms; aborting.`);
        }
        let stale = false;
        try {
            const stat = fs.statSync(mutexDir);
            if (Date.now() - stat.ctimeMs > STALE_TIMEOUT_MS)
                stale = true;
        }
        catch {
            continue;
        }
        if (stale) {
            // Atomic takeover via rename: only one racer succeeds, the rest
            // get ENOENT and loop back to mkdir.
            const takeoverDir = `${mutexDir}.takeover.${process.pid}`;
            try {
                fs.renameSync(mutexDir, takeoverDir);
                try {
                    fs.rmdirSync(takeoverDir);
                }
                catch { }
            }
            catch (e) {
                if (e?.code !== "ENOENT")
                    throw e;
            }
            continue;
        }
        await new Promise((r) => setTimeout(r, POLL_MS));
    }
}
function tryClaimLock(lockFile) {
    const content = JSON.stringify({
        ownerPid: process.pid,
        startedAt: new Date().toISOString(),
    }, null, 2);
    try {
        fs.writeFileSync(lockFile, content, { flag: "wx" });
        return true;
    }
    catch (e) {
        if (e?.code === "EEXIST")
            return false;
        throw e;
    }
}
function readLockFile(lockFile) {
    try {
        const parsed = JSON.parse(fs.readFileSync(lockFile, "utf-8"));
        if (typeof parsed.ownerPid === "number" &&
            typeof parsed.startedAt === "string") {
            return parsed;
        }
        return null;
    }
    catch {
        return null;
    }
}
function makeBusyError(ownerPid, lockFile) {
    return new Error(`Workspace already in use by another dipclaw process (pid ${ownerPid}).\n` +
        `   Run a second instance with a different "workspace" in config.\n` +
        `   If you're sure no other dipclaw is running, remove the stale lock:\n` +
        `     rm ${lockFile}`);
}
async function killOrphanChrome(pidFile, userDataDir) {
    if (!fs.existsSync(pidFile))
        return;
    let pid;
    try {
        pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
    }
    catch {
        try {
            fs.unlinkSync(pidFile);
        }
        catch { }
        return;
    }
    if (!pid || isNaN(pid) || !isProcessAlive(pid)) {
        try {
            fs.unlinkSync(pidFile);
        }
        catch { }
        return;
    }
    // Verify the live process is actually our Chrome (matches user-data-dir
    // in command line). Avoids killing an unrelated process that recycled
    // the PID since the previous run.
    if (!isOurChromeProcess(pid, userDataDir)) {
        try {
            fs.unlinkSync(pidFile);
        }
        catch { }
        return;
    }
    console.log(`🧹 Found orphan Chrome (pid ${pid}) from a previous run, terminating...`);
    try {
        process.kill(pid, "SIGTERM");
    }
    catch { }
    const deadline = Date.now() + 5000;
    while (Date.now() < deadline) {
        if (!isProcessAlive(pid))
            break;
        await new Promise((r) => setTimeout(r, 100));
    }
    if (isProcessAlive(pid)) {
        try {
            process.kill(pid, "SIGKILL");
        }
        catch { }
        await new Promise((r) => setTimeout(r, 200));
    }
    try {
        fs.unlinkSync(pidFile);
    }
    catch { }
}
function isProcessAlive(pid) {
    try {
        process.kill(pid, 0);
        return true;
    }
    catch (e) {
        return e?.code === "EPERM";
    }
}
function isOurChromeProcess(pid, userDataDir) {
    try {
        if (process.platform === "win32") {
            // wmic is deprecated but still available on Win10/11; if missing we
            // fall through to the catch and skip the kill (safer default).
            const out = execSync(`wmic process where ProcessId=${pid} get CommandLine /FORMAT:LIST`, { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] });
            return out.includes(userDataDir);
        }
        const out = execSync(`ps -p ${pid} -o args=`, {
            encoding: "utf-8",
            timeout: 3000,
            stdio: ["ignore", "pipe", "ignore"],
        });
        return out.includes(userDataDir);
    }
    catch {
        return false;
    }
}
//# sourceMappingURL=workspace-lock.js.map