// storage/gaps-log.ts — SPEC-739: Hash-chained gaps log (JSONL per project)
//
// Layout: ~/.planu/data/projects/<projectId>/research/gaps.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 { fastAppendGapEntry, fastVerifyGapsChain } from '../engine/core-bridge.js';
import { projectDataDir } from './base-store.js';
// ---------------------------------------------------------------------------
// Path helper
// ---------------------------------------------------------------------------
function gapsLogPath(projectId) {
    return join(projectDataDir(projectId), 'research', 'gaps.jsonl');
}
// ---------------------------------------------------------------------------
// Node error type guard
// ---------------------------------------------------------------------------
function isNodeError(err) {
    return err instanceof Error && 'code' in err;
}
// ---------------------------------------------------------------------------
// Hash chain helpers
// ---------------------------------------------------------------------------
/**
 * Canonical serialisation of a gap entry (stable key order, excluding `sha`).
 */
export function canonicalGapEntry(entry) {
    return JSON.stringify({
        id: entry.id,
        timestamp: entry.timestamp,
        specId: entry.specId,
        description: entry.description,
        severity: entry.severity,
        affectedSpecs: entry.affectedSpecs,
        prevSha: entry.prevSha,
    });
}
/**
 * Compute the SHA-256 of the canonical gap entry serialisation.
 */
export function computeGapEntrySha(entry) {
    return createHash('sha256').update(canonicalGapEntry(entry), 'utf8').digest('hex');
}
// ---------------------------------------------------------------------------
// Per-file write queues (serialize writes to maintain chain integrity)
// ---------------------------------------------------------------------------
const writeQueues = new Map();
async function doAppend(filePath, input) {
    const severity = input.severity ?? 'medium';
    const affectedSpecs = input.affectedSpecs && input.affectedSpecs.length > 0 ? input.affectedSpecs : [input.specId];
    const id = randomUUID();
    const timestamp = new Date().toISOString();
    const bridgeSha = fastAppendGapEntry(filePath, id, timestamp, input.specId, input.description, severity, affectedSpecs);
    if (typeof bridgeSha === 'string' && bridgeSha.length > 0) {
        // The append path owns prevSha lookup internally. The response only needs
        // the persisted entry identity and final sha.
        return {
            id,
            timestamp,
            specId: input.specId,
            description: input.description,
            severity,
            affectedSpecs,
            prevSha: '',
            sha: bridgeSha,
        };
    }
    await mkdir(dirname(filePath), { recursive: true });
    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,
        timestamp,
        specId: input.specId,
        description: input.description,
        severity,
        affectedSpecs,
        prevSha,
    };
    const sha = computeGapEntrySha(partial);
    const entry = { ...partial, sha };
    await appendFile(filePath, JSON.stringify(entry) + '\n', 'utf-8');
    return entry;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Append a gap entry to the project's gaps log.
 * Writes are serialized per file to preserve hash-chain integrity.
 */
export function appendGapEntry(input) {
    const filePath = gapsLogPath(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 gap entries from the project's gaps log.
 * Returns an empty array if the file does not exist.
 */
export async function readGapsLog(projectId) {
    const filePath = gapsLogPath(projectId);
    const lines = await readJsonlLines(filePath);
    const entries = [];
    for (const line of lines) {
        try {
            const entry = JSON.parse(line);
            entries.push(entry);
        }
        catch {
            // skip malformed lines
        }
    }
    return entries;
}
/**
 * Verify the hash chain of the gaps log for a given project.
 * Returns validation result with the index of the first broken entry (if any).
 */
export function verifyGapsLogChain(projectId) {
    const filePath = gapsLogPath(projectId);
    const bridgeResult = fastVerifyGapsChain(filePath);
    if (isGapsLogChainResult(bridgeResult)) {
        return Promise.resolve({
            valid: bridgeResult.valid,
            totalEntries: bridgeResult.totalEntries,
            brokenAt: bridgeResult.brokenAt,
        });
    }
    return verifyGapsLogChainFallback(filePath);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async function verifyGapsLogChainFallback(filePath) {
    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++) {
        const line = lines[i];
        if (!line) {
            continue;
        }
        let entry;
        try {
            entry = JSON.parse(line);
        }
        catch {
            return { valid: false, totalEntries: lines.length, brokenAt: i };
        }
        if (entry.prevSha !== prevSha) {
            return { valid: false, totalEntries: lines.length, brokenAt: i };
        }
        const { sha, ...rest } = entry;
        const expectedSha = computeGapEntrySha(rest);
        if (sha !== expectedSha) {
            return { valid: false, totalEntries: lines.length, brokenAt: i };
        }
        prevSha = sha;
    }
    return { valid: true, totalEntries: lines.length, brokenAt: null };
}
function isGapsLogChainResult(value) {
    if (typeof value !== 'object' || value === null) {
        return false;
    }
    const candidate = value;
    return (typeof candidate.valid === 'boolean' &&
        typeof candidate.totalEntries === 'number' &&
        (typeof candidate.brokenAt === 'number' || candidate.brokenAt === null));
}
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=gaps-log.js.map