// engine/status-reconciler/index.ts — SPEC-1011 Bug G: status.json reconciles with disk on read
//
// When status.json totalSpecs != actual spec count on disk, recomputes byStatus from
// spec.md frontmatters and atomically rewrites status.json. Runs fire-and-forget.
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { writeJsonSafe } from '../safety/atomic-writer.js';
// ---------------------------------------------------------------------------
// Frontmatter parser (minimal — only status and id fields needed)
// ---------------------------------------------------------------------------
function parseStatusFromFrontmatter(content) {
    const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
    if (!match?.[1]) {
        return null;
    }
    for (const line of match[1].split('\n')) {
        const colonIdx = line.indexOf(':');
        if (colonIdx === -1) {
            continue;
        }
        const key = line.slice(0, colonIdx).trim();
        if (key === 'status') {
            return line
                .slice(colonIdx + 1)
                .trim()
                .replace(/^["']|["']$/g, '');
        }
    }
    return null;
}
// ---------------------------------------------------------------------------
// Disk scan
// ---------------------------------------------------------------------------
/**
 * Scan planu/specs/ on disk and count specs by status.
 * Only counts directories matching the SPEC-\d+ pattern.
 */
export async function computeSpecCountsFromDisk(projectPath) {
    const specsDir = join(projectPath, 'planu', 'specs');
    const byStatus = {};
    let total = 0;
    let entries;
    try {
        entries = await readdir(specsDir);
    }
    catch {
        return { total: 0, byStatus: {} };
    }
    const specDirs = entries.filter((name) => /^SPEC-\d+-/.test(name) || /^SPEC-\d+$/.test(name));
    for (const dir of specDirs) {
        const specPath = join(specsDir, dir, 'spec.md');
        try {
            const content = await readFile(specPath, 'utf-8');
            const status = parseStatusFromFrontmatter(content) ?? 'draft';
            byStatus[status] = (byStatus[status] ?? 0) + 1;
            total++;
        }
        catch {
            // spec.md unreadable — count as draft
            byStatus.draft = (byStatus.draft ?? 0) + 1;
            total++;
        }
    }
    return { total, byStatus };
}
// ---------------------------------------------------------------------------
// Reconciler
// ---------------------------------------------------------------------------
async function readCurrentStatus(statusPath) {
    let raw;
    try {
        raw = await readFile(statusPath, 'utf-8');
    }
    catch (err) {
        if (err instanceof Error && err.code === 'ENOENT') {
            return { totalSpecs: 0, content: {}, exists: false, corrupt: false };
        }
        // Permission denied / EIO / other — treat as corrupt; do NOT clobber.
        return { totalSpecs: 0, content: {}, exists: true, corrupt: true };
    }
    try {
        const parsed = JSON.parse(raw);
        if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
            return { totalSpecs: 0, content: {}, exists: true, corrupt: true };
        }
        const content = parsed;
        const t = content.totalSpecs;
        return {
            totalSpecs: typeof t === 'number' ? t : 0,
            content,
            exists: true,
            corrupt: false,
        };
    }
    catch {
        return { totalSpecs: 0, content: {}, exists: true, corrupt: true };
    }
}
/**
 * Check if status.json totalSpecs matches actual disk count.
 * If counts differ, recompute byStatus from disk and atomically overwrite status.json
 * via writeJsonSafe (with backup rotation — same primitive used elsewhere in the codebase).
 *
 * Designed to be called fire-and-forget (void the returned Promise from the read path).
 */
export async function reconcileStatusFromDisk(projectPath, projectId) {
    const statusPath = join(projectPath, 'planu', 'status.json');
    const current = await readCurrentStatus(statusPath);
    // Refuse to clobber a file we couldn't parse — we don't know what user-set
    // fields would be lost. The user must repair it manually (or run init_project).
    if (current.corrupt) {
        return { reconciled: false, before: current.totalSpecs, after: current.totalSpecs };
    }
    const { total: diskTotal, byStatus } = await computeSpecCountsFromDisk(projectPath);
    // Skip when counts already match (also covers the "0 on both sides" case for empty projects).
    if (diskTotal === current.totalSpecs && current.exists) {
        return { reconciled: false, before: current.totalSpecs, after: diskTotal };
    }
    const updated = {
        ...current.content,
        totalSpecs: diskTotal,
        byStatus,
        updatedAt: new Date().toISOString(),
        reconciledAt: new Date().toISOString(),
        reconcileReason: `count mismatch: status.json had ${String(current.totalSpecs)}, disk has ${String(diskTotal)}`,
    };
    try {
        await writeJsonSafe(statusPath, updated, { verify: false });
    }
    catch {
        // Best-effort — if write fails, return result without throwing
        return { reconciled: false, before: current.totalSpecs, after: diskTotal };
    }
    // Log to autopilot for observability (best-effort)
    try {
        const { appendAutopilotLogEntry } = await import('../../storage/autopilot-log-store.js');
        const resolvedProjectId = projectId ?? (await import('../../storage/base-store.js')).hashProjectPath(projectPath);
        await appendAutopilotLogEntry(resolvedProjectId, {
            specId: 'system',
            hookName: 'status_json_reconcile',
            result: 'pass',
            timestamp: new Date().toISOString(),
            durationMs: 0,
            ...{ from: current.totalSpecs, to: diskTotal },
        });
    }
    catch {
        // best-effort log failure
    }
    return { reconciled: true, before: current.totalSpecs, after: diskTotal };
}
//# sourceMappingURL=index.js.map