// engine/release-pipeline/release-spec-closer.ts — SPEC-1012
// Closes specs referenced in a release commit message by updating their status to 'done'.
import { execFile as execFileCb } from 'node:child_process';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { atomicWriteFile } from '../safety/atomic-write-file.js';
import { parseSpecIdsFromCommitMessage } from './parse-spec-refs.js';
import { hashProjectPath } from '../../storage/base-store.js';
import { specStore } from '../../storage/index.js';
const execFile = promisify(execFileCb);
const TERMINAL_STATUSES = new Set(['done', 'discarded']);
// ---------------------------------------------------------------------------
// Frontmatter helpers
// ---------------------------------------------------------------------------
/**
 * Locate the closing `^---$` boundary of the YAML frontmatter so all
 * status read/write operations stay within it. Returns -1 if the file
 * has no frontmatter.
 */
function findFrontmatterEnd(content) {
    if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
        return -1;
    }
    const re = /\n---\s*\n/;
    const match = re.exec(content.slice(4));
    if (!match) {
        return -1;
    }
    return 4 + match.index + match[0].length;
}
/**
 * Read the `status:` value from spec.md frontmatter only — never from body
 * prose. Returns null if no frontmatter or no status line.
 */
function readStatusFromFrontmatter(content) {
    const fmEnd = findFrontmatterEnd(content);
    if (fmEnd === -1) {
        return null;
    }
    const fm = content.slice(0, fmEnd);
    const m = /^status:\s*["']?([^"'\s\n]+)["']?/m.exec(fm);
    return m?.[1] ?? null;
}
/**
 * Replace the `status:` line in frontmatter only — never touches body
 * prose. Tolerates quoted values (`status: "implementing"`) and
 * preserves the rest of the frontmatter byte-for-byte.
 */
function setStatusInFrontmatter(content, newStatus) {
    const fmEnd = findFrontmatterEnd(content);
    if (fmEnd === -1) {
        return content;
    }
    const fm = content.slice(0, fmEnd);
    const body = content.slice(fmEnd);
    const updated = fm.replace(/^(status:\s*)["']?[^"'\s\n]+["']?/m, `$1${newStatus}`);
    return updated + body;
}
/**
 * Append a history entry at the END of the `## History` section (or end
 * of file if no such section). Walks to the next top-level heading or
 * EOF and inserts just before it, preserving chronological order.
 * Anchors the heading match to a line start so prose mentioning
 * `## History` does not trigger the insert.
 */
function appendHistoryEntry(content, entry) {
    const headerRe = /^## History\s*$/m;
    const match = headerRe.exec(content);
    if (!match) {
        const trailer = content.endsWith('\n') ? '' : '\n';
        return `${content}${trailer}\n## History\n\n${entry}\n`;
    }
    const afterHeader = match.index + match[0].length;
    const nextHeadingRe = /^## /m;
    nextHeadingRe.lastIndex = afterHeader;
    const remainder = content.slice(afterHeader);
    const nextMatch = /\n## /.exec(remainder);
    const insertAt = nextMatch ? afterHeader + nextMatch.index : content.length;
    const before = content.slice(0, insertAt).replace(/\n+$/, '\n');
    const after = content.slice(insertAt);
    return `${before}${entry}\n${after}`;
}
// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------
async function readCommitMessage(commitSha, projectPath) {
    const { stdout } = await execFile('git', ['log', '-1', '--format=%B', commitSha], {
        cwd: projectPath,
    });
    return stdout.trim();
}
/**
 * Verify the SHA is reachable from origin/main. Prevents callers from
 * force-closing specs based on a feature-branch commit. Returns true if
 * reachable OR if origin/main is not configured (single-developer repos).
 */
async function isReachableFromMain(commitSha, projectPath) {
    try {
        await execFile('git', ['merge-base', '--is-ancestor', commitSha, 'origin/main'], {
            cwd: projectPath,
        });
        return true;
    }
    catch {
        // Try local main as fallback for repos without origin/main
        try {
            await execFile('git', ['merge-base', '--is-ancestor', commitSha, 'main'], {
                cwd: projectPath,
            });
            return true;
        }
        catch {
            return false;
        }
    }
}
// ---------------------------------------------------------------------------
// Spec resolution
// ---------------------------------------------------------------------------
async function resolveSpecDir(specsRoot, specId) {
    let entries;
    try {
        entries = await readdir(specsRoot);
    }
    catch {
        return null;
    }
    for (const name of entries) {
        if (name.startsWith(`${specId}-`) || name === specId) {
            return join(specsRoot, name);
        }
    }
    return null;
}
// ---------------------------------------------------------------------------
// Autopilot log (best-effort, fire-and-forget)
// ---------------------------------------------------------------------------
function logToAutopilot(projectPath, specId, version, success) {
    void (async () => {
        try {
            const { appendAutopilotLogEntry } = await import('../../storage/autopilot-log-store.js');
            const projectId = hashProjectPath(projectPath);
            await appendAutopilotLogEntry(projectId, {
                specId,
                hookName: 'release-spec-closer',
                result: success ? 'pass' : 'fail',
                timestamp: new Date().toISOString(),
                durationMs: 0,
                ...(success ? {} : { error: `failed to close ${specId} on release ${version}` }),
            });
        }
        catch {
            // best-effort — never block caller
        }
    })();
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
/**
 * Close all specs referenced in the release commit message.
 *
 * - Reads the commit message via `git log -1 --format=%B <commitSha>`
 * - Parses SPEC-NNN IDs from the message
 * - For each ID: finds the spec folder, reads current status, updates to 'done',
 *   and appends a history entry
 * - Idempotent: skips specs already in a terminal status
 */
export async function closeSpecsOnReleasePublish(input) {
    const { commitSha, version, projectPath, specsRoot } = input;
    const resolvedSpecsRoot = specsRoot ?? join(projectPath, 'planu', 'specs');
    if (!(await isReachableFromMain(commitSha, projectPath))) {
        return { closed: [], skipped: [{ specId: commitSha, reason: 'sha-not-reachable-from-main' }] };
    }
    const message = await readCommitMessage(commitSha, projectPath);
    const specIds = parseSpecIdsFromCommitMessage(message);
    const closed = [];
    const skipped = [];
    for (const specId of specIds) {
        const specDir = await resolveSpecDir(resolvedSpecsRoot, specId);
        if (!specDir) {
            skipped.push({ specId, reason: 'spec-folder-not-found' });
            continue;
        }
        const specMdPath = join(specDir, 'spec.md');
        let content;
        try {
            content = await readFile(specMdPath, 'utf-8');
        }
        catch {
            skipped.push({ specId, reason: 'spec-md-not-readable' });
            continue;
        }
        const currentStatus = readStatusFromFrontmatter(content);
        if (currentStatus !== null && TERMINAL_STATUSES.has(currentStatus)) {
            skipped.push({ specId, reason: `already-${currentStatus}` });
            continue;
        }
        const updated = appendHistoryEntry(setStatusInFrontmatter(content, 'done'), `- event: auto-closed by release v${version}`);
        try {
            await atomicWriteFile(specMdPath, updated);
            // SPEC-720 sanctioned write — same pattern as reconcile-spec.ts:402,
            // spec-split-handler.ts:193, approval-handler.ts:303. Keeps the JSON
            // store (specs.json) in sync with the markdown rewrite so downstream
            // tools (list_specs, validate, velocity_report) see the new status.
            await syncJsonStore(projectPath, specId);
            closed.push(specId);
            logToAutopilot(projectPath, specId, version, true);
        }
        catch {
            skipped.push({ specId, reason: 'write-failed' });
            logToAutopilot(projectPath, specId, version, false);
        }
    }
    return { closed, skipped };
}
async function syncJsonStore(projectPath, specId) {
    try {
        const projectId = hashProjectPath(projectPath);
        await specStore.__internalSetStatus(projectId, specId, 'done');
    }
    catch {
        // best-effort: if the JSON store is missing or stale, the markdown
        // is the source of truth and the next list_specs auto-migrator will
        // reconcile (SPEC-461 lean format auto-migration).
    }
}
//# sourceMappingURL=release-spec-closer.js.map