/**
 * Server-side Y.Doc persistence — durable snapshot store across server restarts.
 *
 * Why this exists: before persistence, the server discarded all Y.Doc state on
 * restart and re-seeded each doc from its .md file under a fresh server
 * `clientID`. Existing clients that retained their pre-restart Y.Doc state
 * (their tab stayed open) would merge with the disk-seeded state under the new
 * server clientID, keeping both insertion sets — duplicating the entire
 * document. With persistence, the server reloads the binary Y.Doc state on
 * startup, so reconnecting clients see a server state vector that already
 * includes their ops; sync-step-2 becomes a no-op and the race is closed.
 *
 * Storage: `{rootDir}/{repo}/{entityType}/{entityId}.ydoc`
 * File body:
 *   [1 byte]   schema version
 *   [4 bytes]  meta JSON length, big-endian uint32
 *   [N bytes]  meta JSON (UTF-8)
 *   [...]      Y.encodeStateAsUpdate(ydoc) bytes
 *
 * Atomic write: tmp + fsync(tmp) + rename + best-effort fsync(parent dir).
 *
 * Concurrency: each doc has its own write chain, so async filesystem writes
 * land in enqueue order. Without this, two debounced flushes could complete
 * out-of-order and an older snapshot's rename could clobber a newer one.
 */
import * as Y from 'yjs';
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
import { dirname, join } from 'node:path';
const SCHEMA_VERSION = 0x01;
const FLUSH_DEBOUNCE_MS = 1000;
const META_LEN_BYTES = 4;
export class YjsPersistence {
    rootDir;
    /** Per-doc chain — every write appends here so async filesystem writes can't run out of order. */
    writeChains = new Map();
    /** Per-doc debounce timers. */
    timers = new Map();
    /** Latest-scheduled flush payload per doc; the timer captures it on fire. */
    pending = new Map();
    destroyed = false;
    constructor(rootDir) {
        this.rootDir = rootDir;
    }
    /** Resolve the on-disk path for a docName. Throws on path-traversal-shaped input. */
    pathFor(docName) {
        const parts = docName.split('/');
        if (parts.length < 3) {
            throw new Error(`yjs-persistence: invalid docName "${docName}" (expected repo/entityType/entityId)`);
        }
        for (const p of parts) {
            if (p === '' || p === '.' || p === '..') {
                throw new Error(`yjs-persistence: invalid docName segment "${p}" in "${docName}"`);
            }
        }
        return join(this.rootDir, ...parts) + '.ydoc';
    }
    encodeFile(meta, state) {
        const metaJson = Buffer.from(JSON.stringify(meta), 'utf-8');
        const buf = Buffer.alloc(1 + META_LEN_BYTES + metaJson.length + state.length);
        let off = 0;
        buf[off] = SCHEMA_VERSION;
        off += 1;
        buf.writeUInt32BE(metaJson.length, off);
        off += META_LEN_BYTES;
        metaJson.copy(buf, off);
        off += metaJson.length;
        buf.set(state, off);
        return buf;
    }
    decodeFile(buf) {
        if (buf.length < 1 + META_LEN_BYTES) {
            console.warn(`[yjs-persistence] malformed snapshot: ${buf.length} bytes`);
            return null;
        }
        const schemaVersion = buf[0];
        if (schemaVersion !== SCHEMA_VERSION) {
            console.warn(`[yjs-persistence] unsupported schema version ${schemaVersion} (expected ${SCHEMA_VERSION})`);
            return null;
        }
        const metaLen = buf.readUInt32BE(1);
        const metaStart = 1 + META_LEN_BYTES;
        const stateStart = metaStart + metaLen;
        if (stateStart > buf.length) {
            console.warn(`[yjs-persistence] malformed snapshot: declared meta length ${metaLen} exceeds file size`);
            return null;
        }
        let meta;
        try {
            meta = JSON.parse(buf.subarray(metaStart, stateStart).toString('utf-8'));
        }
        catch (err) {
            console.warn('[yjs-persistence] snapshot meta JSON parse failed', err);
            return null;
        }
        const state = new Uint8Array(buf.subarray(stateStart));
        return { state, meta };
    }
    /**
     * Load the snapshot for a doc. Returns null when no snapshot exists, or when
     * the file is malformed / wrong schema version (logged, treated as missing).
     */
    async load(docName) {
        const path = this.pathFor(docName);
        let buf;
        try {
            buf = await fs.readFile(path);
        }
        catch (err) {
            if (err?.code === 'ENOENT')
                return null;
            throw err;
        }
        return this.decodeFile(buf);
    }
    /**
     * Encode the Y.Doc state synchronously and append the disk write to the
     * per-doc chain. Resolves when the write lands.
     *
     * `hashOrResolver` accepts either the literal hash string or a resolver
     * function called at write time. Production callers MUST pass the resolver
     * form — see {@link HashResolver}. The string form exists for test
     * ergonomics where the hash isn't tied to a live `entry.mdRevisionHash`.
     *
     * Bypasses the debounce — call this from drain/destroy paths or when the
     * caller specifically wants a synchronous flush. Routine writes go through
     * scheduleWrite().
     */
    async write(docName, ydoc, hashOrResolver) {
        const ytext = ydoc.getText('content').toString();
        const state = Y.encodeStateAsUpdate(ydoc);
        const mdRevisionHash = typeof hashOrResolver === 'string' ? hashOrResolver : hashOrResolver();
        const meta = {
            schemaVersion: SCHEMA_VERSION,
            ytextHash: sha256(ytext),
            mdRevisionHash,
            writtenAt: Date.now(),
        };
        const buf = this.encodeFile(meta, state);
        return this.appendToChain(docName, () => this.doWrite(docName, buf));
    }
    /**
     * Debounced write. Multiple calls within FLUSH_DEBOUNCE_MS coalesce into one
     * snapshot capturing the last-scheduled state. Use for routine Y.Doc updates.
     *
     * The resolver form ensures the snapshot's `mdRevisionHash` reflects the
     * latest live `entry.mdRevisionHash` at FLUSH time, closing the race where
     * a server-side transact fires this observer before the calling code
     * resumes to bump the hash. String form is for tests.
     */
    scheduleWrite(docName, ydoc, hashOrResolver) {
        if (this.destroyed)
            return;
        const getHash = typeof hashOrResolver === 'string'
            ? () => hashOrResolver
            : hashOrResolver;
        this.pending.set(docName, { ydoc, getHash });
        const existing = this.timers.get(docName);
        if (existing)
            clearTimeout(existing);
        const t = setTimeout(() => {
            this.timers.delete(docName);
            const entry = this.pending.get(docName);
            if (!entry)
                return;
            this.pending.delete(docName);
            this.write(docName, entry.ydoc, entry.getHash).catch(err => {
                console.error(`[yjs-persistence] scheduled write for ${docName} failed`, err);
            });
        }, FLUSH_DEBOUNCE_MS);
        this.timers.set(docName, t);
    }
    /**
     * Cancel any pending debounce for this doc, flush any queued state into the
     * chain, and await the chain to drain. Call before destructive ops on the doc.
     */
    async drain(docName) {
        const t = this.timers.get(docName);
        if (t) {
            clearTimeout(t);
            this.timers.delete(docName);
        }
        const pending = this.pending.get(docName);
        if (pending) {
            this.pending.delete(docName);
            await this.write(docName, pending.ydoc, pending.getHash).catch(err => {
                console.error(`[yjs-persistence] drain flush for ${docName} failed`, err);
            });
        }
        const chain = this.writeChains.get(docName);
        if (chain) {
            try {
                await chain;
            }
            catch { /* errors already logged */ }
        }
    }
    /** Delete the snapshot file for a doc. Drains in-flight writes first. */
    async delete(docName) {
        await this.drain(docName);
        const path = this.pathFor(docName);
        try {
            await fs.unlink(path);
        }
        catch (err) {
            if (err?.code !== 'ENOENT')
                throw err;
        }
    }
    /**
     * Rename the snapshot file. Drains writes for the old doc first, then moves
     * the file. The caller is responsible for updating any in-memory references
     * (e.g. YjsManager's docs map).
     */
    async rename(oldDocName, newDocName) {
        await this.drain(oldDocName);
        const oldPath = this.pathFor(oldDocName);
        const newPath = this.pathFor(newDocName);
        try {
            await fs.mkdir(dirname(newPath), { recursive: true });
            await fs.rename(oldPath, newPath);
        }
        catch (err) {
            if (err?.code !== 'ENOENT')
                throw err;
        }
    }
    /** Flush all pending debounced writes and drain all chains. Call on server shutdown. */
    async destroy() {
        this.destroyed = true;
        const docs = Array.from(this.timers.keys());
        for (const docName of docs) {
            const t = this.timers.get(docName);
            if (t)
                clearTimeout(t);
            this.timers.delete(docName);
            const pending = this.pending.get(docName);
            if (pending) {
                this.pending.delete(docName);
                this.write(docName, pending.ydoc, pending.getHash).catch(err => {
                    console.error(`[yjs-persistence] shutdown flush for ${docName} failed`, err);
                });
            }
        }
        await Promise.allSettled(Array.from(this.writeChains.values()));
        this.writeChains.clear();
    }
    /** Append a unit of work to the per-doc chain. Errors are logged but don't break the chain. */
    appendToChain(docName, work) {
        const prev = this.writeChains.get(docName) ?? Promise.resolve();
        const next = prev
            .catch(() => { })
            .then(work);
        this.writeChains.set(docName, next);
        return next.finally(() => {
            // Only clear if we're still the tail — otherwise a newer entry is queued behind us.
            if (this.writeChains.get(docName) === next) {
                this.writeChains.delete(docName);
            }
        });
    }
    /** Atomic write: tmp file + fsync(tmp) + rename + best-effort fsync(parent dir). */
    async doWrite(docName, buf) {
        const path = this.pathFor(docName);
        await fs.mkdir(dirname(path), { recursive: true });
        const tmpPath = `${path}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
        let fh = null;
        let renamed = false;
        try {
            fh = await fs.open(tmpPath, 'w');
            await fh.writeFile(buf);
            await fh.sync();
            await fh.close();
            fh = null;
            await fs.rename(tmpPath, path);
            renamed = true;
            try {
                const dirFh = await fs.open(dirname(path), 'r');
                try {
                    await dirFh.sync();
                }
                finally {
                    await dirFh.close();
                }
            }
            catch {
                // Many filesystems/platforms reject fsync on a directory. Non-fatal — the
                // power-loss window is bounded by FLUSH_DEBOUNCE_MS, and clients can
                // re-supply unflushed ops on reconnect.
            }
        }
        finally {
            if (fh) {
                try {
                    await fh.close();
                }
                catch { /* swallow */ }
            }
            if (!renamed) {
                try {
                    await fs.unlink(tmpPath);
                }
                catch { /* ignore */ }
            }
        }
    }
}
function sha256(s) {
    return createHash('sha256').update(s, 'utf-8').digest('hex');
}
/**
 * Read the `starttime` field (clock ticks since boot) from `/proc/<pid>/stat`.
 * Linux only; returns null elsewhere or on read failure. Unique per process for
 * the kernel's lifetime, so combined with pid it survives pid reuse. Used by
 * the lock acquisition to distinguish "the studiograph that wrote this lock is
 * still alive" from "the kernel happens to have something else at that pid now"
 * — a critical distinction in containers where the namespace gets reset on
 * restart but the lock file persists on the mounted volume.
 *
 * `/proc/<pid>/stat` format: pid (comm) state ppid ... starttime ... — comm
 * can contain spaces/parens, so we anchor on the LAST `)` rather than splitting
 * naively. After the comm, fields are at fixed offsets: state=0, ppid=1, ...,
 * starttime=19 (0-indexed within the post-comm tail).
 */
function readProcessStartTime(pid) {
    if (process.platform !== 'linux')
        return null;
    try {
        const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
        const close = stat.lastIndexOf(')');
        if (close < 0)
            return null;
        const fields = stat.slice(close + 1).trim().split(/\s+/);
        const starttime = Number.parseInt(fields[19], 10);
        return Number.isFinite(starttime) ? starttime : null;
    }
    catch {
        return null;
    }
}
/**
 * Read the cmdline of a process via `/proc/<pid>/cmdline`. Linux only. Returns
 * empty string on failure. Used as a backup signal when startTime isn't
 * available (legacy lock files written before the start-time field existed).
 *
 * Cmdline args are NUL-separated in /proc; we collapse to spaces for substring
 * matching.
 */
function readProcessCmdline(pid) {
    if (process.platform !== 'linux')
        return '';
    try {
        return readFileSync(`/proc/${pid}/cmdline`, 'utf-8').replace(/\0/g, ' ').trim();
    }
    catch {
        return '';
    }
}
function parseLockSentinel(raw) {
    const trimmed = raw.trim();
    if (!trimmed)
        return null;
    // Legacy format: bare pid number (pre-startTime addition).
    if (/^\d+$/.test(trimmed)) {
        const pid = Number.parseInt(trimmed, 10);
        return Number.isFinite(pid) ? { pid, startTime: 0, cmdline: '' } : null;
    }
    try {
        const parsed = JSON.parse(trimmed);
        if (typeof parsed.pid !== 'number' || !Number.isFinite(parsed.pid))
            return null;
        return {
            pid: parsed.pid,
            startTime: typeof parsed.startTime === 'number' ? parsed.startTime : 0,
            cmdline: typeof parsed.cmdline === 'string' ? parsed.cmdline : '',
        };
    }
    catch {
        return null;
    }
}
function buildSentinel() {
    const sentinel = {
        pid: process.pid,
        startTime: readProcessStartTime(process.pid) ?? 0,
        cmdline: process.argv.join(' ').slice(0, 200),
    };
    return JSON.stringify(sentinel);
}
/**
 * Decide whether a lock held by `recorded` is stale (we can take over) or
 * live (we must refuse to start). Exported for tests.
 *
 * Decision tree:
 * 1. Recorded pid not alive → stale.
 * 2. Recorded pid alive AND we recorded a non-zero startTime AND we can read
 *    the live process's startTime → compare. Mismatch → stale (pid was
 *    recycled in a fresh container or on a long-lived host); match → live.
 * 3. Recorded pid alive AND startTime not usable → fall back to cmdline.
 *    Recorded `studiograph` substring AND live cmdline contains `studiograph`
 *    → live. Recorded had it but live doesn't → stale (pid recycled, different
 *    program now). Recorded didn't have it (legacy or non-studiograph) →
 *    treat as live (paranoid default — better to surface the conflict than
 *    silently take over a lock we can't identify).
 *
 * Returns 'stale' to take over, 'live' to refuse, 'self' to no-op (lock is
 * already ours from earlier in this process).
 */
export function classifyLockHolder(recorded) {
    if (recorded.pid === process.pid)
        return 'self';
    let pidAlive;
    try {
        process.kill(recorded.pid, 0);
        pidAlive = true;
    }
    catch (err) {
        if (err?.code === 'ESRCH')
            return 'stale';
        if (err?.code === 'EPERM') {
            // Process exists but owned by another user. Can't safely take over.
            throw new Error(`[yjs-persistence] pid ${recorded.pid} exists but we can't signal it ` +
                `(different user?). Refusing to share state.`);
        }
        throw err;
    }
    if (!pidAlive)
        return 'stale';
    // Pid is alive. Verify it's actually the same process that wrote the lock.
    if (recorded.startTime > 0) {
        const liveStartTime = readProcessStartTime(recorded.pid);
        if (liveStartTime !== null) {
            return liveStartTime === recorded.startTime ? 'live' : 'stale';
        }
        // We recorded a startTime but can't read the live one — tooling mismatch.
        // Fall through to cmdline check.
    }
    // Cmdline fallback for legacy locks or non-Linux platforms.
    const liveCmdline = readProcessCmdline(recorded.pid);
    if (liveCmdline) {
        // If we recorded a cmdline and it doesn't match the live one's
        // studiograph-ness, the pid has been recycled. Be lenient — match on the
        // substring "studiograph" rather than exact equality, since the same
        // server may be invoked via npx with slightly different paths.
        const recordedLooksLikeUs = recorded.cmdline.includes('studiograph');
        const liveLooksLikeUs = liveCmdline.includes('studiograph');
        if (recordedLooksLikeUs && !liveLooksLikeUs)
            return 'stale';
        if (!recordedLooksLikeUs && liveLooksLikeUs)
            return 'stale';
        return 'live';
    }
    // No cmdline available (non-Linux). Fall back to the strict pre-fix behavior:
    // if pid is alive, treat as live conflict. False positives possible on dev
    // machines where pids get reused, but multi-instance is rare in dev.
    return 'live';
}
/**
 * Acquire an advisory single-instance lock at `<rootDir>/.lock`. The lock is a
 * JSON sentinel `{pid, startTime, cmdline}`. Throws if another live process is
 * already using this directory — multi-instance studiograph against the same
 * workspace state would race on snapshot writes and corrupt them, since we
 * don't have cross-instance Yjs pubsub today.
 *
 * Stale locks (recorded pid is dead, or alive but a different process now —
 * common in containers where the pid namespace resets across restarts but the
 * lock file persists on a mounted volume) are taken over. See
 * {@link classifyLockHolder} for the full decision tree.
 *
 * Returns a release function the caller registers in its shutdown hook.
 *
 * Tests opt out by not calling this — each test uses its own temp dir, so the
 * lock would be a no-op anyway. The server bootstrap calls it explicitly.
 */
export function acquireYjsStateLock(rootDir) {
    mkdirSync(rootDir, { recursive: true });
    // Backfill the parent .studiograph/.gitignore so existing workspaces (which
    // were initialized before persistence existed) don't accidentally start
    // tracking binary snapshot files. New workspaces get this entry via
    // workspace.ts init template; this catches the rest. Idempotent.
    try {
        const studiographDir = dirname(rootDir);
        const gitignorePath = join(studiographDir, '.gitignore');
        if (existsSync(gitignorePath)) {
            const existing = readFileSync(gitignorePath, 'utf-8');
            if (!existing.split('\n').some(l => l.trim() === 'yjs-state/' || l.trim() === '/yjs-state/')) {
                writeFileSync(gitignorePath, existing.replace(/\n*$/, '') + '\n# Yjs binary snapshot state (per-instance, not git-friendly)\nyjs-state/\n');
            }
        }
    }
    catch {
        // Best-effort; don't block startup on a gitignore backfill.
    }
    const lockPath = join(rootDir, '.lock');
    const sentinel = buildSentinel();
    // Atomic create-or-fail. The 'wx' flag bypasses the existsSync→writeFileSync
    // TOCTOU window: two processes racing to acquire the lock can't both succeed,
    // because the kernel fails the second openSync with EEXIST.
    const tryAcquire = () => {
        try {
            const fd = openSync(lockPath, 'wx');
            try {
                writeSync(fd, sentinel);
            }
            finally {
                closeSync(fd);
            }
            return true;
        }
        catch (err) {
            if (err?.code === 'EEXIST')
                return false;
            throw err;
        }
    };
    if (!tryAcquire()) {
        let raw;
        try {
            raw = readFileSync(lockPath, 'utf-8');
        }
        catch {
            raw = '';
        }
        const recorded = parseLockSentinel(raw);
        if (!recorded) {
            // Garbage in the file — treat as stale, replace.
            writeFileSync(lockPath, sentinel, 'utf-8');
        }
        else {
            const verdict = classifyLockHolder(recorded);
            if (verdict === 'stale') {
                writeFileSync(lockPath, sentinel, 'utf-8');
            }
            else if (verdict === 'live') {
                throw new Error(`[yjs-persistence] another studiograph server (pid ${recorded.pid}) ` +
                    `is already using ${rootDir}. Multi-instance is not supported — ` +
                    `stop the other process or pick a different workspace.`);
            }
            // verdict === 'self' → no-op, our lock from an earlier run.
        }
    }
    return function release() {
        try {
            const raw = readFileSync(lockPath, 'utf-8');
            const recorded = parseLockSentinel(raw);
            // Only release if it's still ours — paranoia for the case where
            // another process took over after a stale-lock cleanup.
            if (recorded && recorded.pid === process.pid)
                unlinkSync(lockPath);
        }
        catch {
            // File already gone or unreadable — nothing to release.
        }
    };
}
//# sourceMappingURL=yjs-persistence.js.map