/**
 * complete-task handler — the core operation behind complete_task.
 *
 * Validates inputs, atomically renders SUMMARY.md to disk, then writes the
 * task row to DB in a transaction, toggles the plan checkbox, and invalidates
 * caches.
 */
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteAsync } from "../atomic-write.js";
import { clearParseCache } from "../files.js";
import { getGatesForTurn } from "../gate-registry.js";
import { renderPlanCheckboxes } from "../markdown-renderer.js";
import { clearPathCache, resolveSliceFile, resolveTasksDir } from "../paths.js";
import { checkSafeIds } from "../safety/safe-id.js";
import {
	getMilestone,
	getPendingGatesForTurn,
	getSlice,
	getTask,
	insertMilestone,
	insertSlice,
	insertTaskEvidence,
	insertVerificationEvidence,
	saveGateResult,
	completeTaskAtomic,
	transaction,
} from "../sf-db.js";
import { invalidateStateCache } from "../state.js";
import { isClosedStatus } from "../status-guards.js";
import { checkOwnership, taskUnitKey } from "../unit-ownership.js";
import {
	normalizePlanningText,
	normalizeRequiredPlanningText,
	validateSafePathSegment,
} from "../validation.js";
import { appendEvent } from "../workflow-events.js";
import { logError, logWarning } from "../workflow-logger.js";
import { writeManifest } from "../workflow-manifest.js";
import {
	renderAllProjections,
	renderSummaryContent,
} from "../workflow-projections.js";
import { ensureWritableParent, errorMessage } from "./tool-helpers.js";

/**
 * Map an execute-task-owned gate id to the CompleteTaskParams field whose
 * presence drives `pass` vs. `omitted`. Keep in lockstep with the gates
 * declared in gate-registry.ts under ownerTurn "execute-task".
 */
function taskGateFieldForId(id, params) {
	switch (id) {
		case "Q5":
			return params.failureModes;
		case "Q6":
			return params.loadProfile;
		case "Q7":
			return params.negativeTests;
		default:
			return undefined;
	}
}
/**
 * Normalize a list parameter that may arrive as a string (newline-delimited
 * bullet list from the LLM) into a string array (#3361).
 */
function normalizeListParam(value, _field = "list") {
	if (Array.isArray(value)) return value.map(String);
	if (typeof value === "string" && value.trim()) {
		return value
			.split(/\n/)
			.map((s) => s.replace(/^[\s\-*•]+/, "").trim())
			.filter(Boolean);
	}
	return [];
}
function normalizeStringListParam(value, field) {
	return normalizeListParam(value, field).map((item, index) =>
		normalizePlanningText(item, `${field}[${index}]`),
	);
}
function normalizeVerificationEvidence(value) {
	if (!Array.isArray(value)) return [];
	return value.map((entry, index) => {
		if (typeof entry === "string") {
			return {
				command: normalizePlanningText(entry, `verificationEvidence[${index}]`),
				exitCode: -1,
				verdict: "unknown (coerced from string)",
				durationMs: 0,
			};
		}
		if (!entry || typeof entry !== "object") {
			throw new Error(
				`verificationEvidence[${index}] must be an object or string`,
			);
		}
		const record = entry;
		const exitCode =
			typeof record["exitCode"] === "number" &&
			Number.isFinite(record["exitCode"])
				? record["exitCode"]
				: -1;
		const durationMs =
			typeof record["durationMs"] === "number" &&
			Number.isFinite(record["durationMs"])
				? record["durationMs"]
				: 0;
		return {
			command: normalizePlanningText(
				String(record["command"] ?? ""),
				`verificationEvidence[${index}].command`,
			),
			exitCode,
			verdict: normalizePlanningText(
				String(record["verdict"] ?? "unknown"),
				`verificationEvidence[${index}].verdict`,
			),
			durationMs,
		};
	});
}
function normalizeCompleteTaskParams(params) {
	return {
		...params,
		taskId: validateSafePathSegment(params.taskId, "taskId"),
		sliceId: validateSafePathSegment(params.sliceId, "sliceId"),
		milestoneId: validateSafePathSegment(params.milestoneId, "milestoneId"),
		oneLiner: normalizeRequiredPlanningText(params.oneLiner, "oneLiner"),
		narrative: normalizeRequiredPlanningText(params.narrative, "narrative"),
		verification: normalizeRequiredPlanningText(
			params.verification,
			"verification",
		),
		// ADR-0000 (SF v70): goal-anchored purpose sentence. Required so
		// complete-slice can mechanically verify every task served the slice
		// goal before flipping status. Normalize aggressively but keep raw
		// validation to the handler so we can surface the slice.goal in the
		// error message.
		purposeTrace:
			typeof params.purposeTrace === "string"
				? params.purposeTrace.trim()
				: typeof params.purpose_trace === "string"
					? params.purpose_trace.trim()
					: "",
		keyFiles: normalizeStringListParam(params.keyFiles, "keyFiles"),
		keyDecisions: normalizeStringListParam(params.keyDecisions, "keyDecisions"),
		deviations:
			params.deviations === undefined
				? undefined
				: normalizePlanningText(params.deviations, "deviations"),
		knownIssues:
			params.knownIssues === undefined
				? undefined
				: normalizePlanningText(params.knownIssues, "knownIssues"),
		failureModes:
			params.failureModes === undefined
				? undefined
				: normalizePlanningText(params.failureModes, "failureModes"),
		loadProfile:
			params.loadProfile === undefined
				? undefined
				: normalizePlanningText(params.loadProfile, "loadProfile"),
		negativeTests:
			params.negativeTests === undefined
				? undefined
				: normalizePlanningText(params.negativeTests, "negativeTests"),
		verificationEvidence: normalizeVerificationEvidence(
			params.verificationEvidence,
		),
	};
}
async function writeSummaryBeforeDb(filePath, content) {
	try {
		await ensureWritableParent(filePath);
		await atomicWriteAsync(filePath, content);
	} catch (error) {
		throw new Error(
			`SUMMARY.md write failed at ${filePath}: ${errorMessage(error)}`,
		);
	}
}
/**
 * Build a TaskRow-shaped object from CompleteTaskParams so the unified
 * renderSummaryContent() can be used at completion time (#2720).
 */
function paramsToTaskRow(params, completedAt) {
	return {
		milestone_id: params.milestoneId,
		slice_id: params.sliceId,
		id: params.taskId,
		title: params.oneLiner || params.taskId,
		status: "complete",
		one_liner: params.oneLiner,
		narrative: params.narrative,
		verification_result: params.verification,
		duration: "",
		completed_at: completedAt,
		blocker_discovered: params.blockerDiscovered ?? false,
		deviations: params.deviations ?? "",
		known_issues: params.knownIssues ?? "",
		key_files: normalizeListParam(params.keyFiles),
		key_decisions: normalizeListParam(params.keyDecisions),
		full_summary_md: "",
		description: "",
		estimate: "",
		files: [],
		verify: "",
		inputs: [],
		expected_output: [],
		observability_impact: "",
		full_plan_md: "",
		sequence: 0,
		verification_status: "",
	};
}
/**
 * Handle the complete_task operation end-to-end.
 *
 * 1. Validate required fields
 * 2. Validate and atomically render SUMMARY.md to disk
 * 3. Write DB in a transaction (milestone, slice, task, verification evidence)
 * 4. Toggle plan checkbox
 * 6. Invalidate caches
 */
export async function handleCompleteTask(paramsInput, basePath) {
	// Required-field validation should report the actionable missing field before
	// the path-safety layer classifies an empty string as an unsafe segment.
	if (
		!paramsInput.taskId ||
		typeof paramsInput.taskId !== "string" ||
		paramsInput.taskId.trim() === ""
	) {
		return { error: "taskId is required and must be a non-empty string" };
	}
	if (
		!paramsInput.sliceId ||
		typeof paramsInput.sliceId !== "string" ||
		paramsInput.sliceId.trim() === ""
	) {
		return { error: "sliceId is required and must be a non-empty string" };
	}
	if (
		!paramsInput.milestoneId ||
		typeof paramsInput.milestoneId !== "string" ||
		paramsInput.milestoneId.trim() === ""
	) {
		return { error: "milestoneId is required and must be a non-empty string" };
	}
	// ── Path-traversal safety check (runs on raw input before normalization) ─
	// Reject any ID that could escape .sf/ when interpolated into a path.
	// Must run before normalizeCompleteTaskParams, which also validates IDs
	// but returns a generic error string rather than the structured unsafe_id
	// shape expected downstream.
	const idCheck = checkSafeIds({
		milestoneId: paramsInput.milestoneId,
		sliceId: paramsInput.sliceId,
		taskId: paramsInput.taskId,
	});
	if (idCheck) {
		return {
			error: "unsafe_id",
			field: idCheck.fieldName,
			reason: idCheck.reason,
		};
	}
	let params;
	try {
		params = normalizeCompleteTaskParams(paramsInput);
	} catch (error) {
		return { error: errorMessage(error) };
	}
	// ── Validate required fields ────────────────────────────────────────────
	if (
		!params.taskId ||
		typeof params.taskId !== "string" ||
		params.taskId.trim() === ""
	) {
		return { error: "taskId is required and must be a non-empty string" };
	}
	if (
		!params.sliceId ||
		typeof params.sliceId !== "string" ||
		params.sliceId.trim() === ""
	) {
		return { error: "sliceId is required and must be a non-empty string" };
	}
	if (
		!params.milestoneId ||
		typeof params.milestoneId !== "string" ||
		params.milestoneId.trim() === ""
	) {
		return { error: "milestoneId is required and must be a non-empty string" };
	}
	// ── Ownership check (opt-in: only enforced when claim file exists) ──────
	const ownershipErr = checkOwnership(
		basePath,
		taskUnitKey(params.milestoneId, params.sliceId, params.taskId),
		params.actorName,
	);
	if (ownershipErr) {
		return { error: ownershipErr };
	}
	// ── Guards before filesystem work ──────────────────────────────────────
	const completedAt = new Date().toISOString();
	const milestone = getMilestone(params.milestoneId);
	if (milestone && isClosedStatus(milestone.status)) {
		return {
			error: `cannot complete task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`,
		};
	}
	const slice = getSlice(params.milestoneId, params.sliceId);
	if (slice && isClosedStatus(slice.status)) {
		return {
			error: `cannot complete task in a closed slice: ${params.sliceId} (status: ${slice.status})`,
		};
	}
	// ADR-0000 (SF v70) — purpose trace gate ───────────────────────────────
	// execute-task must record the goal-anchored sentence the change served
	// before complete_task is accepted. complete-slice consults this column
	// to mechanically verify every task actually served the slice goal.
	// Refuse with the slice.goal quoted verbatim so the agent has the
	// sentence to anchor against.
	if (
		typeof params.purposeTrace !== "string" ||
		params.purposeTrace.trim().length === 0
	) {
		const sliceGoal =
			(slice && typeof slice.goal === "string" && slice.goal.trim()) || "";
		const goalClause = sliceGoal
			? `slice.goal = "${sliceGoal}"`
			: `slice.goal is empty for ${params.sliceId} — record what the slice was supposed to do`;
		return {
			error:
				`purpose_trace is required (ADR-0000): name the sentence or clause of the slice goal this change served. ` +
				`${goalClause}. ` +
				`Quote or paraphrase that clause in 1-3 sentences and retry complete_task with purpose_trace set.`,
		};
	}
	const existingTask = getTask(
		params.milestoneId,
		params.sliceId,
		params.taskId,
	);
	if (existingTask && isClosedStatus(existingTask.status)) {
		return {
			error: `task ${params.taskId} is already complete — use sf_task_reopen first if you need to redo it`,
		};
	}
	// Render summary markdown via the single source of truth (#2720)
	const taskRow = paramsToTaskRow(params, completedAt);
	const summaryMd = renderSummaryContent(
		taskRow,
		params.sliceId,
		params.milestoneId,
		params.verificationEvidence ?? [],
	);
	// Resolve and write summary to disk
	let summaryPath;
	const tasksDir = resolveTasksDir(
		basePath,
		params.milestoneId,
		params.sliceId,
	);
	if (tasksDir) {
		summaryPath = join(tasksDir, `${params.taskId}-SUMMARY.md`);
	} else {
		// Tasks dir doesn't exist on disk yet — build path manually and ensure dirs
		const sfDir = join(basePath, ".sf");
		const manualTasksDir = join(
			sfDir,
			"milestones",
			params.milestoneId,
			"slices",
			params.sliceId,
			"tasks",
		);
		mkdirSync(manualTasksDir, { recursive: true });
		summaryPath = join(manualTasksDir, `${params.taskId}-SUMMARY.md`);
	}
	// ── Filesystem commit before DB status mutation ────────────────────────
	// SUMMARY.md is the artifact downstream agents read. If this write fails,
	// do not mutate the DB into a completed state.
	try {
		await writeSummaryBeforeDb(summaryPath, summaryMd);
	} catch (writeErr) {
		logWarning(
			"tool",
			`complete_task — SUMMARY.md write failed before DB update: ${errorMessage(writeErr)}`,
		);
		invalidateStateCache();
		return { error: errorMessage(writeErr) };
	}
	// ── DB commit only after SUMMARY.md exists on disk ─────────────────────
	try {
		transaction(() => {
			insertMilestone({ id: params.milestoneId, title: params.milestoneId });
			insertSlice({
				id: params.sliceId,
				milestoneId: params.milestoneId,
				title: params.sliceId,
			});
			const evidence = params.verificationEvidence ?? [];
			const verificationStatus =
				evidence.length === 0
					? ""
					: evidence.every((c) => c.exitCode === 0)
						? "all_pass"
						: evidence.some((c) => c.exitCode === 0)
							? "partial"
							: "all_fail";
			completeTaskAtomic(params.milestoneId, params.sliceId, params.taskId, {
				title: params.oneLiner,
				status: "complete",
				oneLiner: params.oneLiner,
				narrative: params.narrative,
				verificationResult: params.verification,
				duration: "",
				blockerDiscovered: params.blockerDiscovered ?? false,
				deviations: params.deviations ?? "None.",
				knownIssues: params.knownIssues ?? "None.",
				keyFiles: params.keyFiles ?? [],
				keyDecisions: params.keyDecisions ?? [],
				fullSummaryMd: summaryMd,
				summaryMd,
				verificationStatus,
				// ADR-0000 (SF v70): persist the goal-anchored sentence.
				purposeTrace: params.purposeTrace,
			});
			for (const evidence of params.verificationEvidence ?? []) {
				insertVerificationEvidence({
					taskId: params.taskId,
					sliceId: params.sliceId,
					milestoneId: params.milestoneId,
					command: evidence.command,
					exitCode: evidence.exitCode,
					verdict: evidence.verdict,
					durationMs: evidence.durationMs,
				});
			}
			// Record evidence: task completion
			const taskEvidenceContent = JSON.stringify({
				oneLiner: params.oneLiner ?? "",
				narrative: params.narrative ?? "",
				verification: params.verification ?? "",
				verificationEvidence: params.verificationEvidence ?? [],
				blockerDiscovered: params.blockerDiscovered ?? false,
				deviations: params.deviations ?? "None.",
				knownIssues: params.knownIssues ?? "None.",
				keyFiles: params.keyFiles ?? [],
				keyDecisions: params.keyDecisions ?? [],
			});
			insertTaskEvidence(
				params.milestoneId,
				params.sliceId,
				params.taskId,
				"completion_summary",
				taskEvidenceContent,
				"complete-task",
				"agent",
			);
		});
	} catch (dbErr) {
		const msg = errorMessage(dbErr);
		logError(
			"tool",
			`complete_task — DB update failed after SUMMARY.md write succeeded; keeping ${summaryPath}`,
			{ error: msg },
		);
		invalidateStateCache();
		return {
			error: `database update failed after SUMMARY.md write succeeded at ${summaryPath}: ${msg}. SUMMARY.md was kept; retry complete_task after fixing the DB.`,
		};
	}
	// Toggle plan checkbox via renderer module after DB status is updated.
	try {
		const planPath = resolveSliceFile(
			basePath,
			params.milestoneId,
			params.sliceId,
			"PLAN",
		);
		if (planPath) {
			await renderPlanCheckboxes(basePath, params.milestoneId, params.sliceId);
		} else {
			process.stderr.write(
				`sf-db: complete_task — could not find plan file for ${params.sliceId}/${params.milestoneId}, skipping checkbox toggle\n`,
			);
		}
	} catch (renderErr) {
		logWarning(
			"tool",
			`complete_task — plan checkbox render failed after DB update: ${errorMessage(renderErr)}`,
		);
	}
	// ── Close gates owned by execute-task (Q5/Q6/Q7) for this task ────────
	// Each gate id maps to a specific params field via taskGateFieldForId.
	// When the model populates the field, record `pass`; when it's empty,
	// record `omitted`. Task-scoped rows are filtered by taskId so a single
	// task's completion doesn't touch sibling tasks' gate rows.
	try {
		const pendingGates = getPendingGatesForTurn(
			params.milestoneId,
			params.sliceId,
			"execute-task",
			params.taskId,
		);
		if (pendingGates.length > 0) {
			const ownedDefs = new Map(
				getGatesForTurn("execute-task").map((g) => [g.id, g]),
			);
			for (const row of pendingGates) {
				const def = ownedDefs.get(row.gate_id);
				if (!def) continue;
				const field = taskGateFieldForId(def.id, params);
				const hasContent = typeof field === "string" && field.trim().length > 0;
				let verdict = hasContent ? "pass" : "omitted";
				let rationale = hasContent
					? `${def.promptSection} section populated in task summary`
					: `${def.promptSection} section left empty — recorded as omitted`;
				if (verdict === "omitted" && def.minOmissionWords > 0) {
					const wordCount = rationale
						.trim()
						.split(/\s+/)
						.filter(Boolean).length;
					if (wordCount < def.minOmissionWords) {
						verdict = "flag";
						rationale = `[⚠ Rationale too short — ${wordCount} words, ${def.minOmissionWords} required for omission] ${rationale}`;
					}
				}
				saveGateResult({
					milestoneId: params.milestoneId,
					sliceId: params.sliceId,
					taskId: params.taskId,
					gateId: def.id,
					verdict,
					rationale,
					findings: hasContent ? field.trim() : "",
				});
			}
		}
	} catch (gateErr) {
		logWarning(
			"tool",
			`complete-task gate close warning for ${params.milestoneId}/${params.sliceId}/${params.taskId}: ${gateErr.message}`,
		);
	}
	// Invalidate all caches
	invalidateStateCache();
	clearPathCache();
	clearParseCache();
	// ── Post-mutation hook: projections, manifest, event log ───────────────
	// Separate try/catch per step so a projection failure doesn't prevent
	// the event log entry (critical for worktree reconciliation).
	try {
		await renderAllProjections(basePath, params.milestoneId);
	} catch (projErr) {
		logWarning("tool", `complete-task projection warning: ${projErr.message}`);
	}
	try {
		writeManifest(basePath);
	} catch (mfErr) {
		logWarning("tool", `complete-task manifest warning: ${mfErr.message}`);
	}
	try {
		appendEvent(basePath, {
			cmd: "complete-task",
			params: {
				milestoneId: params.milestoneId,
				sliceId: params.sliceId,
				taskId: params.taskId,
			},
			ts: new Date().toISOString(),
			actor: "agent",
			actor_name: params.actorName,
			trigger_reason: params.triggerReason,
		});
	} catch (eventErr) {
		logError(
			"tool",
			`complete-task event log FAILED — completion invisible to reconciliation`,
			{ error: eventErr.message },
		);
	}
	return {
		taskId: params.taskId,
		sliceId: params.sliceId,
		milestoneId: params.milestoneId,
		summaryPath,
	};
}
