// storage/transition-log.ts — SPEC-723: Hash-chained transition log (JSONL per project)
//
// Layout: ~/.planu/data/projects/<projectId>/transition-log.jsonl
//
// Each entry is a JSON object on its own line. The `sha` field is a SHA-256 hash
// of the entry's canonical JSON (excluding `sha`), chained via `prevSha`.
import { createHash, randomUUID } from 'node:crypto';
import { appendFile, readFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { projectDataDir } from './base-store.js';
// ---------------------------------------------------------------------------
// Path helper
// ---------------------------------------------------------------------------
function transitionLogPath(projectId) {
    return join(projectDataDir(projectId), 'transition-log.jsonl');
}
// ---------------------------------------------------------------------------
// Node error type guard
// ---------------------------------------------------------------------------
function isNodeError(err) {
    return err instanceof Error && 'code' in err;
}
// ---------------------------------------------------------------------------
// Hash chain helpers
// ---------------------------------------------------------------------------
function canonicalEntry(entry) {
    // SPEC-734: stable key order includes enriched fields; optional fields are always
    // serialised (as undefined → JSON null) so legacy entries that omit them still
    // produce the same sha they had when written (they never carried these keys).
    return JSON.stringify({
        id: entry.id,
        timestamp: entry.timestamp,
        projectId: entry.projectId,
        specId: entry.specId,
        eventType: entry.eventType,
        frontmatterSha: entry.frontmatterSha,
        from: entry.from,
        to: entry.to,
        actor: entry.actor,
        reason: entry.reason,
        sessionId: entry.sessionId,
        modelId: entry.modelId,
        gateResults: entry.gateResults,
        meta: entry.meta,
        prevSha: entry.prevSha,
    });
}
function computeEntrySha(entry) {
    return createHash('sha256').update(canonicalEntry(entry), 'utf8').digest('hex');
}
// ---------------------------------------------------------------------------
// Per-file write queues (serialize writes to maintain chain integrity)
// ---------------------------------------------------------------------------
const writeQueues = new Map();
async function doAppend(filePath, input) {
    await mkdir(dirname(filePath), { recursive: true });
    // Read last entry's sha to chain
    const lines = await readJsonlLines(filePath);
    let prevSha = '';
    if (lines.length > 0) {
        const lastLine = lines[lines.length - 1] ?? '';
        try {
            const parsed = JSON.parse(lastLine);
            prevSha = typeof parsed.sha === 'string' ? parsed.sha : '';
        }
        catch {
            prevSha = '';
        }
    }
    const partial = {
        id: randomUUID(),
        timestamp: new Date().toISOString(),
        projectId: input.projectId,
        specId: input.specId,
        eventType: input.eventType,
        frontmatterSha: input.frontmatterSha,
        from: input.from,
        to: input.to,
        actor: input.actor,
        reason: input.reason,
        // SPEC-734: enriched fields (optional, backward-compatible)
        ...(input.sessionId !== undefined && { sessionId: input.sessionId }),
        ...(input.modelId !== undefined && { modelId: input.modelId }),
        ...(input.gateResults !== undefined && { gateResults: input.gateResults }),
        ...(input.meta !== undefined && { meta: input.meta }),
        prevSha,
    };
    const sha = computeEntrySha(partial);
    const entry = { ...partial, sha };
    await appendFile(filePath, JSON.stringify(entry) + '\n', 'utf-8');
    return entry;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Append a transition event to the project's transition log.
 * Writes are serialized per file to preserve hash-chain integrity.
 */
export function appendTransitionEvent(input) {
    const filePath = transitionLogPath(input.projectId);
    let result;
    const next = (writeQueues.get(filePath) ?? Promise.resolve()).then(async () => {
        result = await doAppend(filePath, input);
    });
    writeQueues.set(filePath, next.then(() => undefined, () => undefined));
    return next.then(() => result);
}
/**
 * Read all transition log entries for a given project and spec.
 * Returns an empty array if the file does not exist.
 */
export async function readTransitionLog(projectId, specId) {
    const filePath = transitionLogPath(projectId);
    const lines = await readJsonlLines(filePath);
    const entries = [];
    for (const line of lines) {
        try {
            const entry = JSON.parse(line);
            if (specId === undefined || entry.specId === specId) {
                entries.push(entry);
            }
        }
        catch {
            // skip malformed lines
        }
    }
    return entries;
}
/**
 * SPEC-734: Verify the hash-chain integrity of a project's transition log.
 *
 * For each entry, recomputes `sha` from `canonicalEntry(entry)` and checks
 * that `prevSha` matches the previous entry's `sha`. Legacy entries that
 * predate SPEC-734 (missing `sessionId`/`modelId`/`gateResults`) are
 * tolerated — their sha was computed without those fields and remains valid
 * because `canonicalEntry` serialises undefined fields as JSON `undefined`
 * (i.e. they are omitted from the string, same as when they were written).
 */
export async function verifyTransitionLogChain(projectId) {
    const filePath = transitionLogPath(projectId);
    const lines = await readJsonlLines(filePath);
    if (lines.length === 0) {
        return { valid: true, totalEntries: 0, brokenAt: null };
    }
    let prevSha = '';
    for (let i = 0; i < lines.length; i++) {
        let entry;
        try {
            entry = JSON.parse(lines[i] ?? '');
        }
        catch {
            return { valid: false, totalEntries: lines.length, brokenAt: i };
        }
        // Recompute sha from all fields except 'sha' itself
        const { sha: storedSha, ...rest } = entry;
        const expectedSha = computeEntrySha(rest);
        if (storedSha !== expectedSha) {
            return { valid: false, totalEntries: lines.length, brokenAt: i };
        }
        if (i === 0) {
            // First entry must have prevSha === '' (genesis)
            if (entry.prevSha !== '') {
                return { valid: false, totalEntries: lines.length, brokenAt: 0 };
            }
        }
        else if (entry.prevSha !== prevSha) {
            return { valid: false, totalEntries: lines.length, brokenAt: i };
        }
        prevSha = storedSha;
    }
    return { valid: true, totalEntries: lines.length, brokenAt: null };
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async function readJsonlLines(filePath) {
    let raw;
    try {
        raw = await readFile(filePath, 'utf-8');
    }
    catch (err) {
        if (isNodeError(err) && err.code === 'ENOENT') {
            return [];
        }
        throw err;
    }
    return raw.split('\n').filter((l) => l.trim().length > 0);
}
//# sourceMappingURL=transition-log.js.map