/**
 * PtyChatParser — ANSI stripper, message segmenter, role classifier,
 * TUI prompt detector, and completion signal emitter.
 *
 * Accepts raw PTY byte chunks from the /api/terminal/stream SSE feed
 * ({ type: "output", data: string } payloads) and produces a structured
 * ChatMessage[] that downstream chat rendering components can consume.
 *
 * Design principles:
 * - No xterm.js dependency — pure string processing
 * - Deterministic given the same input sequence
 * - Logs structural signals only — never raw PTY content (may contain secrets)
 * - Debug-level console.debug under [pty-chat-parser] prefix
 *
 * TUI detection patterns (after ANSI stripping):
 * - Select list: lines starting with "  › N." (selected) or "    N." (unselected)
 *   Uses SF's shared UI cursor glyph "›"
 * - Checkbox: lines starting with "  › [x]" or "  › [ ]" (multi-select)
 * - Password/text: @clack/prompts "◆  " or "?" prefix + label ending with ":"
 * - Completion: main prompt (❯ / › / > / $) reappears after ≥2s of no output
 */

// ─── Public Types ─────────────────────────────────────────────────────────────

export type MessageRole = "user" | "assistant" | "system";

export interface TuiPrompt {
	kind: "select" | "text" | "password";
	/** The prompt label / question text */
	label: string;
	/** For select prompts: the list of option labels */
	options: string[];
	/** For select prompts: optional per-option descriptions */
	descriptions?: string[];
	/** For select prompts: the currently highlighted option index (0-based) */
	selectedIndex: number;
}

export interface CompletionSignal {
	/** The session or context source this signal came from */
	source: string;
	/** Unix timestamp (ms) when the signal was emitted */
	timestamp: number;
}

export interface ChatMessage {
	/** Stable UUID — same object mutated in place while streaming */
	id: string;
	role: MessageRole;
	/** ANSI-stripped content */
	content: string;
	/** false while streaming, true when a boundary has been detected */
	complete: boolean;
	/** Set when a TUI prompt is detected inside this message */
	prompt?: TuiPrompt;
	/** Unix timestamp (ms) of first content */
	timestamp: number;
	/** Optional images attached by the user (chat mode only — PTY parser never sets this) */
	images?: { data: string; mimeType: string }[];
}

// ─── Subscriber Types ─────────────────────────────────────────────────────────

type MessageCallback = (message: ChatMessage) => void;
type CompletionCallback = (signal: CompletionSignal) => void;
type Unsubscribe = () => void;

// ─── ANSI Stripper ────────────────────────────────────────────────────────────

/**
 * stripAnsi — remove all ANSI/VT100 escape sequences from a string.
 *
 * Handles:
 * - CSI sequences: \x1b[ ... final-byte (params + optional intermediates)
 * - OSC sequences: \x1b] ... \x07 or \x1b\\
 * - SS2/SS3: \x1bN, \x1bO + one char
 * - DCS/PM/APC: \x1bP/\x1b^/\x1b_ ... \x1b\\
 * - Simple ESC + one char (e.g. \x1bM reverse index)
 * - Bare \r at line start (overwrite pattern) → normalised to \n
 */
export function stripAnsi(s: string): string {
	// OSC: \x1b] ... (\x07 or \x1b\)

	s = s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
	// DCS / PM / APC: \x1bP, \x1b^, \x1b_ ... \x1b\

	s = s.replace(/\x1b[P^_][^\x1b]*\x1b\\/g, "");
	// CSI: \x1b[ ... final byte (0x40–0x7e)

	s = s.replace(/\x1b\[[0-9;:<=>?]*[ -/]*[@-~]/g, "");
	// SS2 / SS3: \x1b(N|O) + one char

	s = s.replace(/\x1b[NO]./g, "");
	// All remaining ESC + one char (e.g. \x1bM, \x1b7, \x1b8, \x1b=, etc.)

	s = s.replace(/\x1b./g, "");
	// Stray lone \x1b with no following char

	s = s.replace(/\x1b/g, "");
	// \r followed by content overwrites the current line — keep the tail only
	// e.g. "old content\rnew content" → "new content"
	s = s.replace(/[^\n]*\r([^\n])/g, "$1");
	// Remaining bare \r → strip
	s = s.replace(/\r/g, "");
	return s;
}

// ─── Role / Boundary Heuristics ───────────────────────────────────────────────

/**
 * SF prompt markers that signal the boundary between turns.
 * After ANSI stripping, SF's Pi agent shows one of these at the start
 * of a line when waiting for user input.
 */
const PROMPT_MARKERS = [
	/^❯\s*/, // Pi default primary prompt
	/^›\s*/, // Pi alternate prompt
	/^>(\s+|$)/, // Simple > prompt (some themes) — bare ">" or "> text"
	/^\$(\s+|$)/, // Shell prompt fallback — bare "$" or "$ text"
];

/**
 * System/status lines: short, bracket-wrapped messages that SF emits
 * at well-known lifecycle points.
 */
const SYSTEM_LINE_PATTERNS = [
	/^\[connecting[.\u2026]*/i,
	/^\[connected\]/i,
	/^\[disconnected\]/i,
	/^\[auto\s+mode/i,
	/^\[auto-mode/i,
	/^\[thinking[.\u2026]*/i,
	/^\[done\]/i,
	/^\[error/i,
	/^sf\s+v[\d.]+/i, // version banner
	/^✓\s/, // short success lines
	/^✗\s/, // short failure lines
];

/** Returns true if the (stripped) line looks like a SF input prompt */
function isPromptLine(line: string): boolean {
	const trimmed = line.trim();
	return PROMPT_MARKERS.some((r) => r.test(trimmed));
}

/** Returns true if the (stripped) line looks like a system status message */
function isSystemLine(line: string): boolean {
	const trimmed = line.trim();
	if (trimmed.length === 0) return false;
	// Short bracket-wrapped lines
	if (/^\[.*\]$/.test(trimmed) && trimmed.length < 80) return true;
	return SYSTEM_LINE_PATTERNS.some((r) => r.test(trimmed));
}

// ─── TUI Prompt Detection ─────────────────────────────────────────────────────

/**
 * SF's shared UI uses "›" as cursor glyph (GLYPH.cursor = "›")
 * After ANSI stripping, a selected option renders as:
 *   "  › N. Label"  (with leading spaces from INDENT.option)
 * An unselected option renders as:
 *   "    N. Label"  (4 spaces instead of cursor)
 * Description lines render indented (5 spaces): "     Some description"
 *
 * Checkbox selected:  "  › [x] Label"
 * Checkbox unselected: "  › [ ] Label" or "    [ ] Label"
 *
 * A select block starts with a bar line (──────) or header line and
 * contains ≥2 numbered option lines within a short time window.
 */

/** Matches a SF selected option line: "  › N. Label" */
const SELECT_OPTION_SELECTED_RE = /^\s{0,4}›\s+(\d+)\.\s+(.+)/;

/** Matches a SF unselected option line: "    N. Label" */
const SELECT_OPTION_UNSELECTED_RE = /^\s{3,6}(\d+)\.\s+(.+)/;

/** Matches a SF checkbox option: "  › [x] Label" or "  › [ ] Label" */
const CHECKBOX_SELECTED_RE = /^\s{0,4}›\s+\[([x ])\]\s+(.+)/i;

/** Matches a SF separator bar line: all ─ characters */
const BAR_LINE_RE = /^[─━─\-─]+$/;

/**
 * Matches @clack/prompts password prompt lines:
 * - "◆  Some label:" (clack uses ◆ as question marker)
 * - "?  Some label:" (alternative clack style)
 * - "▲  Some label:" (another clack variant)
 */
const CLACK_PASSWORD_RE =
	/^[◆▲?]\s{1,3}(.+(?:API\s*key|password|token|secret)[^:]*):?\s*$/i;

/**
 * Matches SF text input prompts — @clack style or bare labeled prompts:
 * - "◆  Enter project name:"
 * - "?  What is your name?"
 */
const CLACK_TEXT_RE = /^[◆▲?]\s{1,3}(.+[?:])\s*$/;

/**
 * Matches hints line rendered by SF's shared UI:
 * "  ↑/↓ to move  |  enter to select"
 * These appear below select lists and help confirm a select block is active.
 */
const HINTS_RE = /↑|↓|arrow|enter to select|space to toggle/i;

/** Minimum option lines needed to recognise a select block */
const MIN_SELECT_OPTIONS = 2;

/** Max ms to accumulate select option lines before committing the block */
const SELECT_WINDOW_MS = 300;

/**
 * Minimum milliseconds of silence (no PTY output) after the main prompt
 * re-appears before a CompletionSignal is emitted.
 * Conservative: false positives (premature close) are worse than negatives.
 */
const COMPLETION_DEBOUNCE_MS = 2000;

// ─── UUID Utility ─────────────────────────────────────────────────────────────

function newId(): string {
	if (typeof crypto !== "undefined" && crypto.randomUUID) {
		return crypto.randomUUID();
	}
	// Fallback for environments without crypto.randomUUID
	return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
		const r = (Math.random() * 16) | 0;
		return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
	});
}

// ─── Select Block Accumulator ─────────────────────────────────────────────────

interface SelectOption {
	index: number; // 1-based as rendered by SF
	label: string;
	selected: boolean;
}

interface SelectBlock {
	label: string; // question/header text above the options
	options: SelectOption[];
	windowTimer: ReturnType<typeof setTimeout> | null;
	firstLineAt: number;
}

// ─── PtyChatParser ────────────────────────────────────────────────────────────

/**
 * PtyChatParser — stateful parser for raw PTY output.
 *
 * Usage:
 *   const parser = new PtyChatParser()
 *   parser.onMessage((msg) => console.log(msg))
 *   // Feed SSE output chunks:
 *   es.onmessage = (e) => {
 *     const { type, data } = JSON.parse(e.data)
 *     if (type === 'output') parser.feed(data)
 *   }
 */
export class PtyChatParser {
	/** Raw byte buffer — accumulates across chunks until a boundary is found */
	private _buffer = "";
	/** Stable ordered message list */
	private _messages: ChatMessage[] = [];
	/** Subscribers for message events */
	private _subscribers = new Set<MessageCallback>();
	/** Subscribers for completion signals */
	private _completionSubscribers = new Set<CompletionCallback>();
	/** Source label for CompletionSignal */
	private _source: string;
	/** The message currently being built (not yet complete) */
	private _activeMessage: ChatMessage | null = null;

	// ── TUI state ────────────────────────────────────────────────────────────────

	/**
	 * Pending select block accumulator.
	 * Lives until either: enough options arrive and the window closes,
	 * or the window timer fires with too few options.
	 */
	private _pendingSelect: SelectBlock | null = null;

	/**
	 * The last "question / header" line text seen before option lines start.
	 * Reset when a new bar line appears.
	 */
	private _lastHeaderText = "";

	/**
	 * Set to true when main prompt line appears; cleared if more output arrives
	 * before COMPLETION_DEBOUNCE_MS expires. Timer fires the signal.
	 */
	private _completionTimer: ReturnType<typeof setTimeout> | null = null;

	/**
	 * Whether we have already emitted a completion signal since the last
	 * non-trivial output — guards against double-fire.
	 */
	private _completionEmitted = false;

	/**
	 * True when the parser has seen a prompt boundary and is waiting for user
	 * input.  The next non-system, non-prompt, non-TUI content line after the
	 * prompt is classified as role="user" instead of "assistant".
	 * Reset to false once that user line arrives (or when a new assistant
	 * message explicitly starts via a different signal).
	 */
	private _awaitingInput = false;

	constructor(source = "default") {
		this._source = source;
	}

	// ── Public API ──────────────────────────────────────────────────────────────

	/**
	 * Feed a raw PTY chunk (may contain ANSI codes, partial lines, etc.)
	 */
	feed(chunk: string): void {
		// Any new content resets pending completion — we're still receiving output
		if (this._completionTimer) {
			clearTimeout(this._completionTimer);
			this._completionTimer = null;
		}
		this._buffer += chunk;
		this._process();
	}

	/** Return a shallow copy of the current message list */
	getMessages(): ChatMessage[] {
		return [...this._messages];
	}

	/**
	 * Returns true when the parser has detected a prompt boundary and is
	 * waiting for user input.  Chat UIs can use this to show an "awaiting
	 * input" indicator so the session does not appear stuck.
	 */
	isAwaitingInput(): boolean {
		return this._awaitingInput;
	}

	/**
	 * Flush any trailing partial buffer even if it does not end with a newline.
	 * Useful for terminal UIs that leave the final status line unterminated.
	 */
	flush(): void {
		if (this._buffer.length === 0) return;

		const stripped = stripAnsi(this._buffer);
		this._buffer = "";

		for (const rawLine of stripped.split("\n")) {
			const line = rawLine.trimEnd();
			if (line.length === 0) continue;
			this._handleLine(line);
		}
	}

	/**
	 * Subscribe to message events (new message or content appended).
	 * Returns an unsubscribe function.
	 */
	onMessage(cb: MessageCallback): Unsubscribe {
		this._subscribers.add(cb);
		return () => this._subscribers.delete(cb);
	}

	/**
	 * Subscribe to completion signals (SF returned to idle prompt after ≥2s silence).
	 * Returns an unsubscribe function.
	 */
	onCompletionSignal(cb: CompletionCallback): Unsubscribe {
		this._completionSubscribers.add(cb);
		return () => this._completionSubscribers.delete(cb);
	}

	/** Reset all state — useful when a new session starts */
	reset(): void {
		this._buffer = "";
		this._messages = [];
		this._activeMessage = null;
		this._pendingSelect = null;
		this._lastHeaderText = "";
		this._completionEmitted = false;
		this._awaitingInput = false;
		if (this._completionTimer) {
			clearTimeout(this._completionTimer);
			this._completionTimer = null;
		}
		console.debug("[pty-chat-parser] reset source=%s", this._source);
	}

	// ── Internal Processing ─────────────────────────────────────────────────────

	private _process(): void {
		// Accumulate until we have at least one complete line
		// Process all complete lines; leave the last partial line in the buffer
		const lastNewline = this._buffer.lastIndexOf("\n");
		if (lastNewline === -1) return; // no complete line yet

		const toProcess = this._buffer.slice(0, lastNewline + 1);
		this._buffer = this._buffer.slice(lastNewline + 1);

		const stripped = stripAnsi(toProcess);
		const lines = stripped.split("\n");

		for (const rawLine of lines) {
			const line = rawLine.trimEnd();
			this._handleLine(line);
		}
	}

	private _handleLine(line: string): void {
		const trimmed = line.trim();

		// Blank lines — append to active assistant message as spacing
		if (trimmed.length === 0) {
			if (this._activeMessage?.role === "assistant") {
				this._appendToActive("\n");
			}
			return;
		}

		// ── Separator bar (─────) — signals UI block boundary ───────────────────
		if (BAR_LINE_RE.test(trimmed)) {
			// Commit any pending select block
			this._commitSelectBlock();
			// Reset header text — next non-bar line may be a new question
			this._lastHeaderText = "";
			// Append to active assistant content
			if (
				this._activeMessage &&
				!this._activeMessage.complete &&
				this._activeMessage.role === "assistant"
			) {
				this._appendToActive(line + "\n");
			}
			return;
		}

		// ── TUI option lines — must be checked BEFORE isPromptLine ─────────────
		// Reason: the SF UI cursor glyph "›" is also a PROMPT_MARKER, so a
		// selected-option line like "  › 1. Describe it now" would be mistakenly
		// handled as a prompt boundary if isPromptLine ran first.

		// Checkbox option line: "  › [x] Label" / "  › [ ] Label"
		const checkboxMatch = CHECKBOX_SELECTED_RE.exec(line);
		if (checkboxMatch) {
			this._handleCheckboxOption(checkboxMatch[1], checkboxMatch[2]);
			return;
		}

		// Selected option line: "  › N. Label"
		const selectedMatch = SELECT_OPTION_SELECTED_RE.exec(line);
		if (selectedMatch) {
			this._handleSelectOption(
				parseInt(selectedMatch[1], 10),
				selectedMatch[2],
				true,
			);
			return;
		}

		// Unselected option line: "    N. Label" (3–6 leading spaces, no ›)
		// Guard: must look like a numbered option — not a description indent line
		const unselectedMatch = SELECT_OPTION_UNSELECTED_RE.exec(line);
		if (unselectedMatch && !SELECT_OPTION_SELECTED_RE.test(line)) {
			this._handleSelectOption(
				parseInt(unselectedMatch[1], 10),
				unselectedMatch[2],
				false,
			);
			return;
		}

		// Hints line (↑/↓ navigation hints) — end of a select block
		if (HINTS_RE.test(trimmed)) {
			this._commitSelectBlock();
			if (
				this._activeMessage &&
				!this._activeMessage.complete &&
				this._activeMessage.role === "assistant"
			) {
				this._appendToActive(line + "\n");
			}
			return;
		}

		// ── Prompt line → boundary ───────────────────────────────────────────────
		if (isPromptLine(trimmed)) {
			// Commit any pending select block before closing this turn
			this._commitSelectBlock();

			// Complete any active message
			if (this._activeMessage) {
				this._completeActive();
				console.debug(
					"[pty-chat-parser] boundary: prompt detected, completed msg=%s role=%s source=%s",
					this._activeMessage?.id ?? "(none)",
					this._activeMessage?.role ?? "(none)",
					this._source,
				);
			}

			// Schedule completion signal with debounce
			this._scheduleCompletionSignal();

			// Start a new user message (the text after the prompt marker is user input)
			const userText = trimmed
				.replace(PROMPT_MARKERS[0], "")
				.replace(PROMPT_MARKERS[1], "")
				.replace(PROMPT_MARKERS[2], "")
				.replace(PROMPT_MARKERS[3], "")
				.trim();

			if (userText.length > 0) {
				const msg = this._startMessage("user", userText);
				this._completeMessage(msg); // user lines are typically single-line
				this._awaitingInput = false;
			} else {
				// Bare prompt with no inline user text — mark as awaiting input
				// so the next content line is classified as user input.
				this._awaitingInput = true;
			}
			return;
		}

		// ── System / status line ─────────────────────────────────────────────────
		if (isSystemLine(trimmed)) {
			// Complete any active non-system message first
			if (this._activeMessage && this._activeMessage.role !== "system") {
				this._completeActive();
			}
			// System messages are always self-contained single lines
			const msg = this._startMessage("system", trimmed);
			this._completeMessage(msg);
			console.debug(
				"[pty-chat-parser] system line detected id=%s source=%s",
				msg.id,
				this._source,
			);
			return;
		}

		// ── @clack/prompts TUI prompts ───────────────────────────────────────────

		// Password prompt: @clack/prompts "◆  Paste your Anthropic API key:"
		const passwordMatch = CLACK_PASSWORD_RE.exec(trimmed);
		if (passwordMatch) {
			this._handlePasswordPrompt(passwordMatch[1]);
			return;
		}

		// Text prompt: @clack/prompts "◆  Enter project name:"
		const textMatch = CLACK_TEXT_RE.exec(trimmed);
		if (textMatch) {
			this._handleTextPrompt(textMatch[1]);
			return;
		}

		// ── Question/header line (before options) ────────────────────────────────
		// SF renders a header line or question text above select options.
		// Capture it so we can use it as the TuiPrompt.label when options arrive.
		if (this._looksLikeQuestionHeader(line)) {
			this._lastHeaderText = trimmed;
		}

		// ── Awaiting input → classify as user ──────────────────────────────────
		// After a bare prompt line (e.g. "❯ \n"), the next content line is
		// the user's typed input echoed back by the PTY (without prompt prefix).
		if (this._awaitingInput) {
			this._awaitingInput = false;
			const msg = this._startMessage("user", trimmed);
			this._completeMessage(msg);
			console.debug(
				"[pty-chat-parser] user input detected (post-prompt echo) id=%s source=%s",
				msg.id,
				this._source,
			);
			return;
		}

		// ── Regular content line → assistant ────────────────────────────────────
		if (
			this._activeMessage === null ||
			this._activeMessage.complete ||
			this._activeMessage.role !== "assistant"
		) {
			// Start a new assistant message
			this._activeMessage = this._startMessage("assistant", "");
			console.debug(
				"[pty-chat-parser] role boundary: started assistant msg=%s source=%s",
				this._activeMessage.id,
				this._source,
			);
		}
		this._appendToActive(line + "\n");
	}

	// ── TUI Prompt Handlers ─────────────────────────────────────────────────────

	private _handleSelectOption(
		num: number,
		label: string,
		isSelected: boolean,
	): void {
		const cleanLabel = label.trim();

		if (!this._pendingSelect) {
			// Start a new accumulation block
			this._pendingSelect = {
				label: this._lastHeaderText,
				options: [],
				windowTimer: null,
				firstLineAt: Date.now(),
			};
			// Set window timer — if not enough options arrive, discard
			this._pendingSelect.windowTimer = setTimeout(() => {
				this._commitSelectBlock();
			}, SELECT_WINDOW_MS);
		}

		// Upsert option by its 1-based index
		const block = this._pendingSelect;
		const existing = block.options.find((o) => o.index === num);
		if (existing) {
			existing.label = cleanLabel;
			existing.selected = isSelected;
		} else {
			block.options.push({
				index: num,
				label: cleanLabel,
				selected: isSelected,
			});
		}
	}

	private _handleCheckboxOption(checked: string, label: string): void {
		const isSelected = checked.toLowerCase() === "x";
		// Reuse select option logic — checkboxes map to select with multiple selection
		// For simplicity, we detect checkbox as a variant of select
		this._handleSelectOption(
			this._pendingSelect?.options.length ?? 0 + 1,
			label,
			isSelected,
		);
	}

	private _handlePasswordPrompt(label: string): void {
		// Ensure there's an active assistant message to attach the prompt to
		if (
			!this._activeMessage ||
			this._activeMessage.complete ||
			this._activeMessage.role !== "assistant"
		) {
			this._activeMessage = this._startMessage("assistant", "");
		}
		const prompt: TuiPrompt = {
			kind: "password",
			label: label.trim(),
			options: [],
			selectedIndex: 0,
		};
		this._activeMessage.prompt = prompt;
		this._notify(this._activeMessage);
		console.debug(
			"[pty-chat-parser] tui prompt detected kind=password source=%s",
			this._source,
		);
	}

	private _handleTextPrompt(label: string): void {
		// Ensure there's an active assistant message to attach the prompt to
		if (
			!this._activeMessage ||
			this._activeMessage.complete ||
			this._activeMessage.role !== "assistant"
		) {
			this._activeMessage = this._startMessage("assistant", "");
		}
		const prompt: TuiPrompt = {
			kind: "text",
			label: label.trim(),
			options: [],
			selectedIndex: 0,
		};
		this._activeMessage.prompt = prompt;
		this._notify(this._activeMessage);
		console.debug(
			"[pty-chat-parser] tui prompt detected kind=text label=%s source=%s",
			label.trim(),
			this._source,
		);
	}

	private _commitSelectBlock(): void {
		if (!this._pendingSelect) return;

		const block = this._pendingSelect;
		this._pendingSelect = null;

		if (block.windowTimer) {
			clearTimeout(block.windowTimer);
		}

		if (block.options.length < MIN_SELECT_OPTIONS) {
			// Not enough options — treat as regular content, not a select prompt
			return;
		}

		// Sort options by their 1-based index
		block.options.sort((a, b) => a.index - b.index);

		const selectedOpt = block.options.find((o) => o.selected);
		const selectedIndex = selectedOpt ? block.options.indexOf(selectedOpt) : 0;

		const prompt: TuiPrompt = {
			kind: "select",
			label: block.label,
			options: block.options.map((o) => o.label),
			selectedIndex,
		};

		// Ensure there's an active assistant message to attach the prompt to
		if (
			!this._activeMessage ||
			this._activeMessage.complete ||
			this._activeMessage.role !== "assistant"
		) {
			this._activeMessage = this._startMessage("assistant", "");
		}
		this._activeMessage.prompt = prompt;
		this._notify(this._activeMessage);

		console.debug(
			"[pty-chat-parser] tui prompt detected kind=select options=%d selectedIndex=%d source=%s",
			prompt.options.length,
			selectedIndex,
			this._source,
		);
	}

	/**
	 * Returns true if a stripped line looks like a question/header text that
	 * precedes a select list. Criteria: non-empty, not a system line, not an
	 * option line, and appeared after a bar separator.
	 */
	private _looksLikeQuestionHeader(line: string): boolean {
		const trimmed = line.trim();
		if (trimmed.length === 0) return false;
		if (BAR_LINE_RE.test(trimmed)) return false;
		if (isSystemLine(trimmed)) return false;
		if (SELECT_OPTION_SELECTED_RE.test(line)) return false;
		if (SELECT_OPTION_UNSELECTED_RE.test(line)) return false;
		if (CHECKBOX_SELECTED_RE.test(line)) return false;
		// Only capture as header if we just saw a bar (header text is fresh)
		// — otherwise this rule would capture any assistant content
		return this._lastHeaderText === "" || this._pendingSelect !== null;
	}

	// ── Completion Signal ────────────────────────────────────────────────────────

	/**
	 * Schedule a CompletionSignal to fire after COMPLETION_DEBOUNCE_MS of silence.
	 * Any subsequent PTY input in feed() cancels and resets the timer (see feed()).
	 */
	private _scheduleCompletionSignal(): void {
		if (this._completionTimer) {
			clearTimeout(this._completionTimer);
		}
		this._completionEmitted = false;

		const scheduledAt = Date.now();
		this._completionTimer = setTimeout(() => {
			this._completionTimer = null;
			if (this._completionEmitted) return;

			const elapsed = Date.now() - scheduledAt;
			this._completionEmitted = true;

			const signal: CompletionSignal = {
				source: this._source,
				timestamp: Date.now(),
			};
			console.debug(
				"[pty-chat-parser] completion signal emitted source=%s debounce=%dms",
				this._source,
				elapsed,
			);
			for (const cb of this._completionSubscribers) {
				try {
					cb(signal);
				} catch {
					/* subscriber error */
				}
			}
		}, COMPLETION_DEBOUNCE_MS);

		console.debug(
			"[pty-chat-parser] completion signal scheduled (debounce=%dms) source=%s",
			COMPLETION_DEBOUNCE_MS,
			this._source,
		);
	}

	// ── Message Lifecycle ───────────────────────────────────────────────────────

	private _startMessage(role: MessageRole, content: string): ChatMessage {
		const msg: ChatMessage = {
			id: newId(),
			role,
			content,
			complete: false,
			timestamp: Date.now(),
		};
		this._messages.push(msg);
		this._activeMessage = msg;
		this._notify(msg);
		return msg;
	}

	private _appendToActive(text: string): void {
		if (!this._activeMessage || this._activeMessage.complete) return;
		this._activeMessage.content += text;
		this._notify(this._activeMessage);
	}

	private _completeActive(): void {
		if (!this._activeMessage || this._activeMessage.complete) return;
		this._completeMessage(this._activeMessage);
	}

	private _completeMessage(msg: ChatMessage): void {
		// Trim trailing whitespace from completed messages
		msg.content = msg.content.trimEnd();
		msg.complete = true;
		if (this._activeMessage === msg) this._activeMessage = null;
		this._notify(msg);
		console.debug(
			"[pty-chat-parser] message complete id=%s role=%s source=%s",
			msg.id,
			msg.role,
			this._source,
		);
	}

	private _notify(msg: ChatMessage): void {
		for (const cb of this._subscribers) {
			try {
				cb(msg);
			} catch {
				/* subscriber error */
			}
		}
	}
}
