// SF Extension — Projection Renderers (DB -> Markdown)
// Renders PLAN.md, ROADMAP.md, SUMMARY.md, and STATE.md from database rows.
// Projections are read-only views of engine state (Layer 3 of the architecture).
import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
import { writeRoadmapJsonProjection } from "./roadmap-json-projection.js";
import {
	_getAdapter,
	getMilestone,
	getMilestoneSlices,
	getSliceTasks,
	getVerificationEvidence,
	isDbAvailable,
} from "./sf-db.js";
import { deriveState } from "./state.js";
import { isClosedStatus } from "./status-guards.js";
import { logWarning } from "./workflow-logger.js";
// ─── Helpers ─────────────────────────────────────────────────────────────
/**
 * Strip a leading ID prefix (e.g. "M001: " or "S04: ") from a title
 * to prevent double-prefixing when the renderer adds its own prefix.
 * Handles repeated prefixes (e.g. "M001: M001: M001: Title" → "Title").
 */
/**
 * Strip leading ID prefix from a title to prevent double-prefixing.
 * Handles repeated prefixes (e.g., "M001: M001: Title" → "Title").
 */
export function stripIdPrefix(title, id) {
	const prefix = `${id}: `;
	let result = title;
	while (result.startsWith(prefix)) {
		result = result.slice(prefix.length);
	}
	return result.trim() || title;
}
/**
 * Render a model-provided list entry without corrupting ordered lists.
 *
 * Purpose: projection fallback output remains valid Markdown when planning
 * rows contain numbered success criteria from the LLM.
 * Consumer: renderPlanContent when writing PLAN.md projections.
 */
function renderListEntry(entry) {
	const trimmed = entry.trim();
	const orderedBullet = trimmed.match(/^[-*+]\s+(\d+)[.)]\s+(.+)$/);
	if (orderedBullet) {
		return `${orderedBullet[1]}. ${orderedBullet[2].trim()}`;
	}
	const ordered = trimmed.match(/^(\d+)[.)]\s+(.+)$/);
	if (ordered) {
		return `${ordered[1]}. ${ordered[2].trim()}`;
	}
	if (/^[-*+]\s+\S/.test(trimmed)) {
		return trimmed;
	}
	return `- ${trimmed}`;
}
/**
 * Surround ATX headings in model-provided markdown with blank lines.
 *
 * Purpose: generated PLAN.md projections pass content validation even when
 * task descriptions contain LLM-authored step headings.
 * Consumer: renderPlanContent task entries.
 */
function normalizeMarkdownBlockSpacing(text) {
	const sourceLines = text.trim().replace(/\r\n/g, "\n").split("\n");
	const output = [];
	let inFence = false;
	for (let i = 0; i < sourceLines.length; i++) {
		const line = sourceLines[i];
		const trimmed = line.trim();
		const fence = /^(```|~~~)/.test(trimmed);
		const heading = !inFence && /^#{1,6}\s+\S/.test(trimmed);
		if (heading && output.length > 0 && output[output.length - 1]?.trim()) {
			output.push("");
		}
		output.push(line);
		if (fence) {
			inFence = !inFence;
		}
		const next = sourceLines[i + 1];
		if (heading && next !== undefined && next.trim()) {
			output.push("");
		}
	}
	return output.join("\n").trim();
}
/**
 * Append model-provided markdown as an indented child block.
 *
 * Purpose: task description subsections stay nested under their task instead
 * of becoming top-level PLAN.md headings.
 * Consumer: renderPlanContent task entries.
 */
function appendIndentedMarkdownBlock(lines, text, indent = "  ") {
	for (const line of normalizeMarkdownBlockSpacing(text).split("\n")) {
		lines.push(line.trim() ? `${indent}${line}` : "");
	}
}
// ─── PLAN.md Projection ──────────────────────────────────────────────────
/**
 * Render PLAN.md content from a slice row and its task rows.
 * Pure function — no side effects.
 */
/**
 * Render PLAN.md content from a slice row and its task rows.
 * Pure function with no side effects.
 */
export function renderPlanContent(sliceRow, taskRows) {
	const lines = [];
	const displayTitle = stripIdPrefix(sliceRow.title, sliceRow.id);
	lines.push(`# ${sliceRow.id}: ${displayTitle}`);
	lines.push("");
	// #2945: never use full_summary_md/full_uat_md as display fallbacks —
	// they contain multi-line rendered markdown that corrupts single-line fields.
	lines.push(`**Goal:** ${sliceRow.goal || "TBD"}`);
	lines.push(`**Demo:** After this: ${sliceRow.demo || "TBD"}`);
	lines.push("");
	lines.push("## Must-Haves");
	lines.push("");
	if (sliceRow.success_criteria.trim()) {
		for (const line of sliceRow.success_criteria
			.split(/\n+/)
			.map((entry) => entry.trim())
			.filter(Boolean)) {
			lines.push(renderListEntry(line));
		}
	} else {
		lines.push("- Complete the planned slice outcomes.");
	}
	lines.push("");
	lines.push("## Adversarial Review");
	lines.push("");
	lines.push("### Partner Review");
	lines.push("");
	lines.push(sliceRow.adversarial_partner?.trim() || "Missing partner review.");
	lines.push("");
	lines.push("### Combatant Review");
	lines.push("");
	lines.push(
		sliceRow.adversarial_combatant?.trim() || "Missing combatant review.",
	);
	lines.push("");
	lines.push("### Architect Review");
	lines.push("");
	lines.push(
		sliceRow.adversarial_architect?.trim() || "Missing architect review.",
	);
	lines.push("");
	if (sliceRow.planning_meeting) {
		lines.push("## Planning Meeting");
		lines.push("");
		lines.push("### Trigger");
		lines.push("");
		lines.push(sliceRow.planning_meeting.trigger.trim());
		lines.push("");
		lines.push("### Product Manager");
		lines.push("");
		lines.push(sliceRow.planning_meeting.pm.trim());
		lines.push("");
		if (sliceRow.planning_meeting.userAdvocate?.trim()) {
			lines.push("### User Advocate");
			lines.push("");
			lines.push(sliceRow.planning_meeting.userAdvocate.trim());
			lines.push("");
		}
		if (sliceRow.planning_meeting.customerPanel?.trim()) {
			lines.push("### Customer Panel");
			lines.push("");
			lines.push(sliceRow.planning_meeting.customerPanel.trim());
			lines.push("");
		}
		if (sliceRow.planning_meeting.business?.trim()) {
			lines.push("### Business");
			lines.push("");
			lines.push(sliceRow.planning_meeting.business.trim());
			lines.push("");
		}
		lines.push("### Researcher");
		lines.push("");
		lines.push(sliceRow.planning_meeting.researcher.trim());
		lines.push("");
		if (sliceRow.planning_meeting.deliveryLead?.trim()) {
			lines.push("### Delivery Lead");
			lines.push("");
			lines.push(sliceRow.planning_meeting.deliveryLead.trim());
			lines.push("");
		}
		lines.push("### Partner");
		lines.push("");
		lines.push(sliceRow.planning_meeting.partner.trim());
		lines.push("");
		lines.push("### Combatant");
		lines.push("");
		lines.push(sliceRow.planning_meeting.combatant.trim());
		lines.push("");
		lines.push("### Architect");
		lines.push("");
		lines.push(sliceRow.planning_meeting.architect.trim());
		lines.push("");
		lines.push("### Moderator");
		lines.push("");
		lines.push(sliceRow.planning_meeting.moderator.trim());
		lines.push("");
		lines.push("### Recommended Route");
		lines.push("");
		lines.push(sliceRow.planning_meeting.recommendedRoute);
		lines.push("");
		lines.push("### Confidence");
		lines.push("");
		lines.push(sliceRow.planning_meeting.confidenceSummary.trim());
		lines.push("");
	}
	if (sliceRow.proof_level.trim()) {
		lines.push("## Proof Level");
		lines.push("");
		lines.push(`- This slice proves: ${sliceRow.proof_level.trim()}`);
		lines.push("");
	}
	if (sliceRow.integration_closure.trim()) {
		lines.push("## Integration Closure");
		lines.push("");
		lines.push(sliceRow.integration_closure.trim());
		lines.push("");
	}
	if (sliceRow.observability_impact.trim()) {
		lines.push("## Observability / Diagnostics");
		lines.push("");
		lines.push(sliceRow.observability_impact.trim());
		lines.push("");
	}
	const verificationCommands = taskRows
		.map((task) => task.verify?.trim())
		.filter((verify) => Boolean(verify));
	if (verificationCommands.length > 0) {
		lines.push("## Verification");
		lines.push("");
		for (const command of verificationCommands) {
			lines.push(command.startsWith("-") ? command : `- ${command}`);
		}
		lines.push("");
	}
	lines.push("## Tasks");
	for (const task of taskRows) {
		const checkbox = isClosedStatus(task.status) ? "[x]" : "[ ]";
		lines.push(`- ${checkbox} **${task.id}: ${task.title}**`);
		if (task.description.trim()) {
			appendIndentedMarkdownBlock(lines, task.description);
		}
		// Estimate subline (always present if non-empty)
		if (task.estimate) {
			lines.push(`  - Estimate: ${task.estimate}`);
		}
		// Files subline (only if non-empty array)
		if (task.files && task.files.length > 0) {
			lines.push(`  - Files: ${task.files.join(", ")}`);
		}
		// Verify subline (only if non-null)
		if (task.verify) {
			lines.push(`  - Verify: ${task.verify}`);
		}
		// Duration subline (only if recorded)
		if (task.duration) {
			lines.push(`  - Duration: ${task.duration}`);
		}
		// Blocker subline (if discovered)
		if (task.blocker_discovered && task.known_issues) {
			lines.push(`  - Blocker: ${task.known_issues}`);
		}
	}
	lines.push("");
	return lines.join("\n");
}
/**
 * Render PLAN.md projection to disk for a specific slice.
 * Queries DB via helper functions, renders content, writes via atomicWriteSync.
 */
/**
 * Render and write PLAN.md projection to disk for a slice.
 * Queries DB, renders content, and writes via atomic write.
 */
export function renderPlanProjection(basePath, milestoneId, sliceId) {
	const sliceRows = getMilestoneSlices(milestoneId);
	const sliceRow = sliceRows.find((s) => s.id === sliceId);
	if (!sliceRow) return;
	const taskRows = getSliceTasks(milestoneId, sliceId);
	const content = renderPlanContent(sliceRow, taskRows);
	const dir = join(
		basePath,
		".sf",
		"milestones",
		milestoneId,
		"slices",
		sliceId,
	);
	mkdirSync(dir, { recursive: true });
	atomicWriteSync(join(dir, `${sliceId}-PLAN.md`), content);
}
// ─── ROADMAP.md Projection ───────────────────────────────────────────────
/**
 * Render ROADMAP.md content from a milestone row and its slice rows.
 * Pure function — no side effects.
 */
export function renderRoadmapContent(milestoneRow, sliceRows) {
	const lines = [];
	const displayTitle = stripIdPrefix(milestoneRow.title, milestoneRow.id);
	lines.push(`# ${milestoneRow.id}: ${displayTitle}`);
	lines.push("");
	lines.push("## Vision");
	lines.push(milestoneRow.vision || milestoneRow.title || "TBD");
	lines.push("");
	if (milestoneRow.product_research) {
		lines.push(...renderProductResearchLines(milestoneRow.product_research));
		lines.push("");
	}
	if (milestoneRow.vision_meeting) {
		lines.push("## Vision Alignment Meeting");
		lines.push("");
		lines.push("### Trigger");
		lines.push(milestoneRow.vision_meeting.trigger);
		lines.push("");
		lines.push("### Product Manager");
		lines.push(milestoneRow.vision_meeting.pm);
		lines.push("");
		lines.push("### User Advocate");
		lines.push(milestoneRow.vision_meeting.userAdvocate);
		lines.push("");
		lines.push("### Customer Panel");
		lines.push(milestoneRow.vision_meeting.customerPanel);
		lines.push("");
		lines.push("### Business");
		lines.push(milestoneRow.vision_meeting.business);
		lines.push("");
		lines.push("### Researcher");
		lines.push(milestoneRow.vision_meeting.researcher);
		lines.push("");
		lines.push("### Delivery Lead");
		lines.push(milestoneRow.vision_meeting.deliveryLead);
		lines.push("");
		lines.push("### Partner");
		lines.push(milestoneRow.vision_meeting.partner);
		lines.push("");
		lines.push("### Combatant");
		lines.push(milestoneRow.vision_meeting.combatant);
		lines.push("");
		lines.push("### Architect");
		lines.push(milestoneRow.vision_meeting.architect);
		lines.push("");
		lines.push("### Moderator");
		lines.push(milestoneRow.vision_meeting.moderator);
		lines.push("");
		lines.push("### Weighted Synthesis");
		lines.push(milestoneRow.vision_meeting.weightedSynthesis);
		lines.push("");
		lines.push("### Confidence By Area");
		lines.push(milestoneRow.vision_meeting.confidenceByArea);
		lines.push("");
		lines.push("### Recommended Route");
		lines.push(milestoneRow.vision_meeting.recommendedRoute);
		lines.push("");
	}
	lines.push("## Slice Overview");
	lines.push("| ID | Slice | Risk | Depends | Done | After this |");
	lines.push("|----|-------|------|---------|------|------------|");
	for (const slice of sliceRows) {
		const done = isClosedStatus(slice.status) ? "\u2705" : "\u2B1C";
		// depends is already parsed to string[] by rowToSlice
		let depends = "\u2014";
		if (slice.depends && slice.depends.length > 0) {
			depends = slice.depends.join(", ");
		}
		const risk = (slice.risk || "low").toLowerCase();
		// #2945 Bug 1: never use full_uat_md as a table cell fallback — it contains
		// multi-line UAT content (preconditions, steps, expected results) that
		// corrupts the markdown table and makes subsequent slices invisible.
		const demo = slice.demo || "TBD";
		lines.push(
			`| ${slice.id} | ${slice.title} | ${risk} | ${depends} | ${done} | ${demo} |`,
		);
	}
	lines.push("");
	return lines.join("\n");
}
function renderProductResearchLines(research) {
	const lines = [
		"## Product / Competitor Research",
		"",
		"### Purpose Contract",
		"",
		`- Purpose: ${research.purpose}`,
		`- Consumer: ${research.consumer}`,
		`- Contract: ${research.contract}`,
		`- Failure boundary: ${research.failureBoundary}`,
		`- Evidence: ${research.evidence}`,
		`- Non-goals: ${research.nonGoals}`,
		`- Invariants: ${research.invariants}`,
		`- Assumptions: ${research.assumptions}`,
		"",
		"### Category Findings",
		"",
		`- Category / job: ${research.category} — ${research.targetJob}`,
	];
	for (const item of research.comparables ?? []) {
		lines.push(`- Comparable: ${item}`);
	}
	for (const item of research.tableStakes ?? []) {
		lines.push(`- Table stake: ${item}`);
	}
	for (const item of research.differentiators ?? []) {
		lines.push(`- Differentiator: ${item}`);
	}
	for (const item of research.antiPatterns ?? []) {
		lines.push(`- Do not copy: ${item}`);
	}
	return lines;
}
/**
 * Render ROADMAP.md projection to disk for a specific milestone.
 * Queries DB via helper functions, renders content, writes via atomicWriteSync.
 */
export function renderRoadmapProjection(basePath, milestoneId) {
	const milestoneRow = getMilestone(milestoneId);
	if (!milestoneRow) return;
	const sliceRows = getMilestoneSlices(milestoneId);
	const content = renderRoadmapContent(milestoneRow, sliceRows);
	const dir = join(basePath, ".sf", "milestones", milestoneId);
	mkdirSync(dir, { recursive: true });
	atomicWriteSync(join(dir, `${milestoneId}-ROADMAP.md`), content);
	writeRoadmapJsonProjection(basePath, milestoneId, milestoneRow, sliceRows);
}
// ─── SUMMARY.md Projection ──────────────────────────────────────────────
/**
 * Render SUMMARY.md content from a task row.
 * Single source of truth for summary rendering — used both at completion
 * time and at projection regeneration time (#2720).
 *
 * @param evidence - Optional verification evidence rows. When called from
 *   complete-task, these are passed directly. When called from projection
 *   regeneration, they are queried from the DB by renderSummaryProjection.
 */
export function renderSummaryContent(taskRow, sliceId, milestoneId, evidence) {
	// If the task already has a fully rendered summary (written by handleCompleteTask's
	// renderSummaryMarkdown), use it as-is. That content already includes frontmatter,
	// heading, and all sections. Re-wrapping it inside a second frontmatter/heading
	// envelope produces double frontmatter and duplicate sections.
	if (
		taskRow.full_summary_md &&
		taskRow.full_summary_md.trimStart().startsWith("---")
	) {
		return taskRow.full_summary_md;
	}
	// ── Frontmatter (YAML list format, matches parseSummary() expectations) ──
	const keyFilesYaml =
		taskRow.key_files && taskRow.key_files.length > 0
			? taskRow.key_files.map((f) => `  - ${f}`).join("\n")
			: "  - (none)";
	const keyDecisionsYaml =
		taskRow.key_decisions && taskRow.key_decisions.length > 0
			? taskRow.key_decisions.map((d) => `  - ${d}`).join("\n")
			: "  - (none)";
	// Derive verification_result from evidence if available
	const evidenceList = evidence ?? [];
	const allPassed =
		evidenceList.length > 0 &&
		evidenceList.every((e) => {
			const code = e.exitCode ?? e.exit_code ?? -1;
			return (
				code === 0 ||
				e.verdict.includes("\u2705") ||
				e.verdict.toLowerCase().includes("pass")
			);
		});
	const verificationResult = taskRow.verification_result
		? allPassed
			? "passed"
			: evidenceList.length === 0
				? "untested"
				: "mixed"
		: allPassed
			? "passed"
			: evidenceList.length === 0
				? "untested"
				: "mixed";
	// Build verification evidence table
	let evidenceTable =
		"| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n";
	if (evidenceList.length > 0) {
		evidenceList.forEach((e, i) => {
			const code = e.exitCode ?? e.exit_code ?? 0;
			const dur = e.durationMs ?? e.duration_ms ?? 0;
			evidenceTable += `| ${i + 1} | \`${e.command}\` | ${code} | ${e.verdict} | ${dur}ms |\n`;
		});
	} else {
		evidenceTable +=
			"| \u2014 | No verification commands discovered | \u2014 | \u2014 | \u2014 |\n";
	}
	const title = taskRow.one_liner || taskRow.title || taskRow.id;
	return `---
id: ${taskRow.id}
parent: ${sliceId}
milestone: ${milestoneId}
key_files:
${keyFilesYaml}
key_decisions:
${keyDecisionsYaml}
duration: ${taskRow.duration || ""}
verification_result: ${verificationResult}
completed_at: ${taskRow.completed_at || ""}
blocker_discovered: ${taskRow.blocker_discovered ? "true" : "false"}
---

# ${taskRow.id}: ${title}

**${taskRow.one_liner || ""}**

## What Happened

${taskRow.narrative || "No summary recorded."}

## Verification

${taskRow.verification_result || "No verification recorded."}

## Verification Evidence

${evidenceTable}
## Deviations

${taskRow.deviations || "None."}

## Known Issues

${taskRow.known_issues || "None."}

## Files Created/Modified

${taskRow.key_files && taskRow.key_files.length > 0 ? taskRow.key_files.map((f) => `- \`${f}\``).join("\n") : "None."}
`;
}
/**
 * Render SUMMARY.md projection to disk for a specific task.
 * Queries DB via helper functions, renders content, writes via atomicWriteSync.
 */
export function renderSummaryProjection(
	basePath,
	milestoneId,
	sliceId,
	taskId,
) {
	const taskRows = getSliceTasks(milestoneId, sliceId);
	const taskRow = taskRows.find((t) => t.id === taskId);
	if (!taskRow) return;
	const evidenceRows = getVerificationEvidence(milestoneId, sliceId, taskId);
	const content = renderSummaryContent(
		taskRow,
		sliceId,
		milestoneId,
		evidenceRows,
	);
	const dir = join(
		basePath,
		".sf",
		"milestones",
		milestoneId,
		"slices",
		sliceId,
		"tasks",
	);
	mkdirSync(dir, { recursive: true });
	atomicWriteSync(join(dir, `${taskId}-SUMMARY.md`), content);
}
// ─── STATE.md Projection ────────────────────────────────────────────────
/**
 * Render STATE.md content from SFState.
 * Matches the buildStateMarkdown output format from doctor.ts exactly.
 * Pure function — no side effects.
 */
export function renderStateContent(state) {
	const lines = [];
	lines.push("# SF State", "");
	const activeSlice = state.activeSlice
		? `${state.activeSlice.id}: ${stripIdPrefix(state.activeSlice.title, state.activeSlice.id)}`
		: "None";
	if (state.phase === "complete" && state.lastCompletedMilestone) {
		lines.push(
			`**Last Completed Milestone:** ${state.lastCompletedMilestone.id}: ${state.lastCompletedMilestone.title}`,
		);
	} else {
		const activeMilestone = state.activeMilestone
			? `${state.activeMilestone.id}: ${stripIdPrefix(state.activeMilestone.title, state.activeMilestone.id)}`
			: "None";
		lines.push(`**Active Milestone:** ${activeMilestone}`);
	}
	lines.push(`**Active Slice:** ${activeSlice}`);
	lines.push(`**Phase:** ${state.phase}`);
	if (state.requirements) {
		lines.push(
			`**Requirements Status:** ${state.requirements.active} active \u00b7 ${state.requirements.validated} validated \u00b7 ${state.requirements.deferred} deferred \u00b7 ${state.requirements.outOfScope} out of scope`,
		);
	}
	lines.push("");
	lines.push("## Milestone Registry");
	for (const entry of state.registry) {
		const glyph =
			entry.status === "complete"
				? "\u2705"
				: entry.status === "active"
					? "\uD83D\uDD04"
					: entry.status === "parked"
						? "\u23F8\uFE0F"
						: "\u2B1C";
		lines.push(
			`- ${glyph} **${entry.id}:** ${stripIdPrefix(entry.title, entry.id)}`,
		);
	}
	lines.push("");
	lines.push("## Recent Decisions");
	if (state.recentDecisions.length > 0) {
		for (const decision of state.recentDecisions) lines.push(`- ${decision}`);
	} else {
		lines.push("- None recorded");
	}
	lines.push("");
	lines.push("## Blockers");
	if (state.blockers.length > 0) {
		for (const blocker of state.blockers) lines.push(`- ${blocker}`);
	} else {
		lines.push("- None");
	}
	lines.push("");
	lines.push("## Next Action");
	lines.push(state.nextAction || "None");
	lines.push("");
	return lines.join("\n");
}
/**
 * Render STATE.md projection to disk.
 * Derives state from DB, renders content, writes via atomicWriteSync.
 */
export async function renderStateProjection(basePath) {
	try {
		if (!isDbAvailable()) return;
		// Probe DB handle — adapter may be set but underlying handle closed
		const adapter = _getAdapter();
		if (!adapter) return;
		try {
			adapter.prepare("SELECT 1").get();
		} catch (err) {
			logWarning(
				"projection",
				"renderStateProjection: DB handle probe failed, skipping render",
				{
					error: err.message,
				},
			);
			return;
		}
		await deriveState(basePath); // update DB-backed state caches
	} catch (err) {
		logWarning("projection", `renderStateProjection failed: ${err.message}`);
	}
}
// ─── renderAllProjections ───────────────────────────────────────────────
/**
 * Regenerate all projection files for a milestone from DB state.
 * All calls are wrapped in try/catch — projection failure is non-fatal per D-02.
 */
export async function renderAllProjections(basePath, milestoneId) {
	// Render ROADMAP.md for the milestone
	try {
		renderRoadmapProjection(basePath, milestoneId);
	} catch (err) {
		logWarning(
			"projection",
			`renderRoadmapProjection failed for ${milestoneId}: ${err.message}`,
		);
	}
	// Query all slices for this milestone
	const sliceRows = getMilestoneSlices(milestoneId);
	for (const slice of sliceRows) {
		// PLAN.md is rendered by the authoritative markdown-renderer.js in
		// plan-slice/replan-slice tools. Do NOT overwrite it here — the simplified
		// projection is missing key sections (Must-Haves, Verification, Files
		// Likely Touched) and corrupts multi-line task descriptions (#3651).
		// Render SUMMARY.md for each completed task
		const taskRows = getSliceTasks(milestoneId, slice.id);
		const doneTasks = taskRows.filter(
			(t) => t.status === "done" || t.status === "complete",
		);
		for (const task of doneTasks) {
			try {
				renderSummaryProjection(basePath, milestoneId, slice.id, task.id);
			} catch (err) {
				logWarning(
					"projection",
					`renderSummaryProjection failed for ${milestoneId}/${slice.id}/${task.id}: ${err.message}`,
				);
			}
		}
	}
	// Render STATE.md
	try {
		await renderStateProjection(basePath);
	} catch (err) {
		logWarning("projection", `renderStateProjection failed: ${err.message}`);
	}
}
// ─── regenerateIfMissing ────────────────────────────────────────────────
/**
 * Check if a projection file exists on disk. If missing, regenerate it from DB.
 * Returns true if the file was regenerated, false if it already existed.
 * Satisfies PROJ-05 (corrupted/deleted projections regenerate on demand).
 */
export function regenerateIfMissing(basePath, milestoneId, sliceId, fileType) {
	let filePath;
	switch (fileType) {
		case "PLAN":
			filePath = join(
				basePath,
				".sf",
				"milestones",
				milestoneId,
				"slices",
				sliceId,
				`${sliceId}-PLAN.md`,
			);
			break;
		case "ROADMAP":
			filePath = join(
				basePath,
				".sf",
				"milestones",
				milestoneId,
				`${milestoneId}-ROADMAP.md`,
			);
			break;
		case "SUMMARY":
			// For SUMMARY, we regenerate all task summaries in the slice
			filePath = join(
				basePath,
				".sf",
				"milestones",
				milestoneId,
				"slices",
				sliceId,
				"tasks",
			);
			break;
		case "STATE":
			filePath = join(basePath, ".sf", "STATE.md");
			break;
	}
	if (fileType === "SUMMARY") {
		// Check each completed task's SUMMARY file individually (not just the directory)
		const taskRows = getSliceTasks(milestoneId, sliceId);
		const doneTasks = taskRows.filter(
			(t) => t.status === "done" || t.status === "complete",
		);
		let regenerated = 0;
		for (const task of doneTasks) {
			const summaryPath = join(
				basePath,
				".sf",
				"milestones",
				milestoneId,
				"slices",
				sliceId,
				"tasks",
				`${task.id}-SUMMARY.md`,
			);
			if (!existsSync(summaryPath)) {
				try {
					renderSummaryProjection(basePath, milestoneId, sliceId, task.id);
					regenerated++;
				} catch (err) {
					logWarning(
						"projection",
						`regenerateIfMissing SUMMARY failed for ${task.id}: ${err.message}`,
					);
				}
			}
		}
		return regenerated > 0;
	}
	if (
		fileType === "ROADMAP" &&
		existsSync(filePath) &&
		!existsSync(
			join(
				basePath,
				".sf",
				"milestones",
				milestoneId,
				`${milestoneId}-ROADMAP.json`,
			),
		)
	) {
		try {
			renderRoadmapProjection(basePath, milestoneId);
			return true;
		} catch (err) {
			logWarning(
				"projection",
				`regenerateIfMissing ROADMAP.json failed for ${milestoneId}: ${err.message}`,
			);
			return false;
		}
	}
	if (existsSync(filePath)) {
		return false;
	}
	// Regenerate the missing file
	try {
		switch (fileType) {
			case "PLAN":
				renderPlanProjection(basePath, milestoneId, sliceId);
				break;
			case "ROADMAP":
				renderRoadmapProjection(basePath, milestoneId);
				break;
			case "STATE":
				// renderStateProjection is async — fire-and-forget.
				// Return false since the file isn't written yet; it will appear
				// on the next post-mutation hook cycle.
				void renderStateProjection(basePath);
				return false;
		}
		return true;
	} catch (err) {
		logWarning(
			"projection",
			`regenerateIfMissing ${fileType} failed: ${err.message}`,
		);
		return false;
	}
}
