// SF DB Writer — Markdown generators + DB-first write helpers
//
// The missing DB→markdown direction. S03 established markdown→DB (md-importer.ts).
// This module generates DECISIONS.md and REQUIREMENTS.md from DB state,
// computes next decision IDs, and provides write helpers that upsert to DB
// then regenerate the corresponding markdown file.
//
// Critical invariant: generated markdown must round-trip through
// parseDecisionsTable() and parseRequirementsSections() with field fidelity.
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { SF_IO_ERROR, SF_STALE_STATE, SFError } from "./errors.js";
import { clearParseCache, saveFile } from "./files.js";
import { clearPathCache, resolveSfRootFile } from "./paths.js";
import { invalidateStateCache } from "./state.js";
import { logError, logWarning } from "./workflow-logger.js";
// ─── Freeform Detection ───────────────────────────────────────────────────
/**
 * Detect whether a DECISIONS.md file is in canonical table format
 * (generated by generateDecisionsMd).
 *
 * Returns true only if the file starts with the canonical header
 * ("# Decisions Register") that generateDecisionsMd produces.
 * Files with freeform content — even if they contain an appended
 * decisions table section — return false so the freeform content
 * is preserved.
 */
export function isDecisionsTableFormat(content) {
	// The canonical format always starts with "# Decisions Register"
	const firstLine = content.split("\n")[0]?.trim() ?? "";
	if (firstLine !== "# Decisions Register") return false;
	// Additionally verify the file has the canonical table header
	return content.includes(
		"| # | When | Scope | Decision | Choice | Rationale | Revisable?",
	);
}
/**
 * Generate a minimal decisions table section (header + rows) for appending
 * to a freeform DECISIONS.md file.
 */
function _generateDecisionsAppendBlock(decisions) {
	const lines = [];
	lines.push("");
	lines.push("---");
	lines.push("");
	lines.push("## Decisions Table");
	lines.push("");
	lines.push(
		"| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |",
	);
	lines.push(
		"|---|------|-------|----------|--------|-----------|------------|---------|",
	);
	for (const d of decisions) {
		const cells = [
			d.id,
			d.when_context,
			d.scope,
			d.decision,
			d.choice,
			d.rationale,
			d.revisable,
			d.made_by ?? "agent",
		].map((cell) => (cell ?? "").replace(/\|/g, "\\|"));
		lines.push(`| ${cells.join(" | ")} |`);
	}
	return lines.join("\n") + "\n";
}
// ─── Markdown Generators ──────────────────────────────────────────────────
/**
 * Generate full DECISIONS.md content from an array of Decision objects.
 * Produces the canonical format: H1 header, HTML comment block, table header,
 * separator, and one data row per decision.
 *
 * Column order: #, When, Scope, Decision, Choice, Rationale, Revisable?
 */
export function generateDecisionsMd(decisions) {
	const lines = [];
	lines.push("# Decisions Register");
	lines.push("");
	lines.push("<!-- Append-only. Never edit or remove existing rows.");
	lines.push("     To reverse a decision, add a new row that supersedes it.");
	lines.push(
		"     Read this file at the start of any planning or research phase. -->",
	);
	lines.push("");
	lines.push(
		"| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |",
	);
	lines.push(
		"|---|------|-------|----------|--------|-----------|------------|---------|",
	);
	for (const d of decisions) {
		// Escape pipe characters within cell values to preserve table structure
		const cells = [
			d.id,
			d.when_context,
			d.scope,
			d.decision,
			d.choice,
			d.rationale,
			d.revisable,
			d.made_by ?? "agent",
		].map((cell) => (cell ?? "").replace(/\|/g, "\\|"));
		lines.push(`| ${cells.join(" | ")} |`);
	}
	return lines.join("\n") + "\n";
}
// ─── Requirements Markdown Generator ──────────────────────────────────────
/** Status values that map to specific sections, in display order. */
const STATUS_SECTION_MAP = [
	{ status: "active", heading: "Active" },
	{ status: "resolved", heading: "Resolved" },
	{ status: "validated", heading: "Validated" },
	{ status: "deferred", heading: "Deferred" },
	{ status: "superseded", heading: "Superseded" },
	{ status: "cancelled", heading: "Cancelled" },
	{ status: "out-of-scope", heading: "Out of Scope" },
];
const REQUIREMENTS_TITLE = "# Requirements: Autonomous Self-Healing";
function requirementNumber(id) {
	const match = String(id ?? "").match(/^R(\d+)$/);
	return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
}
function singleLine(value, fallback = "") {
	const text = String(value ?? "")
		.replace(/\s+/g, " ")
		.trim();
	return text.length > 0 ? text : fallback;
}
function requirementHeadingDescription(requirement) {
	const firstLine = String(requirement.description ?? "")
		.split(/\r?\n/)
		.find((line) => line.trim().length > 0);
	return singleLine(firstLine, "Untitled");
}
function hasMappedOwner(requirement) {
	const owner = singleLine(requirement.primary_owner).toLowerCase();
	return (
		owner.length > 0 &&
		owner !== "none" &&
		owner !== "unmapped" &&
		!owner.startsWith("unmapped ")
	);
}
function renderRequirementBlock(requirement) {
	const stored = String(requirement.full_content ?? "").trim();
	if (stored.startsWith(`### ${requirement.id} `)) {
		return stored;
	}
	const lines = [];
	lines.push(
		`### ${requirement.id} — ${requirementHeadingDescription(requirement)}`,
	);
	lines.push(`- Class: ${singleLine(requirement.class, "unspecified")}`);
	lines.push(`- Status: ${singleLine(requirement.status, "active")}`);
	lines.push(
		`- Description: ${singleLine(requirement.description, "Untitled")}`,
	);
	lines.push(`- Why it matters: ${singleLine(requirement.why, "unspecified")}`);
	lines.push(`- Source: ${singleLine(requirement.source, "unspecified")}`);
	lines.push(
		`- Primary owning slice: ${singleLine(requirement.primary_owner, "unmapped")}`,
	);
	lines.push(
		`- Supporting slices: ${singleLine(requirement.supporting_slices, "none")}`,
	);
	lines.push(`- Validation: ${singleLine(requirement.validation, "unmapped")}`);
	lines.push(`- Notes: ${singleLine(requirement.notes, "none")}`);
	return lines.join("\n");
}
/**
 * Generate full REQUIREMENTS.md content from an array of Requirement objects.
 * Groups requirements by status into sections (## Active, ## Validated, etc.),
 * each containing ### RXXX — Description headings with bullet fields.
 * Preserves DB-stored full_content blocks when present so DB→markdown projection
 * does not flatten curated requirement text. Appends Traceability and Coverage
 * Summary derived from actual owner mappings.
 */
export function generateRequirementsMd(requirements) {
	const lines = [];
	lines.push(REQUIREMENTS_TITLE);
	lines.push("");
	lines.push(
		"This file is the explicit capability and coverage contract for the project.",
	);
	lines.push("");
	// Group by status
	const byStatus = new Map();
	const sortedRequirements = [...requirements].sort(
		(a, b) => requirementNumber(a.id) - requirementNumber(b.id),
	);
	for (const r of sortedRequirements) {
		const status = (r.status || "active").toLowerCase();
		if (!byStatus.has(status)) byStatus.set(status, []);
		byStatus.get(status).push(r);
	}
	// Emit sections in canonical order
	for (const { status, heading } of STATUS_SECTION_MAP) {
		const reqs = byStatus.get(status);
		if (!reqs || reqs.length === 0) continue;
		lines.push(`## ${heading}`);
		lines.push("");
		for (const r of reqs) {
			lines.push(renderRequirementBlock(r));
			lines.push("");
		}
	}
	// Traceability table
	lines.push("## Traceability");
	lines.push("");
	lines.push("| ID | Class | Status | Primary owner | Supporting | Proof |");
	lines.push("|---|---|---|---|---|---|");
	for (const r of sortedRequirements) {
		const proof = singleLine(r.validation, "unmapped");
		lines.push(
			`| ${r.id} | ${singleLine(r.class)} | ${singleLine(r.status)} | ${singleLine(r.primary_owner, "none")} | ${singleLine(r.supporting_slices, "none")} | ${proof} |`,
		);
	}
	lines.push("");
	// Coverage Summary
	const activeCount = byStatus.get("active")?.length ?? 0;
	const mappedActiveCount =
		byStatus.get("active")?.filter((r) => hasMappedOwner(r)).length ?? 0;
	const validatedReqs = byStatus.get("validated") ?? [];
	const validatedIds = validatedReqs.map((r) => r.id).join(", ");
	lines.push("## Coverage Summary");
	lines.push("");
	lines.push(`- Active requirements: ${activeCount}`);
	lines.push(`- Mapped to slices: ${mappedActiveCount}`);
	lines.push(
		`- Validated: ${validatedReqs.length}${validatedIds ? ` (${validatedIds})` : ""}`,
	);
	lines.push(
		`- Unmapped active requirements: ${activeCount - mappedActiveCount}`,
	);
	return lines.join("\n") + "\n";
}
// ─── Next Decision ID ─────────────────────────────────────────────────────
/**
 * Compute the next decision ID from the current DB state.
 * Queries MAX(CAST(SUBSTR(id, 2) AS INTEGER)) from decisions table.
 * Returns D001 if no decisions exist. Zero-pads to 3 digits.
 */
export async function nextDecisionId() {
	try {
		const db = await import("./sf-db.js");
		const adapter = db._getAdapter();
		if (!adapter) return "D001";
		const row = adapter
			.prepare(
				"SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM decisions",
			)
			.get();
		const maxNum = row ? row["max_num"] : null;
		if (maxNum == null || Number.isNaN(maxNum)) return "D001";
		const next = maxNum + 1;
		return `D${String(next).padStart(3, "0")}`;
	} catch (err) {
		logError("manifest", "nextDecisionId failed", {
			fn: "nextDecisionId",
			error: String(err.message),
		});
		return "D001";
	}
}
// ─── Next Requirement ID ─────────────────────────────────────────────────
/**
 * Compute the next requirement ID from the current DB state.
 * Queries MAX(CAST(SUBSTR(id, 2) AS INTEGER)) from requirements table.
 * Returns R001 if no requirements exist. Zero-pads to 3 digits.
 */
export async function nextRequirementId() {
	try {
		const db = await import("./sf-db.js");
		const adapter = db._getAdapter();
		if (!adapter) return "R001";
		const row = adapter
			.prepare(
				"SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM requirements",
			)
			.get();
		const maxNum = row ? row["max_num"] : null;
		if (maxNum == null || Number.isNaN(maxNum)) return "R001";
		const next = maxNum + 1;
		return `R${String(next).padStart(3, "0")}`;
	} catch (err) {
		logError("manifest", "nextRequirementId failed", {
			fn: "nextRequirementId",
			error: String(err.message),
		});
		return "R001";
	}
}
/**
 * Save a new requirement to DB and regenerate REQUIREMENTS.md.
 * Auto-assigns the next ID via nextRequirementId().
 *
 * The ID computation and insert are wrapped in a single transaction
 * to prevent parallel race conditions (same pattern as saveDecisionToDb).
 *
 * Returns the assigned ID.
 */
export async function saveRequirementToDb(fields, _basePath) {
	try {
		const db = await import("./sf-db.js");
		// Atomic ID assignment + insert inside a transaction.
		const id = db.transaction(() => {
			const adapter = db._getAdapter();
			if (!adapter)
				throw new SFError(SF_STALE_STATE, "sf-db: No database open");
			const row = adapter
				.prepare(
					"SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM requirements",
				)
				.get();
			const maxNum = row ? row["max_num"] : null;
			const nextId =
				maxNum == null || Number.isNaN(maxNum)
					? "R001"
					: `R${String(maxNum + 1).padStart(3, "0")}`;
			const requirement = {
				id: nextId,
				class: fields.class,
				status: fields.status ?? "active",
				description: fields.description,
				why: fields.why,
				source: fields.source,
				primary_owner: fields.primary_owner ?? "",
				supporting_slices: fields.supporting_slices ?? "",
				validation: fields.validation ?? "",
				notes: fields.notes ?? "",
				full_content: "",
				superseded_by: null,
			};
			db.upsertRequirement(requirement);
			return nextId;
		});
		invalidateStateCache();
		clearPathCache();
		clearParseCache();
		return { id };
	} catch (err) {
		logError("manifest", "saveRequirementToDb failed", {
			fn: "saveRequirementToDb",
			error: String(err.message),
		});
		throw err;
	}
}
/**
 * Save a new decision to DB and regenerate DECISIONS.md.
 * Auto-assigns the next ID via nextDecisionId().
 *
 * The ID computation (SELECT MAX) and insert are wrapped in a single
 * transaction to prevent parallel tool calls from computing the same ID
 * and silently overwriting each other (#3326, #3339, #3459).
 *
 * Returns the assigned ID.
 */
export async function saveDecisionToDb(fields, _basePath) {
	try {
		const db = await import("./sf-db.js");
		// Atomic ID assignment + insert inside a transaction to prevent
		// parallel calls from racing on the same MAX(id) value.
		const id = db.transaction(() => {
			const adapter = db._getAdapter();
			if (!adapter)
				throw new SFError(SF_STALE_STATE, "sf-db: No database open");
			const row = adapter
				.prepare(
					"SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM decisions",
				)
				.get();
			const maxNum = row ? row["max_num"] : null;
			const nextId =
				maxNum == null || Number.isNaN(maxNum)
					? "D001"
					: `D${String(maxNum + 1).padStart(3, "0")}`;
			db.upsertDecision({
				id: nextId,
				when_context: fields.when_context ?? "",
				scope: fields.scope,
				decision: fields.decision,
				choice: fields.choice,
				rationale: fields.rationale,
				revisable: fields.revisable ?? "Yes",
				made_by: fields.made_by ?? "agent",
				superseded_by: null,
			});
			return nextId;
		});
		// #2661: When a decision defers a slice, update the slice status in the DB
		// so the dispatcher skips it. Without this, STATE.md and DECISIONS.md are
		// in split-brain: the decision says "deferred" but the state still says
		// "active", causing autonomous mode to keep dispatching the deferred work.
		try {
			const sliceRef = extractDeferredSliceRef(fields);
			if (sliceRef) {
				db.updateSliceStatus(
					sliceRef.milestoneId,
					sliceRef.sliceId,
					"deferred",
				);
			}
		} catch (deferErr) {
			// Non-fatal — log but don't fail the decision save
			logError("manifest", "failed to update deferred slice status", {
				fn: "saveDecisionToDb",
				error: String(deferErr.message),
			});
		}
		// Invalidate file-read caches so deriveState() sees the updated markdown.
		// Do NOT clear the artifacts table — we just wrote to it intentionally.
		invalidateStateCache();
		clearPathCache();
		clearParseCache();
		return { id };
	} catch (err) {
		logError("manifest", "saveDecisionToDb failed", {
			fn: "saveDecisionToDb",
			error: String(err.message),
		});
		throw err;
	}
}
/**
 * Extract a milestone/slice reference from a deferral decision.
 *
 * Detects deferrals by checking:
 *   - scope contains "defer" (e.g., "deferral", "defer")
 *   - choice or decision contains "defer" + an M###/S## pattern
 *
 * Returns { milestoneId, sliceId } if found, null otherwise.
 */
export function extractDeferredSliceRef(fields) {
	const isDeferral =
		/\bdefer(?:ral|red|ring|s)?\b/i.test(fields.scope) ||
		/\bdefer(?:ral|red|ring|s)?\b/i.test(fields.choice) ||
		/\bdefer(?:ral|red|ring|s)?\b/i.test(fields.decision);
	if (!isDeferral) return null;
	// Look for M###/S## pattern in choice first, then decision
	const slicePattern = /\b(M\d{3,4})\/(S\d{2,3})\b/;
	const choiceMatch = fields.choice.match(slicePattern);
	if (choiceMatch) {
		return { milestoneId: choiceMatch[1], sliceId: choiceMatch[2] };
	}
	const decisionMatch = fields.decision.match(slicePattern);
	if (decisionMatch) {
		return { milestoneId: decisionMatch[1], sliceId: decisionMatch[2] };
	}
	return null;
}
// ─── Update Requirement in DB + Regenerate Markdown ───────────────────────
/**
 * Update a requirement in DB and regenerate REQUIREMENTS.md.
 * Fetches existing requirement, merges updates, upserts, then regenerates.
 */
export async function updateRequirementInDb(id, updates, basePath) {
	try {
		const db = await import("./sf-db.js");
		let existing = db.getRequirementById(id);
		// If requirement doesn't exist in DB, seed the entire requirements table
		// from REQUIREMENTS.md first (#3346). This handles the standard workflow
		// where requirements are authored in markdown during discussion but never
		// imported into the database — making update_requirement always fail
		// with "not_found" at milestone completion.
		if (!existing) {
			const reqFilePath = resolveSfRootFile(basePath, "REQUIREMENTS");
			try {
				const content = readFileSync(reqFilePath, "utf-8");
				const { parseRequirementsSections } = await import("./md-importer.js");
				const parsed = parseRequirementsSections(content);
				if (parsed.length > 0) {
					logWarning(
						"manifest",
						`Seeding ${parsed.length} requirements from REQUIREMENTS.md into DB (first update triggers import)`,
						{ fn: "updateRequirementInDb" },
					);
					for (const req of parsed) {
						// Only seed if not already in DB (avoid overwriting concurrent inserts)
						if (!db.getRequirementById(req.id)) {
							db.upsertRequirement(req);
						}
					}
					// Re-check after seeding
					existing = db.getRequirementById(id);
				}
			} catch {
				// REQUIREMENTS.md missing or unparseable — fall through to skeleton
			}
		}
		const base = existing ?? {
			id,
			class: "",
			status: "active",
			description: "",
			why: "",
			source: "",
			primary_owner: "",
			supporting_slices: "",
			validation: "",
			notes: "",
			full_content: "",
			superseded_by: null,
		};
		// Merge updates into existing (or skeleton)
		const merged = {
			...base,
			...updates,
			id: base.id, // ID cannot be changed
		};
		db.upsertRequirement(merged);
		invalidateStateCache();
		clearPathCache();
		clearParseCache();
	} catch (err) {
		logError("manifest", "updateRequirementInDb failed", {
			fn: "updateRequirementInDb",
			error: String(err.message),
		});
		throw err;
	}
}
/**
 * Save an artifact to DB and write the corresponding markdown file to disk.
 * The path is relative to .sf/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md").
 * The full file path is computed as basePath + '.sf/' + path.
 */
export async function saveArtifactToDb(opts, basePath) {
	try {
		const db = await import("./sf-db.js");
		// Guard against path traversal before any reads/writes
		const sfDir = resolve(basePath, ".sf");
		const fullPath = resolve(basePath, ".sf", opts.path);
		if (!fullPath.startsWith(sfDir)) {
			throw new SFError(
				SF_IO_ERROR,
				`saveArtifactToDb: path escapes .sf/ directory: ${opts.path}`,
			);
		}
		// Shrinkage guard: if the file already exists and the new content is
		// significantly smaller (<50%), preserve the richer file on disk and
		// store its content in the DB instead of the abbreviated version.
		let dbContent = opts.content;
		let skipDiskWrite = false;
		if (existsSync(fullPath)) {
			const existingSize = statSync(fullPath).size;
			const newSize = Buffer.byteLength(opts.content, "utf-8");
			if (existingSize > 0 && newSize < existingSize * 0.5) {
				logWarning(
					"manifest",
					`new content (${newSize}B) is <50% of existing file (${existingSize}B), preserving disk file`,
					{ fn: "saveArtifactToDb", path: opts.path },
				);
				dbContent = readFileSync(fullPath, "utf-8");
				skipDiskWrite = true;
			}
		}
		db.insertArtifact({
			path: opts.path,
			artifact_type: opts.artifact_type,
			milestone_id: opts.milestone_id ?? null,
			slice_id: opts.slice_id ?? null,
			task_id: opts.task_id ?? null,
			full_content: dbContent,
		});
		// Write the file to disk (only if we're not preserving a richer existing file)
		if (!skipDiskWrite) {
			try {
				await saveFile(fullPath, opts.content);
			} catch (diskErr) {
				logError("manifest", "disk write failed, rolling back DB row", {
					fn: "saveArtifactToDb",
					error: String(diskErr.message),
				});
				db.deleteArtifactByPath(opts.path);
				throw diskErr;
			}
		}
		// Invalidate file-read caches so deriveState() sees the updated markdown.
		// Do NOT clear the artifacts table — we just wrote to it intentionally.
		invalidateStateCache();
		clearPathCache();
		clearParseCache();
	} catch (err) {
		logError("manifest", "saveArtifactToDb failed", {
			fn: "saveArtifactToDb",
			error: String(err.message),
		});
		throw err;
	}
}
