/**
 * Plannotator Pi Extension — File-based plan mode with visual browser review.
 *
 * During planning the agent writes any markdown file anywhere inside cwd and
 * calls plannotator_submit_plan with the path. The user reviews in the
 * browser UI and can approve, deny with annotations, or request changes.
 *
 * Features:
 * - /plannotator command or Ctrl+Alt+P to toggle
 * - --plan flag to start in planning mode
 * - Structured planning tools preferred over bash/curl-style exploration
 * - Writes restricted to markdown files inside cwd or configured external planRoot during planning
 * - plannotator_submit_plan tool with browser-based visual approval
 * - [DONE:n] markers for execution progress tracking
 * - /plannotator-review command for code review
 * - /plannotator-annotate command for markdown annotation
 */

import { existsSync, readFileSync, statSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { Type } from "@earendil-works/pi-ai";
import type {
	ExtensionAPI,
	ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Key } from "@earendil-works/pi-tui";
import { buildPromptVariables, formatTodoList, loadPlannotatorConfig, renderTemplate, resolveJjDefaultDiffType as resolvePiJjDefaultDiffType, resolvePhaseProfile, resolvePlanRoot, type PlannotatorConfig } from "./config.js";
import {
	type ChecklistItem,
	markCompletedSteps,
	parseChecklist,
} from "./generated/checklist.js";
import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js";
import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
import { urlToMarkdown, isConvertedSource } from "./generated/url-to-markdown.js";
import { loadConfig, resolveUseJina } from "./generated/config.js";
import { readImprovementHook } from "./generated/improvement-hooks.js";
import { composeImproveContext } from "./generated/pfm-reminder.js";
import {
	getReviewApprovedPrompt,
	getReviewDeniedSuffix,
	getPlanDeniedPrompt,
	getPlanApprovedPrompt,
	getPlanApprovedWithNotesPrompt,
	getPlanAutoApprovedPrompt,
	getPlanToolName,
	buildPlanFileRule,
	getAnnotateFileFeedbackPrompt,
	getAnnotateMessageFeedbackPrompt,
} from "./generated/prompts.js";
import { parseAnnotateArgs } from "./generated/annotate-args.js";
import { parseReviewArgs } from "./generated/review-args.js";
import { resolveAtReference } from "./generated/at-reference.js";
import {
	hasPlanBrowserHtml,
	hasReviewBrowserHtml,
	getStartupErrorMessage,
	startCodeReviewBrowserSession,
	startLastMessageAnnotationSession,
	startMarkdownAnnotationSession,
	openPlanReviewBrowser,
	registerPlannotatorEventListeners,
} from "./plannotator-events.js";
import {
	findAssistantMessageByEntryId,
	getAssistantMessageText,
	getLastAssistantMessageSnapshot,
	getRecentAssistantMessages,
	hasSessionMovedPastEntry,
} from "./assistant-message.js";
import {
	getPiSessionIdentity,
	isCurrentPiSessionDifferentFrom,
	notifyCurrentPiSession,
	type PiSessionIdentity,
	registerCurrentPiSession,
	sendUserMessageToCurrentPiSession,
	withCurrentPiSessionFallbackHeader,
} from "./current-pi-session.js";
import {
	getToolsForPhase,
	isPlanWritePathAllowed,
	PLAN_SUBMIT_TOOL,
	resolvePlanWritePath,
	validatePlanningJjTodoInput,
	type Phase,
	stripPlanningOnlyTools,
} from "./tool-scope.ts";

// ── Types ──────────────────────────────────────────────────────────────


type SavedPhaseState = {
	activeTools: string[];
	model?: { provider: string; id: string };
	thinkingLevel: ThinkingLevel;
};

type PersistedPlannotatorState = {
	phase: Phase;
	lastSubmittedPath?: string;
	savedState?: SavedPhaseState | null;
};

type ApprovalSessionMode = "current" | "fresh";

type PendingApprovedPlanContinuation = {
	mode: ApprovalSessionMode;
	planFilePath: string;
	kickoffMessage: string;
	queued?: boolean;
};

function getPlanReviewAvailabilityWarning(options: { hasUI: boolean; hasPlanHtml: boolean }): string | null {
	const { hasUI, hasPlanHtml } = options;
	if (hasUI && hasPlanHtml) return null;
	if (!hasUI && !hasPlanHtml) {
		return "Plannotator: interactive plan review is unavailable in this session (no UI support and missing built assets). Plans will auto-approve on exit_plan_mode.";
	}
	if (!hasUI) {
		return "Plannotator: interactive plan review is unavailable in this session (no UI support). Plans will auto-approve on exit_plan_mode.";
	}
	return "Plannotator: interactive plan review assets are missing. Rebuild the extension to restore the browser UI. Plans will auto-approve on exit_plan_mode.";
}

function safeNotify(
	ctx: ExtensionContext,
	message: string,
	type: "info" | "warning" | "error" = "info",
	origin?: PiSessionIdentity,
): void {
	try {
		ctx.ui.notify(message, type);
	} catch (err) {
		if (notifyCurrentPiSession(message, type, origin)) return;
		console.error(`Plannotator notification failed: ${err instanceof Error ? err.message : String(err)}`);
	}
}

function reportBackgroundError(ctx: ExtensionContext, message: string, err: unknown, origin?: PiSessionIdentity): void {
	const detail = getStartupErrorMessage(err);
	console.error(`${message}: ${detail}`);
	safeNotify(ctx, `${message}: ${detail}`, "error", origin);
}

function excerptText(text: string, maxChars = 1000): string {
	const trimmed = text.trim();
	if (trimmed.length <= maxChars) return trimmed;
	return `${trimmed.slice(0, maxChars).trimEnd()}...`;
}

function blockquote(text: string): string {
	return text
		.split("\n")
		.map((line) => `> ${line}`)
		.join("\n");
}

function anchorMessageFeedback(feedback: string, originalMessage: string): string {
	return `This feedback applies to the earlier assistant response excerpted below:

${blockquote(excerptText(originalMessage))}

User feedback:
${feedback}`;
}

function shouldAnchorLastMessageFeedback(ctx: ExtensionContext, entryId: string, origin: PiSessionIdentity): boolean {
	if (isCurrentPiSessionDifferentFrom(origin)) return true;
	try {
		return hasSessionMovedPastEntry(ctx, entryId);
	} catch {
		return true;
	}
}

function reportCurrentSessionSendFailure(errorMessage: string, err: unknown, origin: PiSessionIdentity): void {
	const detail = getStartupErrorMessage(err);
	console.error(`${errorMessage}: ${detail}`);
	notifyCurrentPiSession(`${errorMessage}: ${detail}`, "error", origin);
}

function trySendUserMessageToDifferentCurrentSession(
	content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
	options: Parameters<ExtensionAPI["sendUserMessage"]>[1],
	errorMessage: string,
	origin: PiSessionIdentity,
): boolean {
	const result = sendUserMessageToCurrentPiSession(
		withCurrentPiSessionFallbackHeader(content),
		options,
		origin,
	);
	if (result.ok) return true;
	if (result.reason === "send-failed") {
		reportCurrentSessionSendFailure(errorMessage, result.error, origin);
		return true;
	}
	return false;
}

function sendUserMessageWithCurrentSessionFallback(
	pi: ExtensionAPI,
	content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
	options: Parameters<ExtensionAPI["sendUserMessage"]>[1],
	errorMessage: string,
	origin: PiSessionIdentity,
): void {
	if (trySendUserMessageToDifferentCurrentSession(content, options, errorMessage, origin)) return;

	try {
		pi.sendUserMessage(content, options);
		return;
	} catch (err) {
		if (trySendUserMessageToDifferentCurrentSession(content, options, errorMessage, origin)) return;
		throw err;
	}
}

export default function plannotator(pi: ExtensionAPI): void {
	const currentPiSession = registerCurrentPiSession(pi);
	let phase: Phase = "idle";
	void registerPlannotatorEventListeners(pi, {
		handlePlanMode: async (mode, ctx) => {
			if (mode === "status") return { phase };
			if (mode === "enter") {
				if (phase === "idle") await enterPlanning(ctx);
				return { phase };
			}
			if (mode === "exit") {
				if (phase !== "idle") await exitToIdle(ctx);
				return { phase };
			}
			await togglePlanMode(ctx);
			return { phase };
		},
	});
	let lastSubmittedPath: string | null = null;
	let checklistItems: ChecklistItem[] = [];
	let savedState: SavedPhaseState | null = null;
	let plannotatorConfig: PlannotatorConfig = {};
	let pendingApprovedPlanContinuation: PendingApprovedPlanContinuation | null = null;

	pi.on("session_start", (_event, ctx) => {
		currentPiSession.update(ctx);
	});

	pi.on("session_shutdown", () => {
		currentPiSession.clear();
	});

	// ── Flags ────────────────────────────────────────────────────────────

	pi.registerFlag("plan", {
		description: "Start in plan mode (restricted exploration and planning)",
		type: "boolean",
		default: false,
	});

	// ── Helpers ──────────────────────────────────────────────────────────

	function getPhaseProfile(): ReturnType<typeof resolvePhaseProfile> | undefined {
		if (phase === "planning" || phase === "executing") {
			return resolvePhaseProfile(plannotatorConfig, phase);
		}
		return undefined;
	}

	function getResolvedPlanRoot(ctx: ExtensionContext): string | undefined {
		return resolvePlanRoot(plannotatorConfig, ctx.cwd);
	}

	function getPlanPathOptions(ctx: ExtensionContext): { planRoot?: string } {
		const planRoot = getResolvedPlanRoot(ctx);
		return planRoot ? { planRoot } : {};
	}

	function formatPlanScope(ctx: ExtensionContext): string {
		const planRoot = getResolvedPlanRoot(ctx);
		return planRoot
			? `inside the working directory or under the configured external plan root (${planRoot})`
			: "inside the working directory";
	}

	function sanitizePlanPathComponent(value: string): string {
		const slug = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
		return slug || "project";
	}

	function buildPlanFileGuidance(ctx: ExtensionContext): string {
		const planRoot = getResolvedPlanRoot(ctx);
		if (!planRoot) {
			return "Choose a descriptive markdown plan file inside the working directory, such as PLAN.md for a single focused plan or plans/<short-name>.md when the project keeps multiple plans.";
		}
		const projectSlug = sanitizePlanPathComponent(basename(ctx.cwd));
		const example = join(planRoot, projectSlug, "<session-or-task-slug>.md");
		return `Use a session-unique markdown plan file under the configured external plan root, preferably ${example}. Do not reuse a fixed PLAN.md when multiple plans may coexist.`;
	}

	function updateStatus(ctx: ExtensionContext): void {
		const profile = getPhaseProfile();
		if (phase === "executing" && checklistItems.length > 0) {
			const completed = checklistItems.filter((t) => t.completed).length;
			ctx.ui.setStatus(
				"plannotator",
				ctx.ui.theme.fg("accent", `📋 ${completed}/${checklistItems.length}`),
			);
		} else if (phase === "planning" && profile?.statusLabel) {
			ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("warning", profile.statusLabel));
		} else if (phase === "executing" && profile?.statusLabel) {
			ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("accent", profile.statusLabel));
		} else {
			ctx.ui.setStatus("plannotator", undefined);
		}
	}

	function updateWidget(ctx: ExtensionContext): void {
		if (phase === "executing" && checklistItems.length > 0) {
			const lines = checklistItems.map((item) => {
				if (item.completed) {
					return (
						ctx.ui.theme.fg("success", "☑ ") +
						ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
					);
				}
				return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
			});
			ctx.ui.setWidget("plannotator-progress", lines);
		} else {
			ctx.ui.setWidget("plannotator-progress", undefined);
		}
	}

	function captureSavedState(ctx: ExtensionContext): void {
		savedState = {
			activeTools: pi.getActiveTools(),
			model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
			thinkingLevel: pi.getThinkingLevel(),
		};
	}

	function persistState(): void {


		pi.appendEntry("plannotator", { phase, lastSubmittedPath, savedState });
	}

	async function applyModelRef(
		ref: { provider: string; id: string },
		ctx: ExtensionContext,
		reason: string,
	): Promise<void> {
		const model = ctx.modelRegistry.find(ref.provider, ref.id);
		if (!model) {
			ctx.ui.notify(`Plannotator: ${reason} model ${ref.provider}/${ref.id} not found.`, "warning");
			return;
		}

		const success = await pi.setModel(model);
		if (!success) {
			ctx.ui.notify(`Plannotator: no API key for ${ref.provider}/${ref.id}.`, "warning");
		}
	}

	async function restoreSavedState(ctx: ExtensionContext): Promise<void> {
		if (!savedState) return;

		pi.setActiveTools(savedState.activeTools);
		if (savedState.model) {
			await applyModelRef(savedState.model, ctx, "restore");
		}
		pi.setThinkingLevel(savedState.thinkingLevel);
	}

	async function applyPhaseConfig(ctx: ExtensionContext, opts: { restoreSavedState?: boolean } = {}): Promise<void> {
		const profile = getPhaseProfile();
		if (opts.restoreSavedState !== false && savedState) {
			await restoreSavedState(ctx);
		}

		if (phase === "planning" || phase === "executing") {
			const baseTools = stripPlanningOnlyTools(savedState?.activeTools ?? pi.getActiveTools());
			const toolSet = new Set(baseTools);
			for (const tool of profile?.activeTools ?? []) toolSet.add(tool);
			if (phase === "planning") {
				pi.setActiveTools(getToolsForPhase([...toolSet], phase));
			} else {
				pi.setActiveTools([...toolSet]);
			}
		}

		if (profile?.model) {
			await applyModelRef(profile.model, ctx, phase);
		}

		if (profile?.thinking) {
			pi.setThinkingLevel(profile.thinking);
		}

		updateStatus(ctx);
		updateWidget(ctx);
	}

	async function enterPlanning(ctx: ExtensionContext): Promise<void> {
		phase = "planning";
		checklistItems = [];
		captureSavedState(ctx);
		await applyPhaseConfig(ctx, { restoreSavedState: false });
		persistState();
		ctx.ui.notify(
			"Plannotator: planning mode enabled.",
		);
		const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: hasPlanBrowserHtml() });
		if (warning) {
			ctx.ui.notify(warning, "warning");
		}
	}

	async function exitToIdle(ctx: ExtensionContext): Promise<void> {
		phase = "idle";
		checklistItems = [];
		lastSubmittedPath = null;

		await restoreSavedState(ctx);
		savedState = null;
		updateStatus(ctx);
		updateWidget(ctx);
		persistState();
		ctx.ui.notify("Plannotator: disabled. Full access restored.");
	}

	async function togglePlanMode(ctx: ExtensionContext): Promise<void> {
		if (phase === "idle") {
			await enterPlanning(ctx);
		} else {
			await exitToIdle(ctx);
		}
	}

	// ── Commands & Shortcuts ─────────────────────────────────────────────

	pi.registerCommand("plannotator", {
		description: "Toggle plannotator planning mode (markdown plans in cwd or configured external planRoot)",
		handler: async (_args, ctx) => {
			await togglePlanMode(ctx);
		},
	});

	pi.registerCommand("plannotator-review", {
		description: "Open interactive code review for current changes or a PR URL; pass --git to force Git in JJ workspaces",
		handler: async (args, ctx) => {
			if (!hasReviewBrowserHtml()) {
				ctx.ui.notify(
					"Code review UI not available. Run 'bun run build' in the pi-extension directory.",
					"error",
				);
				return;
			}

			currentPiSession.update(ctx);
			const origin = getPiSessionIdentity(ctx);

			try {
				const reviewArgs = parseReviewArgs(args ?? "");
				const session = await startCodeReviewBrowserSession(ctx, {
					prUrl: reviewArgs.prUrl,
					vcsType: reviewArgs.vcsType,
					useLocal: reviewArgs.useLocal,
					jjDefaultDiffType: resolvePiJjDefaultDiffType(plannotatorConfig),
				});
				ctx.ui.notify("Code review opened. You can keep chatting while it runs.", "info");
				void session
					.waitForDecision()
					.then((result) => {
						try {
							if (result.exit) {
								safeNotify(ctx, "Code review session closed.", "info", origin);
								return;
							}
							if (result.approved) {
								sendUserMessageWithCurrentSessionFallback(
									pi,
									getReviewApprovedPrompt("pi", loadConfig()),
									{ deliverAs: "followUp" },
									"Plannotator code review feedback could not be sent",
									origin,
								);
								return;
							}
							if (!result.feedback) {
								safeNotify(ctx, "Code review closed (no feedback).", "info", origin);
								return;
							}
							// Append the triage-first suffix when the reviewer sent
							// annotations to act on (PR mode included). Platform PR actions
							// (approve/comment posted to the host) come back with an empty
							// annotation set and a status message — don't tell the agent to
							// "address" a platform action.
							const reviewFeedback = (result.annotations?.length ?? 0) > 0
								? `${result.feedback}${getReviewDeniedSuffix("pi", loadConfig())}`
								: result.feedback;
							sendUserMessageWithCurrentSessionFallback(
								pi,
								reviewFeedback,
								{ deliverAs: "followUp" },
								"Plannotator code review feedback could not be sent",
								origin,
							);
						} catch (err) {
							reportBackgroundError(ctx, "Plannotator code review feedback could not be sent", err, origin);
						}
					})
					.catch((err) => {
						reportBackgroundError(ctx, "Plannotator code review session failed", err, origin);
					});
			} catch (err) {
				ctx.ui.notify(
					`Failed to start code review UI: ${getStartupErrorMessage(err)}`,
					"error",
				);
			}
		},
	});

	pi.registerCommand("plannotator-annotate", {
		description: "Open markdown file or folder in annotation UI",
		handler: async (args, ctx) => {
			// Split known annotate flags from the path. --json is silently
			// accepted (Pi writes back via sendUserMessage, not stdout).
			// `rawFilePath` keeps any leading `@` for the literal-@ fallback
			// (scoped-package-style names).
			const { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag, noJina } = parseAnnotateArgs(args ?? "");
			if (!filePath) {
				ctx.ui.notify("Usage: /plannotator-annotate <file.md | file.html | https://... | folder/> [--no-jina] [--gate] [--json]", "error");
				return;
			}
			if (!hasPlanBrowserHtml()) {
				ctx.ui.notify(
					"Annotation UI not available. Run 'bun run build' in the pi-extension directory.",
					"error",
				);
				return;
			}

			let markdown: string;
			let rawHtml: string | undefined;
			let absolutePath: string;
			let folderPath: string | undefined;
			let mode: "annotate" | "annotate-folder" | undefined;
			let sourceInfo: string | undefined;
			let sourceConverted = false;
			let isFolder = false;

			// --- URL annotation ---
			const isUrl = /^https?:\/\//i.test(filePath);

			if (isUrl) {
				const useJina = resolveUseJina(noJina, loadConfig());
				ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info");
				try {
					const result = await urlToMarkdown(filePath, { useJina });
					markdown = result.markdown;
					sourceConverted = isConvertedSource(result.source);
				} catch (err) {
					ctx.ui.notify(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, "error");
					return;
				}
				absolutePath = filePath;
				sourceInfo = filePath;
			} else {
				// Pick the interpretation of the user input that actually exists:
				// stripped form first (reference-mode primary), literal as fallback
				// for scoped-package-style names. Falls back to the stripped form
				// for the error message if neither exists.
				const resolvedCandidate = resolveAtReference(rawFilePath, (c) => {
					const abs = resolveUserPath(c, ctx.cwd);
					return existsSync(abs);
				});
				if (resolvedCandidate === null) {
					absolutePath = resolveUserPath(filePath, ctx.cwd);
					ctx.ui.notify(`File not found: ${absolutePath}`, "error");
					return;
				}
				absolutePath = resolveUserPath(resolvedCandidate, ctx.cwd);

				try {
					isFolder = statSync(absolutePath).isDirectory();
				} catch {
					ctx.ui.notify(`Cannot access: ${absolutePath}`, "error");
					return;
				}

				if (isFolder) {
					if (!hasMarkdownFiles(absolutePath, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) {
						ctx.ui.notify(`No markdown or HTML files found in ${absolutePath}`, "error");
						return;
					}
					markdown = "";
					folderPath = absolutePath;
					mode = "annotate-folder";
					ctx.ui.notify(`Opening annotation UI for folder ${filePath}...`, "info");
				} else if (/\.html?$/i.test(absolutePath)) {
					// HTML file annotation — convert to markdown via Turndown
					const fileSize = statSync(absolutePath).size;
					if (fileSize > 10 * 1024 * 1024) {
						ctx.ui.notify(`File too large (${Math.round(fileSize / 1024 / 1024)}MB, max 10MB)`, "error");
						return;
					}
					const html = readFileSync(absolutePath, "utf-8");
					if (renderHtmlFlag) {
						rawHtml = html;
						markdown = "";
					} else {
						markdown = htmlToMarkdown(html);
						sourceConverted = true;
					}
					sourceInfo = basename(absolutePath);
					ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info");
				} else {
					markdown = readFileSync(absolutePath, "utf-8");
					ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info");
				}
			}

			currentPiSession.update(ctx);
			const origin = getPiSessionIdentity(ctx);

			try {
				const session = await startMarkdownAnnotationSession(
					ctx,
					absolutePath,
					markdown,
					mode ?? "annotate",
					folderPath,
					sourceInfo,
					sourceConverted,
					gate,
					rawHtml,
					renderHtmlFlag,
				);
				ctx.ui.notify("Annotation opened. You can keep chatting while it runs.", "info");
				void session
					.waitForDecision()
					.then((result) => {
						try {
							if (result.exit) {
								safeNotify(ctx, "Annotation session closed.", "info", origin);
								return;
							}
							if (result.approved) {
								safeNotify(ctx, "Annotation approved.", "info", origin);
								return;
							}
							if (!result.feedback) {
								safeNotify(ctx, "Annotation closed (no feedback).", "info", origin);
								return;
							}
							sendUserMessageWithCurrentSessionFallback(
								pi,
								getAnnotateFileFeedbackPrompt("pi", loadConfig(), {
									fileHeader: isFolder ? "Folder" : "File",
									filePath: absolutePath,
									feedback: result.feedback,
								}),
								{ deliverAs: "followUp" },
								"Plannotator annotation feedback could not be sent",
								origin,
							);
						} catch (err) {
							reportBackgroundError(ctx, "Plannotator annotation feedback could not be sent", err, origin);
						}
					})
					.catch((err) => {
						reportBackgroundError(ctx, "Plannotator annotation session failed", err, origin);
					});
			} catch (err) {
				ctx.ui.notify(
					`Failed to start annotation UI: ${getStartupErrorMessage(err)}`,
					"error",
				);
			}
		},
	});

	pi.registerCommand("plannotator-last", {
		description: "Annotate the last assistant message",
		handler: async (args, ctx) => {
			// Support --gate on /plannotator-last for the Stop-hook review gate.
			const { gate } = parseAnnotateArgs(args ?? "");

			if (!hasPlanBrowserHtml()) {
				ctx.ui.notify(
					"Annotation UI not available. Run 'bun run build' in the pi-extension directory.",
					"error",
				);
				return;
			}

			currentPiSession.update(ctx);
			const origin = getPiSessionIdentity(ctx);

			const snapshot = getLastAssistantMessageSnapshot(ctx);
			if (!snapshot) {
				ctx.ui.notify("No assistant message found in session.", "error");
				return;
			}

			const recent = getRecentAssistantMessages(ctx, 25);
			const pickerMessages = recent.length > 1 ? recent : undefined;

			ctx.ui.notify("Opening annotation UI for last message...", "info");

			try {
				const session = await startLastMessageAnnotationSession(ctx, snapshot.text, gate, pickerMessages);
				ctx.ui.notify("Last-message annotation opened. You can keep chatting while it runs.", "info");
				void session
					.waitForDecision()
					.then((result) => {
						try {
							if (result.exit) {
								safeNotify(ctx, "Annotation session closed.", "info", origin);
								return;
							}
							if (result.approved) {
								safeNotify(ctx, "Message approved.", "info", origin);
								return;
							}
							if (!result.feedback) {
								safeNotify(ctx, "Annotation closed (no feedback).", "info", origin);
								return;
							}
							// Picker may have changed which message the feedback targets; if so,
							// look that one up in the current branch so the anchor quote matches.
							const target = result.selectedMessageId && result.selectedMessageId !== snapshot.entryId
								? findAssistantMessageByEntryId(ctx, result.selectedMessageId) ?? snapshot
								: snapshot;
								const feedback = result.feedbackScope !== "messages" && shouldAnchorLastMessageFeedback(ctx, target.entryId, origin)
									? anchorMessageFeedback(result.feedback, target.text)
									: result.feedback;
							sendUserMessageWithCurrentSessionFallback(
								pi,
								getAnnotateMessageFeedbackPrompt("pi", loadConfig(), {
									feedback,
								}),
								{ deliverAs: "followUp" },
								"Plannotator message annotation feedback could not be sent",
								origin,
							);
						} catch (err) {
							reportBackgroundError(ctx, "Plannotator message annotation feedback could not be sent", err, origin);
						}
					})
					.catch((err) => {
						reportBackgroundError(ctx, "Plannotator message annotation session failed", err, origin);
					});
			} catch (err) {
				ctx.ui.notify(
					`Failed to start annotation UI: ${getStartupErrorMessage(err)}`,
					"error",
				);
			}
		},
	});

	pi.registerShortcut(Key.ctrlAlt("p"), {
		description: "Toggle plannotator",
		handler: async (ctx) => {
			await togglePlanMode(ctx);
		},
	});

	pi.registerCommand("plannotator-continue-approved-plan", {
		description: "Continue the last approved Plannotator plan in a fresh session",
		handler: async (_args, ctx) => {
			const pending = pendingApprovedPlanContinuation;
			if (!pending || pending.mode !== "fresh") {
				ctx.ui.notify("No approved Plannotator plan is waiting for a fresh session.", "warning");
				return;
			}

			pendingApprovedPlanContinuation = null;
			const parentSession = ctx.sessionManager.getSessionFile();
			const result = await ctx.newSession({
				parentSession,
				setup: async (sessionManager) => {
					sessionManager.appendCustomEntry("plannotator", {
						phase: "executing",
						lastSubmittedPath: pending.planFilePath,
						savedState: null,
					} satisfies PersistedPlannotatorState);
					sessionManager.appendCustomEntry("plannotator-execute", {
						lastSubmittedPath: pending.planFilePath,
					});
				},
				withSession: async (replacementCtx) => {
					replacementCtx.ui.notify("Plannotator: starting approved plan in a fresh session.", "info");
					await replacementCtx.sendUserMessage(pending.kickoffMessage);
				},
			});

			if (result.cancelled) {
				pendingApprovedPlanContinuation = { ...pending, queued: false };
				ctx.ui.notify("Fresh-session approval was cancelled.", "warning");
			}
		},
	});

	// ── plannotator_submit_plan Tool ────────────────────────────────────

	pi.registerTool({
		name: PLAN_SUBMIT_TOOL,
		label: "Submit Plan",
		description:
			"Submit your Plannotator plan for user review. " +
			"Call this only while Plannotator planning mode is active, after writing your plan as a markdown file in the working directory or configured external planRoot. " +
			"Pass the path to the session-unique plan file (for example PLAN.md, plans/auth.md, or an external planRoot path). " +
			"Do not call it before the plan exists on disk. " +
			"If denied, edit the same file in place, then call this again with the same path.",
		parameters: Type.Object({
			filePath: Type.String({
			description:
				"Path to the markdown plan file. Must end in .md or .mdx and resolve inside cwd or the configured external planRoot.",
			}),
		}) as any,

		async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
			// Guard: must be in planning phase
			if (phase !== "planning") {
				return {
					content: [
						{
							type: "text",
							text: "Error: Not in plan mode. Use /plannotator to enter planning mode first.",
						},
					],
					details: { approved: false },
				};
			}

			const inputPath = (params as { filePath?: string })?.filePath?.trim();
			if (!inputPath) {
				return {
					content: [
						{
							type: "text",
							text: `Error: ${PLAN_SUBMIT_TOOL} requires a filePath argument pointing to your markdown plan file. ${buildPlanFileGuidance(ctx)}`,
						},
					],
					details: { approved: false },
				};
			}

			const fullPath = resolvePlanWritePath(inputPath, ctx.cwd, getPlanPathOptions(ctx));
			if (!fullPath) {
				return {
					content: [
						{
							type: "text",
							text: `Error: plan file must be a markdown file (.md or .mdx) ${formatPlanScope(ctx)}. Rejected: ${inputPath}`,
						},
					],
					details: { approved: false },
				};
			}

			try {
				if (!statSync(fullPath).isFile()) {
					return {
						content: [
							{
								type: "text",
								text: `Error: ${inputPath} is not a regular file. Write your plan to a markdown file first, then call ${PLAN_SUBMIT_TOOL} with its path.`,
							},
						],
						details: { approved: false },
					};
				}
			} catch {
				return {
					content: [
						{
							type: "text",
							text: `Error: ${inputPath} does not exist. Write your plan using the write tool first, then call ${PLAN_SUBMIT_TOOL} again.`,
						},
					],
					details: { approved: false },
				};
			}

			let planContent: string;
			try {
				planContent = readFileSync(fullPath, "utf-8");
			} catch (err) {
				return {
					content: [
						{
							type: "text",
							text: `Error: failed to read ${inputPath}: ${err instanceof Error ? err.message : String(err)}`,
						},
					],
					details: { approved: false },
				};
			}

			if (planContent.trim().length === 0) {
				return {
					content: [
						{
							type: "text",
							text: `Error: ${inputPath} is empty. Write your plan first, then call ${PLAN_SUBMIT_TOOL} again.`,
						},
					],
					details: { approved: false },
				};
			}

			lastSubmittedPath = inputPath;
			checklistItems = parseChecklist(planContent);

			// Non-interactive or no HTML: auto-approve
			if (!ctx.hasUI || !hasPlanBrowserHtml()) {
				phase = "executing";
				await applyPhaseConfig(ctx, { restoreSavedState: true });
				pi.appendEntry("plannotator-execute", { lastSubmittedPath });
				persistState();
				pendingApprovedPlanContinuation = {
					mode: "current",
					planFilePath: inputPath,
					kickoffMessage: "Continue with the approved plan.",
				};
				return {
					content: [
						{
							type: "text",
							text: getPlanAutoApprovedPrompt("pi", loadConfig()),
						},
					],
					details: { approved: true },
					terminate: true,
				};
			}

			let result: Awaited<ReturnType<typeof openPlanReviewBrowser>>;
			try {
				result = await openPlanReviewBrowser(ctx, planContent);
			} catch (err) {
				const message = `Failed to start plan review UI: ${getStartupErrorMessage(err)}`;
				ctx.ui.notify(message, "error");
				return {
					content: [{ type: "text", text: message }],
					details: { approved: false },
				};
			}

			if (result.approved) {
				const approvalSession: ApprovalSessionMode = result.approvalSession === "fresh" ? "fresh" : "current";
				const doneMsg =
					checklistItems.length > 0
						? `After completing each step, include [DONE:n] in your response where n is the step number.`
						: "";
				const approvalPrompt = result.feedback
					? getPlanApprovedWithNotesPrompt("pi", loadConfig(), {
						planFilePath: inputPath,
						doneMsg,
						feedback: result.feedback,
					})
					: getPlanApprovedPrompt("pi", loadConfig(), {
						planFilePath: inputPath,
						doneMsg,
					});

				if (approvalSession === "fresh") {
					phase = "idle";
					checklistItems = [];
					lastSubmittedPath = null;
					await restoreSavedState(ctx);
					savedState = null;
					updateStatus(ctx);
					updateWidget(ctx);
					persistState();
					pendingApprovedPlanContinuation = {
						mode: "fresh",
						planFilePath: inputPath,
						kickoffMessage: approvalPrompt,
					};

					return {
						content: [
							{
								type: "text",
								text: "Plan approved. Starting implementation in a fresh Pi session.",
							},
						],
						details: { approved: true, feedback: result.feedback, approvalSession },
						terminate: true,
					};
				}

				phase = "executing";
				await applyPhaseConfig(ctx, { restoreSavedState: true });
				pi.appendEntry("plannotator-execute", { lastSubmittedPath });
				persistState();
				pendingApprovedPlanContinuation = {
					mode: "current",
					planFilePath: inputPath,
					kickoffMessage: "Continue with the approved plan.",
				};

				return {
					content: [
						{
							type: "text",
							text: approvalPrompt,
						},
					],
					details: { approved: true, feedback: result.feedback, approvalSession },
					terminate: true,
				};
			}

			// Denied
			persistState();
			const feedbackText = result.feedback || "Plan rejected. Please revise.";
			return {
				content: [
					{
						type: "text",
						text: getPlanDeniedPrompt("pi", loadConfig(), {
							toolName: getPlanToolName("pi"),
							planFileRule: buildPlanFileRule(getPlanToolName("pi"), inputPath),
							feedback: feedbackText,
						}),
					},
				],
				details: { approved: false, feedback: feedbackText },
			};
		},
	});

	// ── Event Handlers ───────────────────────────────────────────────────

	// Gate writes during planning — only markdown files inside cwd.
	pi.on("tool_call", async (event, ctx) => {
		if (phase !== "planning") return;
		if (event.toolName === "jj_todo") {
			const reason = validatePlanningJjTodoInput(event.input);
			if (reason) {
				return { block: true, reason };
			}
			return;
		}
		if (event.toolName !== "write" && event.toolName !== "edit") return;

		const inputPath = event.input.path as string;
		if (!isPlanWritePathAllowed(inputPath, ctx.cwd, getPlanPathOptions(ctx))) {
			const verb = event.toolName === "write" ? "writes" : "edits";
			return {
				block: true,
				reason: `Plannotator: during planning, ${verb} are limited to markdown files (.md, .mdx) ${formatPlanScope(ctx)}. Blocked: ${inputPath}`,
			};
		}
	});

	// Inject phase-specific context
	pi.on("before_agent_start", async (_event, ctx) => {
		const profile = getPhaseProfile();
		const planRef = lastSubmittedPath ?? "your plan file";

		if (phase === "executing" && lastSubmittedPath) {
			// Re-read from disk each turn to stay current
			const fullPath = resolvePlanWritePath(lastSubmittedPath, ctx.cwd, getPlanPathOptions(ctx)) ?? resolve(ctx.cwd, lastSubmittedPath);
			try {
				const planContent = readFileSync(fullPath, "utf-8");
				checklistItems = parseChecklist(planContent);
			} catch {
				// File deleted during execution — degrade gracefully
			}
		}

		const todoStats = phase === "executing" ? formatTodoList(checklistItems) : formatTodoList([]);

		let improveContext: string | null = null;
		if (phase === "planning") {
			const hook = readImprovementHook("enterplanmode-improve");
			const pfmEnabled = loadConfig().pfmReminder === true;
			improveContext = composeImproveContext({
				pfmEnabled,
				improvementHookContent: hook?.content ?? null,
			});
		}

		if (profile?.systemPrompt) {
			const rendered = renderTemplate(
				profile.systemPrompt,
				buildPromptVariables({
					planFilePath: planRef,
					phase,
					todoList: todoStats.todoList,
					completedCount: todoStats.completedCount,
					totalCount: todoStats.totalCount,
					remainingCount: todoStats.remainingCount,
					planRoot: getResolvedPlanRoot(ctx),
					planFileGuidance: buildPlanFileGuidance(ctx),
				}),
			);
			if (rendered.unknownVariables.length > 0) {
				ctx.ui.notify(
					"Plannotator: unknown template variables in " + phase + " prompt: " + rendered.unknownVariables.join(", "),
					"warning",
				);
			}

			return { systemPrompt: rendered.text + (improveContext ? "\n\n" + improveContext : "") };
		}

		if (phase === "planning") {
			return {
				message: {
					customType: "plannotator-context",
					content: `[PLANNOTATOR - PLANNING PHASE]
You are in plan mode. You MUST NOT make any changes to the codebase — no code edits, commits, installs, destructive commands, or repository mutations. During planning you may only write or edit markdown files (.md, .mdx) ${formatPlanScope(ctx)}.

Available tools: read, grep, find, ls, ask_user_question, web_search, fetch_content, get_search_content, code_search, jj_context, write (markdown plan files only), edit (markdown plan files only), jj_todo (planning previews only), ${PLAN_SUBMIT_TOOL}. Use bash sparingly for tests/build metadata or external CLIs when a structured tool cannot answer the question.

Do not use curl/wget for web research; use the web/content tools instead. jj_todo create/update is allowed only with dryRun: true and fresh: false.

## Iterative Planning Workflow

You are pair-planning with the user. Explore the code to build context, then write your findings into a markdown plan file as you go. The plan starts as a rough skeleton and gradually becomes the final plan.

### Picking a plan file

${buildPlanFileGuidance(ctx)}

Choose a descriptive, session-unique filename for new plans so multiple concurrent or historical plans can coexist. Reuse the same filename only when revising the same plan after feedback.

### The Loop

Repeat this cycle until the plan is complete:

1. **Explore** — Use read, grep, find, ls, jj_context, and the search/content tools to understand the codebase. Actively search for existing functions, utilities, and patterns that can be reused — avoid proposing new code when suitable implementations already exist.
2. **Update the plan file** — After each discovery, immediately capture what you learned in the plan. Don't wait until the end. Use write for the initial draft, then edit for all subsequent updates.
3. **Ask the user** — When you hit an ambiguity or decision you can't resolve from code alone, use ask_user_question when structured choices would be easier for the user; otherwise ask directly. Then go back to step 1.

### First Turn

Start by quickly scanning key files to form an initial understanding of the task scope. Then write a skeleton plan (headers and rough notes) and ask the user your first round of questions. Don't explore exhaustively before engaging the user.

### Asking Good Questions

- Never ask what you could find out by reading the code.
- Batch related questions together.
- Focus on things only the user can answer: requirements, preferences, tradeoffs, edge-case priorities.
- Use ask_user_question for concise option sets when it makes the choice easier to answer.
- Scale depth to the task — a vague feature request needs many rounds; a focused bug fix may need one or none.

### Plan File Structure

Your plan file should use markdown with clear sections:
- **Context** — Why this change is being made: the problem, what prompted it, the intended outcome.
- **Approach** — Your recommended approach only, not all alternatives considered.
- **Files to modify** — List the critical file paths that will be changed.
- **Reuse** — Reference existing functions and utilities you found, with their file paths.
- **Steps** — Implementation checklist:
  - [ ] Step 1 description
  - [ ] Step 2 description
- **Verification** — How to test the changes end-to-end (run the code, run tests, manual checks).

Keep the plan concise enough to scan quickly, but detailed enough to execute effectively.

### When to Submit

Your plan is ready when you've addressed all ambiguities and it covers: what to change, which files to modify, what existing code to reuse, and how to verify. Call ${PLAN_SUBMIT_TOOL} with the path to your plan file to submit for review.

### Revising After Feedback

When the user denies a plan with feedback:
1. Read the plan file to see the current plan.
2. Use the edit tool to make targeted changes addressing the feedback — do NOT rewrite the entire file.
3. Call ${PLAN_SUBMIT_TOOL} again with the same filePath to resubmit.

### Ending Your Turn

Your turn should only end by either:
- Asking the user a question to gather more information.
- Calling ${PLAN_SUBMIT_TOOL} when the plan is ready for review.

Do not end your turn without doing one of these two things.` + (improveContext ? "\n\n---\n\n" + improveContext : ""),
					display: false,
				},
			};
		}

		if (phase === "executing" && checklistItems.length > 0) {
			const remaining = checklistItems.filter((t) => !t.completed);
			if (remaining.length > 0) {
				const todoList = remaining
					.map((t) => `- [ ] ${t.step}. ${t.text}`)
					.join("\n");
				return {
					message: {
						customType: "plannotator-context",
						content: `[PLANNOTATOR - EXECUTING PLAN]
Full tool access is enabled. Execute the plan from ${planRef}.

Remaining steps:
${todoList}

Execute each step in order. After completing a step, include [DONE:n] in your response where n is the step number.`,
						display: false,
					},
				};
			}
		}
	});

	// Filter stale context when idle
	pi.on("context", async (event) => {
		if (phase !== "idle") return;

		return {
			messages: event.messages.filter((m) => {
				const msg = m as { customType?: string; role?: string; content?: unknown };
				if (msg.customType === "plannotator-context") return false;
				if (msg.role !== "user") return true;

				const content = msg.content;
				if (typeof content === "string") {
					return !content.includes("[PLANNOTATOR -");
				}
				if (Array.isArray(content)) {
					return !content.some(
						(c) =>
							c.type === "text" &&
							(c as { text?: string }).text?.includes("[PLANNOTATOR -"),
					);
				}
				return true;
			}),
		};
	});

	// Track execution progress
	pi.on("turn_end", async (event, ctx) => {
		if (phase !== "executing" || checklistItems.length === 0) return;

		const text = getAssistantMessageText(event.message);
		if (!text) return;
		if (markCompletedSteps(text, checklistItems) > 0) {
			updateStatus(ctx);
			updateWidget(ctx);
		}
		persistState();
	});

	// Detect execution completion
	pi.on("agent_end", async (_event, ctx) => {
		const pendingContinuation = pendingApprovedPlanContinuation;
		if (pendingContinuation) {
			let attempts = 0;
			const continueWhenIdle = (): void => {
				if (!ctx.isIdle()) {
					attempts += 1;
					if (attempts <= 200) setTimeout(continueWhenIdle, 50);
					return;
				}
				if (pendingContinuation.mode === "fresh") {
					if (pendingContinuation.queued) return;
					pendingContinuation.queued = true;
					pi.sendUserMessage("/plannotator-continue-approved-plan");
					return;
				}
				pendingApprovedPlanContinuation = null;
				pi.sendUserMessage(pendingContinuation.kickoffMessage);
			};
			setTimeout(continueWhenIdle, 0);
			return;
		}

		if (phase !== "executing" || checklistItems.length === 0) return;

		if (checklistItems.every((t) => t.completed)) {
			const completedList = checklistItems
				.map((t) => `- [x] ~~${t.text}~~`)
				.join("\n");
			pi.sendMessage(
				{
					customType: "plannotator-complete",
					content: `**Plan Complete!** ✓\n\n${completedList}`,
					display: true,
				},
				{ triggerTurn: false },
			);
			phase = "idle";
			checklistItems = [];
			lastSubmittedPath = null;

			await restoreSavedState(ctx);
			savedState = null;
			updateStatus(ctx);
			updateWidget(ctx);
			persistState();
		}
	});

	// Restore state on session start/resume
	pi.on("session_start", async (_event, ctx) => {
		const loadedConfig = loadPlannotatorConfig(ctx.cwd);
		plannotatorConfig = loadedConfig.config;
		for (const warning of loadedConfig.warnings) {
			ctx.ui.notify(`Plannotator config: ${warning}`, "warning");
		}

		// Check --plan flag
		if (pi.getFlag("plan") === true) {
			phase = "planning";
		}

		// Restore persisted state
		const entries = ctx.sessionManager.getEntries();
		const stateEntry = entries
			.filter(
				(e: { type: string; customType?: string }) =>
					e.type === "custom" && e.customType === "plannotator",
			)
			.pop() as { data?: PersistedPlannotatorState } | undefined;

		if (stateEntry?.data) {
			phase = stateEntry.data.phase ?? phase;
			lastSubmittedPath = stateEntry.data.lastSubmittedPath ?? lastSubmittedPath;
			savedState = stateEntry.data.savedState ?? savedState;
		}

		// Rebuild execution state from disk + session messages
		if (phase === "executing") {
			if (lastSubmittedPath) {
				const fullPath = resolvePlanWritePath(lastSubmittedPath, ctx.cwd, getPlanPathOptions(ctx)) ?? resolve(ctx.cwd, lastSubmittedPath);
				if (existsSync(fullPath)) {
					const content = readFileSync(fullPath, "utf-8");
					checklistItems = parseChecklist(content);

					// Find last execution marker and scan messages after it for [DONE:n]
					let executeIndex = -1;
					for (let i = entries.length - 1; i >= 0; i--) {
						const entry = entries[i] as { type: string; customType?: string };
						if (entry.customType === "plannotator-execute") {
							executeIndex = i;
							break;
						}
					}

					for (let i = executeIndex + 1; i < entries.length; i++) {
						const entry = entries[i];
						if (entry.type === "message" && "message" in entry) {
							const text = getAssistantMessageText(entry.message);
							if (text) markCompletedSteps(text, checklistItems);
						}
					}
				} else {
					// Plan file gone — fall back to idle
					phase = "idle";
					lastSubmittedPath = null;
				}
			} else {
				// No path recorded — can't rebuild, fall back to idle
				phase = "idle";
			}
		}
		if (phase === "planning") {
			checklistItems = [];
			const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: hasPlanBrowserHtml() });
			if (warning) {
				ctx.ui.notify(warning, "warning");
			}
		}

		if (phase === "idle") {
			if (savedState) {
				await restoreSavedState(ctx);
				savedState = null;
			} else {
				// Strip planning-only tools on fresh sessions where savedState is null.
				// Without this, plannotator_submit_plan stays in the active tool set
				// even though plan mode hasn't been activated. See #387.
				pi.setActiveTools(stripPlanningOnlyTools(pi.getActiveTools()));
			}
		} else if (phase === "planning" || phase === "executing") {
			if (!savedState) captureSavedState(ctx);
			await applyPhaseConfig(ctx, { restoreSavedState: true });
		}

		updateStatus(ctx);
		updateWidget(ctx);
		persistState();
	});
}
