// SF Extension — SF ADR-011 Phase 2 Mid-Execution Escalation
//
// Owns: artifact I/O (read/build/write), detection, producer-side flag
// flips, user-facing resolution, carry-forward injection (claim/format),
// and memory persistence — every successful escalation flow (resolve,
// auto-accept, continueWithDefault) deposits a "[escalation:T##] ..."
// memory in the architecture category so the choice + rationale flows
// into downstream prompts via getRelevantMemoriesRanked.
//
// SF's local ADR-011 is "Swarm Chat and Debate Mode" — unrelated.
// The reject-blocker choice from SF is deferred — needs a
// blocker_source column SF doesn't yet have.
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
import { createMemory } from "./memory-store.js";
import { resolveSlicePath } from "./paths.js";
import {
	claimEscalationOverride,
	clearTaskEscalationFlags,
	findUnappliedEscalationOverride,
	getTask,
	setTaskEscalationAwaitingReview,
	setTaskEscalationPending,
} from "./sf-db.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { logWarning } from "./workflow-logger.js";
// ─── Paths ────────────────────────────────────────────────────────────────
/** Canonical escalation artifact path, parallel to T##-SUMMARY.md:
 *   .sf/milestones/{M}/slices/{S}/tasks/{T}-ESCALATION.json */
export function escalationArtifactPath(basePath, milestoneId, sliceId, taskId) {
	const sliceDir = resolveSlicePath(basePath, milestoneId, sliceId);
	if (!sliceDir) return null;
	return join(sliceDir, "tasks", `${taskId}-ESCALATION.json`);
}
// ─── Artifact builder ──────────────────────────────────────────────────────
/** Build an EscalationArtifact from a task agent's escalation payload.
 *  Server-side validation matches readEscalationArtifact's schema checks so a
 *  hand-edited artifact cannot be weaker than what the writer would emit. */
export function buildEscalationArtifact(params) {
	if (
		!Array.isArray(params.options) ||
		params.options.length < 2 ||
		params.options.length > 4
	) {
		throw new Error(
			`escalation.options must have between 2 and 4 entries (got ${params.options?.length ?? 0})`,
		);
	}
	const optionIds = new Set(params.options.map((o) => o.id));
	if (optionIds.size !== params.options.length) {
		throw new Error("escalation.options must have unique ids");
	}
	if (!optionIds.has(params.recommendation)) {
		throw new Error(
			`escalation.recommendation "${params.recommendation}" is not one of the option ids: ${[...optionIds].join(", ")}`,
		);
	}
	return {
		version: 1,
		taskId: params.taskId,
		sliceId: params.sliceId,
		milestoneId: params.milestoneId,
		question: params.question,
		options: params.options,
		recommendation: params.recommendation,
		recommendationRationale: params.recommendationRationale,
		continueWithDefault: params.continueWithDefault,
		createdAt: new Date().toISOString(),
	};
}
// ─── Artifact writer ───────────────────────────────────────────────────────
/** Atomically write an escalation artifact and flip the appropriate DB flag.
 *  When continueWithDefault=true, marks awaiting_review (no pause); otherwise
 *  marks pending (loop pauses next dispatch). Emits a UOK audit event for
 *  trace continuity. Returns the path that was written. */
export function writeEscalationArtifact(basePath, artifact) {
	const path = escalationArtifactPath(
		basePath,
		artifact.milestoneId,
		artifact.sliceId,
		artifact.taskId,
	);
	if (!path) {
		throw new Error(
			`escalation: cannot resolve tasks dir for ${artifact.milestoneId}/${artifact.sliceId} — run /doctor`,
		);
	}
	mkdirSync(dirname(path), { recursive: true });
	atomicWriteSync(path, JSON.stringify(artifact, null, 2));
	if (artifact.continueWithDefault) {
		setTaskEscalationAwaitingReview(
			artifact.milestoneId,
			artifact.sliceId,
			artifact.taskId,
			path,
		);
	} else {
		setTaskEscalationPending(
			artifact.milestoneId,
			artifact.sliceId,
			artifact.taskId,
			path,
		);
	}
	emitUokAuditEvent(
		basePath,
		buildAuditEnvelope({
			traceId: `escalation:${artifact.milestoneId}:${artifact.sliceId}:${artifact.taskId}`,
			category: "gate",
			type: "escalation-manual-attention-created",
			payload: {
				milestoneId: artifact.milestoneId,
				sliceId: artifact.sliceId,
				taskId: artifact.taskId,
				continueWithDefault: artifact.continueWithDefault,
				optionCount: artifact.options.length,
				recommendation: artifact.recommendation,
			},
		}),
	);
	// continueWithDefault=true: the agent already proceeded with its
	// recommendation. The artifact is the audit trail, but the choice +
	// rationale should ALSO land in the memory store so future tasks see
	// it via getRelevantMemoriesRanked — otherwise the rationale lives
	// only in the JSON artifact and never reaches downstream prompts.
	// resolveEscalation handles this for the continueWithDefault=false
	// path; we do the symmetric write here. Best-effort.
	if (artifact.continueWithDefault) {
		try {
			const recommendedOption = artifact.options.find(
				(o) => o.id === artifact.recommendation,
			);
			const memoryContent = formatEscalationMemoryContent(
				artifact,
				recommendedOption,
				`auto-applied default: ${artifact.recommendationRationale}`,
			);
			createMemory({
				category: "architecture",
				content: memoryContent,
				confidence: 0.85,
				source_unit_type: "execute-task",
				source_unit_id: artifact.taskId,
			});
		} catch (memoryErr) {
			logWarning(
				"tool",
				`escalation: continueWithDefault memory write failed: ${memoryErr.message}`,
			);
		}
	}
	return path;
}
/** Read an escalation artifact by path. Returns null when missing or malformed.
 *
 * Schema validation is strict (matches the eventual buildEscalationArtifact)
 * so a hand-edited artifact cannot be weaker than what the writer would emit.
 * Downstream callers can treat null as "no actionable escalation here." */
export function readEscalationArtifact(path) {
	if (!existsSync(path)) return null;
	try {
		const raw = readFileSync(path, "utf-8");
		const parsed = JSON.parse(raw);
		if (!parsed || typeof parsed !== "object") return null;
		const art = parsed;
		if (art.version !== 1) return null;
		if (typeof art.taskId !== "string" || art.taskId.length === 0) return null;
		if (typeof art.sliceId !== "string" || art.sliceId.length === 0)
			return null;
		if (typeof art.milestoneId !== "string" || art.milestoneId.length === 0) {
			return null;
		}
		if (typeof art.question !== "string" || art.question.length === 0) {
			return null;
		}
		if (
			!Array.isArray(art.options) ||
			art.options.length < 2 ||
			art.options.length > 4
		) {
			return null;
		}
		const optionIds = new Set();
		for (const opt of art.options) {
			if (!opt || typeof opt !== "object") return null;
			const o = opt;
			if (typeof o.id !== "string" || o.id.length === 0) return null;
			if (typeof o.label !== "string") return null;
			if (typeof o.tradeoffs !== "string") return null;
			if (optionIds.has(o.id)) return null;
			optionIds.add(o.id);
		}
		if (typeof art.recommendation !== "string") return null;
		if (!art.options.some((o) => o.id === art.recommendation)) return null;
		if (typeof art.continueWithDefault !== "boolean") return null;
		if (typeof art.createdAt !== "string") return null;
		return art;
	} catch {
		return null;
	}
}
/** Returns the task id of the first task with an un-resolved pause-escalation
 * (escalation_pending=1, artifact present, no respondedAt). Returns null when
 * nothing in the slice is paused — caller should treat that as "carry on."
 *
 * O(n) over the slice's tasks, with an early continue when escalation_pending
 * isn't set, so the common no-escalation path costs almost nothing. */
export function detectPendingEscalation(tasks, _basePath) {
	for (const t of tasks) {
		if (t.escalation_pending !== 1) continue;
		if (!t.escalation_artifact_path) continue;
		const art = readEscalationArtifact(t.escalation_artifact_path);
		if (art && !art.respondedAt) return t.id;
	}
	return null;
}
/** SF ADR-011 P2 carry-forward injection: when a previous task in this slice
 *  had an escalation that the user resolved, atomically claim the override
 *  (race-safe via DB UPDATE) and return the markdown block to prepend to
 *  the next executor's prompt. Returns null when no unapplied override
 *  exists OR when another caller claimed it first. Idempotent: a successful
 *  claim flips override_applied 0→1 so the same override never injects twice. */
export function claimOverrideForInjection(_basePath, milestoneId, sliceId) {
	const unapplied = findUnappliedEscalationOverride(milestoneId, sliceId);
	if (!unapplied) return null;
	// Validate the artifact BEFORE claiming. A missing/malformed file would
	// otherwise mark the row applied=1 and silently swallow the override.
	const art = readEscalationArtifact(unapplied.artifactPath);
	if (!art) {
		logWarning(
			"tool",
			`escalation: artifact missing/malformed at ${unapplied.artifactPath} (task ${unapplied.taskId}); skipping without claim — operator should resolve or remove the row`,
		);
		return null;
	}
	if (!art.respondedAt || !art.userChoice) return null;
	const claimed = claimEscalationOverride(
		milestoneId,
		sliceId,
		unapplied.taskId,
	);
	if (!claimed) return null; // race loser
	return {
		injectionBlock: formatOverrideBlock(art),
		sourceTaskId: unapplied.taskId,
	};
}
/** Build the markdown block prepended to a downstream executor's prompt to
 *  carry forward a user's escalation resolution as a hard constraint. */
function formatOverrideBlock(art) {
	const isAccept = art.userChoice === "accept";
	const isOptionChoice = !!art.userChoice && !isAccept;
	const choiceLabel = isAccept
		? `accepted recommendation (${art.recommendation})`
		: isOptionChoice
			? `${art.options.find((o) => o.id === art.userChoice)?.label ?? art.userChoice} (id: ${art.userChoice})`
			: (art.userChoice ?? "unknown");
	const tradeoffs = isOptionChoice
		? (art.options.find((o) => o.id === art.userChoice)?.tradeoffs ?? "")
		: "";
	const rationale = art.userRationale
		? `\n\n**User rationale:** ${art.userRationale}`
		: "";
	return [
		`## Escalation Override (from ${art.taskId})`,
		"",
		`During ${art.taskId} the executor escalated: **${art.question}**`,
		"",
		`The user's resolution: **${choiceLabel}**.${rationale}`,
		tradeoffs ? `\n**Tradeoffs of this choice:** ${tradeoffs}` : "",
		"",
		"Apply this decision as a hard constraint for the current task. If it contradicts the task plan, surface the conflict in your summary rather than silently deviating.",
	]
		.filter((line) => line !== undefined)
		.join("\n");
}
/** Apply a user response to a pending escalation:
 *   1) Update the artifact with respondedAt + userChoice + userRationale.
 *   2) Clear the DB escalation flags (artifact_path is preserved as audit trail).
 *   3) Emit a UOK audit event.
 *
 *  `choice` accepts either "accept" (selects the recommended option) or a
 *  concrete option id from the artifact's options array. Invalid choices are
 *  rejected with a list of valid ones.
 *
 *  Note: this does NOT set up carry-forward injection — the next dispatch
 *  cycle picks up phase != 'escalating-task' (because flags are cleared) and
 *  routes to execute-task normally. Override-as-context-injection is a future
 *  fire (claimOverrideForInjection currently returns null). */
export function resolveEscalation(
	basePath,
	milestoneId,
	sliceId,
	taskId,
	choice,
	rationale,
	source = "user",
) {
	const task = getTask(milestoneId, sliceId, taskId);
	if (!task || !task.escalation_artifact_path) {
		return {
			status: "not-found",
			message: `No escalation artifact found for ${milestoneId}/${sliceId}/${taskId}.`,
		};
	}
	const art = readEscalationArtifact(task.escalation_artifact_path);
	if (!art) {
		return {
			status: "not-found",
			message: `Escalation artifact at ${task.escalation_artifact_path} is missing or malformed.`,
		};
	}
	if (art.respondedAt) {
		const wasAuto = art.userRationale?.startsWith("autonomous mode:");
		const detail = wasAuto
			? ` (auto-accepted in autonomous mode → choice="${art.userChoice}"; the carry-forward was already injected into the downstream task, so this can't be retroactively changed via /escalate resolve. Capture the corrective decision as \`/memory note "..."\` so future tasks pick it up.)`
			: ` (resolved by user → choice="${art.userChoice}").`;
		return {
			status: "already-resolved",
			message: `Escalation for ${taskId} was already resolved at ${art.respondedAt}${detail}`,
		};
	}
	let chosenOption;
	if (choice === "accept") {
		chosenOption = art.options.find((o) => o.id === art.recommendation);
	} else {
		chosenOption = art.options.find((o) => o.id === choice);
		if (!chosenOption) {
			const valid = ["accept", ...art.options.map((o) => o.id)].join(", ");
			return {
				status: "invalid-choice",
				message: `Unknown choice "${choice}". Valid choices: ${valid}.`,
			};
		}
	}
	const respondedAt = new Date().toISOString();
	const updated = {
		...art,
		respondedAt,
		userChoice: choice,
		userRationale: rationale,
	};
	atomicWriteSync(
		task.escalation_artifact_path,
		JSON.stringify(updated, null, 2),
	);
	clearTaskEscalationFlags(milestoneId, sliceId, taskId);
	emitUokAuditEvent(
		basePath,
		buildAuditEnvelope({
			traceId: `escalation:${milestoneId}:${sliceId}:${taskId}`,
			category: "gate",
			type:
				source === "autonomous mode"
					? "escalation-auto-accepted"
					: "escalation-user-responded",
			payload: {
				milestoneId,
				sliceId,
				taskId,
				chosenOptionId: chosenOption?.id,
				rationale,
				resolvedBy: source,
			},
		}),
	);
	// Persist as a durable memory so the choice + rationale auto-injects into
	// future prompts via getActiveMemoriesRanked. Mirrors the decisions->memories
	// backfill pattern (category="architecture", "[decision:<id>] ..." prefix).
	// Best-effort — never block resolution if the memory write fails.
	try {
		const memoryContent = formatEscalationMemoryContent(
			art,
			chosenOption,
			rationale,
		);
		createMemory({
			category: "architecture",
			content: memoryContent,
			confidence: 0.85,
			source_unit_type: "execute-task",
			source_unit_id: taskId,
		});
	} catch (memoryErr) {
		logWarning(
			"tool",
			`escalation: failed to persist resolution as memory: ${memoryErr.message}`,
		);
	}
	return {
		status: "resolved",
		message: `Escalation resolved. Next ${sliceId} dispatch will run normally.`,
		artifactPath: task.escalation_artifact_path,
		chosenOption,
	};
}
/** Synthesize a 1–3 sentence memory line from a resolved escalation artifact.
 *  The "[escalation:<task>]" prefix mirrors the decisions->memories backfill
 *  format so de-duplication and search work the same way. */
function formatEscalationMemoryContent(art, chosenOption, userRationale) {
	const choiceLabel = chosenOption
		? `${chosenOption.label} (${chosenOption.id})`
		: "unknown";
	const rationale = userRationale.trim()
		? userRationale.trim()
		: art.recommendationRationale;
	const tradeoffs = chosenOption?.tradeoffs?.trim();
	return [
		`[escalation:${art.taskId}] ${art.question}`,
		`Chose: ${choiceLabel}.`,
		`Rationale: ${rationale}`,
		tradeoffs ? `Tradeoffs: ${tradeoffs}` : "",
	]
		.filter(Boolean)
		.join(" ");
}
