// atomic-write-file.ts — Atomic writes for spec lifecycle markdown files (SPEC-718)
// Distinct from atomic-writer.ts (JSON + backup rotation for config stores).
// This module targets spec.md / technical.md / hook scripts via tmp+fsync+rename.
// SPEC-747: Freeze-on-done guard — rejects writes to done/discarded specs without unlock token.
import { open, mkdir, rename, unlink, readdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { randomBytes } from 'node:crypto';
import { isSpecMdPath, isSpecFrozenByPath } from '../freeze/freeze-checker.js';
import { hasUnlockToken, consumeUnlock } from '../freeze/unlock-token.js';
const IS_POSIX = process.platform !== 'win32';
// ---------------------------------------------------------------------------
// SPEC-747: Freeze-on-done error
// ---------------------------------------------------------------------------
/**
 * Error thrown when a write to a frozen spec.md is rejected.
 * SPEC-747: freeze-on-done enforcement.
 */
export class SpecFrozenError extends Error {
    specId;
    fixHint;
    code = 'SPEC_FROZEN';
    constructor(specId, fixHint) {
        super(`Spec "${specId}" is frozen (status=done). ` +
            `Run bump_spec_version before editing. ` +
            `Fix hint: ${fixHint}`);
        this.specId = specId;
        this.fixHint = fixHint;
        this.name = 'SpecFrozenError';
    }
}
/**
 * Run the SPEC-747 freeze check before allowing a write to spec.md.
 * Throws SpecFrozenError or Error if the write should be blocked.
 * No-op if the file is not a frozen spec.md.
 */
function enforceFreezePolicy(targetPath, opts) {
    if (opts?._skipFreezeCheck || !isSpecMdPath(targetPath)) {
        return;
    }
    const freezeStatus = isSpecFrozenByPath(targetPath);
    if (!freezeStatus.frozen) {
        return;
    }
    if (opts?.forceEdit) {
        if (opts.forceEdit.reason.length < 30) {
            throw new Error(`INVALID_FORCE_REASON: forceEdit.reason must be >= 30 chars (got ${opts.forceEdit.reason.length}).`);
        }
        // forceEdit accepted — caller is responsible for retro-audit
        return;
    }
    if (hasUnlockToken(targetPath)) {
        // Valid unlock token — consume it (single-use)
        consumeUnlock(targetPath);
        return;
    }
    const specIdMatch = /SPEC-\d+/.exec(targetPath);
    const specId = specIdMatch?.[0] ?? 'unknown';
    throw new SpecFrozenError(specId, `Run bump_spec_version({ specId: '${specId}', kind: 'patch', reason: '...' }) first.`);
}
// ---------------------------------------------------------------------------
// Atomic write
// ---------------------------------------------------------------------------
/**
 * Atomically write `content` to `targetPath`.
 *
 * Sequence:
 *   1. SPEC-747 freeze check (if target is a frozen spec.md)
 *   2. mkdir -p dirname(targetPath)
 *   3. write content to `${targetPath}.tmp.<pid>.<nonce>` (exclusive create)
 *   4. fsync the tmp file descriptor
 *   5. rename tmp -> targetPath (POSIX-atomic on same fs)
 *   6. (optional) fsync parent directory fd
 *
 * On any error, the tmp file is unlinked best-effort and the original
 * `targetPath` is unchanged. Never returns with both target and tmp present.
 *
 * EXDEV: Cross-device rename is rejected with an explicit error. Callers must
 * keep tmp on the same filesystem as target.
 */
export async function atomicWriteFile(targetPath, content, opts) {
    // SPEC-747: freeze-on-done guard (extracted to reduce complexity)
    enforceFreezePolicy(targetPath, opts);
    const encoding = opts?.encoding ?? 'utf-8';
    const mode = opts?.mode ?? 0o644;
    const fsyncParent = opts?.fsyncParent ?? IS_POSIX;
    const nonce = opts?.tmpSuffix ?? `.tmp.${process.pid.toString()}.${randomBytes(4).toString('hex')}`;
    const tmpPath = `${targetPath}${nonce}`;
    await mkdir(dirname(targetPath), { recursive: true });
    let tmpFd = null;
    try {
        // Exclusive create — prevents name collision if two calls race with same nonce
        tmpFd = await open(tmpPath, 'wx', mode);
        const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, encoding);
        await tmpFd.write(buf);
        await tmpFd.datasync();
        await tmpFd.close();
        tmpFd = null;
        await rename(tmpPath, targetPath);
        if (fsyncParent) {
            await fsyncDirectory(dirname(targetPath));
        }
    }
    catch (err) {
        // Close fd if still open
        if (tmpFd !== null) {
            try {
                await tmpFd.close();
            }
            catch {
                // ignore close error
            }
        }
        // Best-effort cleanup of tmp file
        try {
            await unlink(tmpPath);
        }
        catch {
            // Ignore cleanup errors — tmp will be swept by cleanupOrphanTmps()
        }
        throw err;
    }
}
/**
 * Fsync a directory to flush its entry metadata to disk.
 * No-op on Windows (not supported).
 */
async function fsyncDirectory(dirPath) {
    if (!IS_POSIX) {
        return;
    }
    let dirFd = null;
    try {
        dirFd = await open(dirPath, 'r');
        await dirFd.datasync();
        await dirFd.close();
        dirFd = null;
    }
    catch {
        // Non-fatal — target file is already durable, only dir metadata may need recovery
        if (dirFd !== null) {
            try {
                await dirFd.close();
            }
            catch {
                // ignore
            }
        }
    }
}
/**
 * Remove orphaned `.tmp.<pid>.<nonce>` files left by interrupted writes.
 * Called by `init_project`'s cleanup pass on startup.
 *
 * @param dir - Directory to scan recursively for orphan tmp files.
 */
export async function cleanupOrphanTmps(dir) {
    let removed = 0;
    const entries = await readdir(dir, { withFileTypes: true });
    for (const entry of entries) {
        const full = `${dir}/${entry.name}`;
        if (entry.isDirectory()) {
            removed += await cleanupOrphanTmps(full);
        }
        else if (entry.isFile() && /\.tmp\.\d+\.[0-9a-f]{8}$/.test(entry.name)) {
            try {
                await unlink(full);
                removed++;
            }
            catch {
                // ignore
            }
        }
    }
    return removed;
}
//# sourceMappingURL=atomic-write-file.js.map