/** Live PTY attach surface for hosted agent-board rows. */
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
import { createConnection, type Socket } from "node:net";
import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { isProbablyEmptyPiInputLine } from "../core/pty-input.mjs";
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";

export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };

type ThemeLike = {
	fg(color: string, text: string): string;
	bold(text: string): string;
};

export interface PtyAttachOptions {
	socketPath: string;
	screenLogPath?: string;
	title: string;
}

const require = createRequire(import.meta.url);
const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record<string, unknown>) => XtermLike };

const DETACH_KEYS = new Set(["\x1d"]); // ctrl+]
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
const XTSHIFTESCAPE_SELECT = "\x1b[>0s";
const DOUBLE_CLICK_MS = 260;
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
const LOADING_TICK_MS = 120;
/** How long to keep the loading banner after the last resize-jiggle during attach. */
const ATTACH_SETTLE_MS = 250;
/** Hard cap on the attach transition so a silent session can't stall the banner. */
const ATTACH_HARD_TIMEOUT_MS = 2500;
/** How many tail bytes of the screen log to replay on attach. Read from the file tail
 * (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
 * handful of screens, which is all a fresh attach needs. */
const ATTACH_REPLAY_BYTES = 60_000;
const OSC52_PREFIX = "\x1b]52;";
const OSC52_MAX_BYTES = 1_000_000;
const OSC52_CARRY_MAX_BYTES = OSC52_MAX_BYTES + 4096;
const TERMINAL_PASSTHROUGH_MAX_BYTES = 5_000_000;
const TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES = TERMINAL_PASSTHROUGH_MAX_BYTES + 4096;
const KITTY_IMAGE_PREFIX = "\x1b_G";
const ITERM2_FILE_PREFIX = "\x1b]1337;File=";

interface XtermLike {
	write(data: string, cb?: () => void): void;
	resize(cols: number, rows: number): void;
	buffer: {
		active: {
			baseY: number;
			cursorX?: number;
			cursorY?: number;
			length: number;
			getLine(index: number): BufferLineLike | undefined;
			getNullCell(): BufferCellLike;
		};
	};
	_core?: {
		_oscLinkService?: {
			getLinkData?: (id: number) => { uri?: string } | undefined;
			_dataByLinkId?: Map<number, { data?: { uri?: string } }>;
		};
	};
}

interface BufferLineLike {
	length: number;
	getCell(x: number, cell?: BufferCellLike): BufferCellLike | undefined;
	translateToString(trimRight?: boolean): string;
}

interface BufferCellLike {
	getWidth(): number;
	getChars(): string;
	extended?: { urlId?: number; _urlId?: number };
	getFgColor(): number;
	getBgColor(): number;
	isFgRGB(): boolean;
	isBgRGB(): boolean;
	isFgPalette(): boolean;
	isBgPalette(): boolean;
	isFgDefault(): boolean;
	isBgDefault(): boolean;
	isBold(): number;
	isItalic(): number;
	isDim(): number;
	isUnderline(): number;
	isBlink(): number;
	isInverse(): number;
	isInvisible(): number;
	isStrikethrough(): number;
	isOverline(): number;
}

type MousePoint = { line: number; col: number };
type MouseSelection = { anchor: MousePoint; focus: MousePoint };
type NormalizedSelection = { start: MousePoint; end: MousePoint };

export class PtyAttachComponent implements Component {
	private socket: Socket | null = null;
	private connected = false;
	private closed = false;
	private status = "connecting";
	private parserBuffer = "";
	private retryTimer: ReturnType<typeof setTimeout> | null = null;
	private loadingTimer: ReturnType<typeof setInterval> | null = null;
	private mouseRefreshTimers: Array<ReturnType<typeof setTimeout>> = [];
	// Shrink-and-hold jiggle protocol (issue #25): on attach we resize the child to
	// (cols-1, rows-1) and hold it there until the child emits a full clear
	// (\x1b[2J) — any render the child makes while held sees a width delta and
	// must fullRender, so boot-storm coalescing of ±1 pulses can no longer
	// produce a net-zero change. The controller restores the original size on
	// clear / first TUI frame (re-arm) / no-frame 6s guard / budget exhaustion /
	// close / external resize (guards G1-G5, see controller module).
	private readonly jiggleRetry = createJiggleRetryController({
		sendResize: (cols, rows) => this.sendResize(cols, rows),
		setTimeoutFn: (fn, ms) => {
			const t = setTimeout(fn, ms);
			t.unref?.();
			return t;
		},
		clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
	});
	private osc52Carry = "";
	private passthroughCarry = "";
	private readonly connectStartedAt = Date.now();
	// Lines scrolled per mouse-wheel event; configurable via $AGENT_BOARD_WHEEL_LINES.
	private readonly mouseWheelLines = resolveWheelLines();
	private readonly term: XtermLike;
	private cols = 120;
	private rows = 24;
	// Absolute buffer line shown at the top of the viewport. null means follow bottom.
	private viewportTop: number | null = null;
	private selection: MouseSelection | null = null;
	private selectionDragging = false;
	private selectionAutoScrollTimer: ReturnType<typeof setInterval> | null = null;
	private selectionAutoScrollLines = 0;
	private selectionAutoScrollMouse: { row: number; col: number } | null = null;
	private pendingClickTimer: ReturnType<typeof setTimeout> | null = null;
	private lastClickPoint: MousePoint | null = null;
	private lastClickAt = 0;
	// Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
	// loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
	// the previous screen visible.
	private receivedOutput = false;
	// Attach transition: hold the loading banner until the screen-log replay and the
	// initial resize-jiggle redraws settle, so attach never visibly scrolls/flashes the
	// buffer. `receivedOutput` tracks buffer content; `attaching` gates whether we paint it.
	private attaching = true;
	private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
	private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
	// Force a single full-clear on the first paint so the prior session/dashboard can't
	// ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
	// differential renderer so wheel/output bursts don't each trigger a full repaint.
	private firstPaint = true;
	// Coalesce live PTY output repaints to ~25fps. node-pty splits one child-TUI update
	// into many small chunks; painting after each chunk would drive the outer TUI to its
	// frame cap and expose intermediate frames (visible as flicker on a busy session).
	private readonly outputRenderScheduler = createAttachOutputRenderScheduler(() => this.scheduleRender());
	// Fold pi-tui's post-frame cursor-park writes back into the frame's sync block so the
	// terminal reports one stable IME cursor rect per frame instead of two (issue #28).
	// Never active when AGENT_BOARD_IME_FIX=0; no-op passthrough if pi-tui changes shape.
	private readonly imeCoalesceUninstall: (() => void) | null;

	constructor(
		private readonly tui: TUI,
		private readonly theme: ThemeLike,
		_keybindings: KeybindingsManager,
		private readonly done: (result: PtyAttachResult) => void,
		private readonly opts: PtyAttachOptions,
	) {
		const size = this.currentSize();
		this.cols = size.cols;
		this.rows = size.rows;
		// Assigned in the body (not as a field initializer) so it runs after the `tui`
		// parameter property is set regardless of the TS loader's field-init semantics.
		this.imeCoalesceUninstall = installImeCursorCoalesce(this.tui);
		this.term = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true });
		// Keep mouse reporting enabled by default so wheel scrolling and local drag-to-copy
		// selection can coexist inside the attach surface. Set AGENT_BOARD_ATTACH_MOUSE=0
		// to fall back to terminal-native selection only.
		this.disableMouseScroll();
		this.enableMouseScroll();
		this.refreshMouseScrollMode();
		this.replayScreenLog();
		this.connect();
		this.startLoadingTicker();
		// Paint immediately (forced once) so the loading banner replaces the previous
		// surface the instant we attach, rather than after the first reconnect tick.
		this.scheduleRender();
	}

	handleInput(data: string): void {
		const mouseInput = parseMouseInputChunk(data);
		if (mouseInput && this.handleMouseInputChunk(mouseInput)) return;
		if (matchesKey(data, Key.pageUp)) {
			this.clearPendingClick();
			this.clearSelection();
			if (this.tryScrollBy(this.pageSize())) return;
			this.send({ type: "input", data });
			return;
		}
		if (matchesKey(data, Key.pageDown)) {
			this.clearPendingClick();
			this.clearSelection();
			if (this.tryScrollBy(-this.pageSize())) return;
			this.send({ type: "input", data });
			return;
		}
		if (matchesKey(data, Key.home)) {
			this.clearPendingClick();
			this.clearSelection();
			if (this.tryScrollToTop()) return;
			this.send({ type: "input", data });
			return;
		}
		if (matchesKey(data, Key.end)) {
			this.clearPendingClick();
			this.clearSelection();
			if (this.tryScrollToBottom()) return;
			this.send({ type: "input", data });
			return;
		}
		if (
			DETACH_KEYS.has(data) ||
			matchesKey(data, Key.left) ||
			matchesKey(data, Key.ctrl("]"))
		) {
			if (this.childInputLooksEmpty()) {
				this.detach();
				return;
			}
			this.clearPendingClick();
			this.clearSelection();
			this.scrollToBottom();
			this.send({ type: "input", data });
			return;
		}
		this.clearPendingClick();
		this.clearSelection();
		this.scrollToBottom();
		this.send({ type: "input", data });
	}

	render(width: number): string[] {
		this.resizeIfNeeded(width);
		const height = this.tui.terminal?.rows ?? 24;
		const bodyHeight = Math.max(1, height - 2);
		let body: string[];
		if (!this.attaching && this.receivedOutput) {
			const projected = this.project(bodyHeight, width);
			body = projected.lines;
			while (body.length < bodyHeight) body.unshift("");
		} else {
			body = this.renderLoading(bodyHeight, width);
		}
		const header =
			this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
			this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach · ctrl+] detach`);
		return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
	}

	/** Centered "loading" surface shown until the first PTY output paints the session. */
	private renderLoading(height: number, width: number): string[] {
		const elapsedMs = Date.now() - this.connectStartedAt;
		const spinner = this.closed ? "·" : SPINNER[Math.floor(elapsedMs / LOADING_TICK_MS) % SPINNER.length];
		const title = `${spinner} Loading "${this.opts.title}"…`;
		const detail = this.loadingDetail(Math.max(0, Math.round(elapsedMs / 1000)));
		const out: string[] = [];
		const top = Math.max(0, Math.floor((height - 3) / 2));
		for (let i = 0; i < top; i++) out.push("");
		out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
		out.push(center(this.theme.fg("muted", detail), width));
		out.push("");
		out.push(center(this.theme.fg("dim", "← or ctrl+] to detach"), width));
		while (out.length < height) out.push("");
		return out.slice(0, height);
	}

	private loadingDetail(elapsedSeconds: number): string {
		if (this.status === "attached") return "Attached · waiting for the session to render…";
		if (this.status.startsWith("error") || this.status === "host exited") return this.status;
		if (this.status === "disconnected") return `Reconnecting to the session host… ${elapsedSeconds}s`;
		return `Starting the session host… ${elapsedSeconds}s`;
	}

	invalidate(): void {}

	dispose(): void {
		this.close();
	}

	private detach(): void {
		this.send({ type: "detach" });
		this.close();
		this.done({ action: "detached" });
	}

	private childInputLooksEmpty(): boolean {
		if (!this.receivedOutput) return true;
		const active = this.term.buffer.active;
		if (typeof active.cursorY !== "number") return false;
		const line = active.getLine(active.baseY + active.cursorY)?.translateToString(true) ?? "";
		return isProbablyEmptyPiInputLine(line);
	}

	private connect(): void {
		if (this.closed || this.connected || this.socket) return;
		if (!existsSync(this.opts.socketPath)) {
			this.status = `starting host… ${Math.ceil((Date.now() - this.connectStartedAt) / 1000)}s`;
			this.scheduleReconnect();
			return;
		}
		const socket = createConnection(this.opts.socketPath);
		this.socket = socket;
		socket.on("connect", () => {
			this.clearRetry();
			this.connected = true;
			this.status = "attached";
			this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
			this.jiggleRetry.start(this.cols, this.rows);
			this.enableMouseScroll();
			this.scheduleRender();
			this.startAttachSettle();
		});
		socket.on("data", (chunk) => this.onSocketData(chunk.toString("utf8")));
		socket.on("close", () => {
			this.socket = null;
			this.connected = false;
			if (!this.closed && this.status !== "host exited") {
				this.status = "disconnected";
				this.scheduleReconnect();
			}
			if (!this.closed) this.scheduleRender();
		});
		socket.on("error", (err) => {
			this.socket = null;
			this.connected = false;
			if (this.closed) return;
			this.status = `waiting for host… ${err.message}`;
			this.scheduleReconnect();
			this.scheduleRender();
		});
	}

	/**
	 * Request a repaint. The first paint is forced (clears any prior screen so the previous
	 * session can't ghost behind us); all later paints are coalesced + throttled + differential
	 * by the TUI, so bursts of wheel/output events don't each clear-and-repaint the whole screen.
	 */
	private scheduleRender(force = false): void {
		const next = nextAttachRender(this.firstPaint, force);
		this.firstPaint = next.firstPaint;
		this.tui.requestRender(next.force);
	}

	private scheduleReconnect(): void {
		if (this.closed || this.retryTimer) return;
		this.retryTimer = setTimeout(() => {
			this.retryTimer = null;
			this.connect();
			this.scheduleRender();
		}, 150);
	}

	/** Animate the loading banner until the first PTY output arrives (or we close). */
	private startLoadingTicker(): void {
		if (this.loadingTimer || !this.attaching || this.closed) return;
		this.loadingTimer = setInterval(() => {
			if (this.closed || !this.attaching) return this.stopLoadingTicker();
			this.tui.requestRender();
		}, LOADING_TICK_MS);
		this.loadingTimer.unref?.();
	}

	private stopLoadingTicker(): void {
		if (!this.loadingTimer) return;
		clearInterval(this.loadingTimer);
		this.loadingTimer = null;
	}

	/**
	 * Attach transition lifecycle. Keep the loading banner up while the screen-log replay
	 * and the initial resize-jiggle redraws settle, so the buffer doesn't visibly scroll or
	 * flash through the viewport on attach. Each output chunk defers the settle
	 * window; a hard timeout guards against a session that never produces output.
	 */
	private startAttachSettle(): void {
		if (!this.attaching) return;
		this.deferAttachSettle();
		if (!this.attachHardTimeout) {
			this.attachHardTimeout = setTimeout(() => this.finishAttachTransition(), ATTACH_HARD_TIMEOUT_MS);
			this.attachHardTimeout.unref?.();
		}
	}

	private deferAttachSettle(): void {
		if (!this.attaching) return;
		if (this.attachSettleTimer) clearTimeout(this.attachSettleTimer);
		this.attachSettleTimer = setTimeout(() => this.finishAttachTransition(), ATTACH_SETTLE_MS);
		this.attachSettleTimer.unref?.();
	}

	private finishAttachTransition(): void {
		if (!this.attaching) return;
		// Note: jiggle retry chain is NOT cancelled here — it survives the settle
		// transition because any successful jiggle triggers a fullRender which emits
		// \x1b[2J, self-cancelling the chain. Post-settle jiggles only occur when the
		// child consumed none of the earlier ones (the exact failure mode this
		// feature fixes); in that case the screen is already stale, so the trade-off
		// of a brief full-render flicker (which also clears any in-progress text
		// selection) is acceptable in exchange for self-healing. When the child is
		// healthy, the very first jiggle's fullRender is detected and the chain
		// stops before settle ends, so no post-settle jiggle fires at all.
		this.attaching = false;
		if (this.attachSettleTimer) {
			clearTimeout(this.attachSettleTimer);
			this.attachSettleTimer = null;
		}
		if (this.attachHardTimeout) {
			clearTimeout(this.attachHardTimeout);
			this.attachHardTimeout = null;
		}
		this.stopLoadingTicker();
		// Force a full clear so the loading banner is replaced atomically by the settled
		// buffer, instead of diffing banner lines into buffer lines.
		this.scheduleRender(true);
	}

	private clearMouseRefreshTimers(): void {
		for (const timer of this.mouseRefreshTimers) clearTimeout(timer);
		this.mouseRefreshTimers = [];
	}

	private clearRetry(): void {
		if (!this.retryTimer) return;
		clearTimeout(this.retryTimer);
		this.retryTimer = null;
	}

	private enableMouseScroll(): void {
		if (!this.mouseScrollEnabled()) return;
		try {
			this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
			this.tui.terminal.write(MOUSE_ENABLE);
		} catch {}
	}

	private mouseScrollEnabled(): boolean {
		const mode = (process.env.AGENT_BOARD_ATTACH_MOUSE ?? "").trim().toLowerCase();
		if (mode === "0" || mode === "off" || mode === "false") return false;
		if (mode === "1" || mode === "on" || mode === "true" || mode === "classic") return true;
		if ((process.env.AGENT_BOARD_ENABLE_MOUSE_SCROLL ?? "").trim() === "0") return false;
		return true;
	}

	private refreshMouseScrollMode(): void {
		this.clearMouseRefreshTimers();
		if (!this.mouseScrollEnabled()) return;
		for (const delay of [0, 50, 250]) {
			const timer = setTimeout(() => {
				if (!this.closed) this.enableMouseScroll();
			}, delay);
			timer.unref?.();
			this.mouseRefreshTimers.push(timer);
		}
	}

	private disableMouseScroll(): void {
		try {
			this.tui.terminal.write(MOUSE_DISABLE);
		} catch {}
	}

	private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean {
		let handled = false;
		for (const entry of events) {
			const { mouse, raw } = entry;
			if ((mouse.button & 64) !== 0) {
				handled = true;
				const wheelButton = mouse.button & 3;
				const wheel = wheelButton === 0 ? 1 : wheelButton === 1 ? -1 : 0;
				if (wheel === 0) continue;
				this.clearPendingClick();
				this.clearSelection();
				if (!this.tryScrollBy(wheel * this.mouseWheelLines)) this.send({ type: "input", data: raw });
				continue;
			}
			handled = true;
			this.handleLocalMouseEvent(mouse);
		}
		return handled;
	}

	private updateSelectionAutoScroll(row: number, col: number): void {
		this.selectionAutoScrollMouse = { row, col };
		if (!this.selection || !this.selectionDragging) return this.clearSelectionAutoScroll(false);
		const lines = selectionDragScrollLines(row, this.bodyHeight());
		if (lines === 0) return this.clearSelectionAutoScroll(false);
		if (this.selectionAutoScrollTimer && this.selectionAutoScrollLines === lines) return;
		this.clearSelectionAutoScroll(false);
		this.selectionAutoScrollLines = lines;
		this.selectionAutoScrollTimer = setInterval(() => this.tickSelectionAutoScroll(), 50);
		this.selectionAutoScrollTimer.unref?.();
	}

	private tickSelectionAutoScroll(): void {
		if (this.closed || !this.selection || !this.selectionDragging || !this.selectionAutoScrollMouse || !this.selectionAutoScrollLines) {
			this.clearSelectionAutoScroll();
			return;
		}
		const changed = this.tryScrollBy(this.selectionAutoScrollLines);
		const point = this.mousePointForEvent(this.selectionAutoScrollMouse.row, this.selectionAutoScrollMouse.col, true);
		if (point) this.selection.focus = point;
		if (!changed) this.clearSelectionAutoScroll(false);
	}

	private clearSelectionAutoScroll(clearPointer = true): void {
		if (this.selectionAutoScrollTimer) {
			clearInterval(this.selectionAutoScrollTimer);
			this.selectionAutoScrollTimer = null;
		}
		this.selectionAutoScrollLines = 0;
		if (clearPointer) this.selectionAutoScrollMouse = null;
	}

	private handleLocalMouseEvent(mouse: { button: number; row: number; col: number; action: string }): void {
		const primary = (mouse.button & 3) === 0;
		const middleButton = (mouse.button & 3) === 1;
		if (mouse.action === "press") {
			this.clearSelectionAutoScroll();
			if (middleButton) {
				// Middle-click paste: forward the X11 PRIMARY selection to the hosted
				// session as input, mimicking the terminal-native middle-click paste
				// that mouse reporting (kept on for wheel scrolling) would swallow.
				this.pastePrimarySelection();
				this.clearPendingClick();
				this.clearSelection();
				return;
			}
			if (!primary) {
				this.clearPendingClick();
				this.clearSelection();
				return;
			}
			const point = this.mousePointForEvent(mouse.row, mouse.col, false);
			if (!point) {
				this.clearPendingClick();
				this.clearSelection();
				return;
			}
			if (this.isDoubleClickCandidate(point)) this.clearPendingClick();
			this.selection = { anchor: point, focus: point };
			this.selectionDragging = false;
			this.scheduleRender();
			return;
		}
		if (!this.selection) return;
		if (mouse.action === "move") {
			if (!primary) return;
			const point = this.mousePointForEvent(mouse.row, mouse.col, true);
			if (!point) return;
			this.selection.focus = point;
			this.selectionDragging = true;
			this.updateSelectionAutoScroll(mouse.row, mouse.col);
			this.scheduleRender();
			return;
		}
		if (mouse.action === "release") {
			this.clearSelectionAutoScroll();
			const point = this.mousePointForEvent(mouse.row, mouse.col, true);
			if (point) this.selection.focus = point;
			const shouldCopy = this.selectionDragging;
			this.selectionDragging = false;
			if (shouldCopy) {
				this.clearPendingClick();
				this.lastClickPoint = null;
				this.lastClickAt = 0;
				this.copySelectionToClipboard();
				this.scheduleRender();
				return;
			}
			if (!point) {
				this.clearPendingClick();
				this.clearSelection();
				return;
			}
			if (this.isDoubleClickCandidate(point)) {
				this.clearPendingClick();
				this.lastClickPoint = null;
				this.lastClickAt = 0;
				if (!this.selectWordAtPoint(point)) this.selection = null;
				this.scheduleRender();
				return;
			}
			this.lastClickPoint = point;
			this.lastClickAt = Date.now();
			this.schedulePendingClick(point);
		}
	}

	private mousePointForEvent(row: number, col: number, clampToBody: boolean): MousePoint | null {
		const height = this.bodyHeight();
		const bodyRow = row - 2;
		if (!clampToBody && (bodyRow < 0 || bodyRow >= height)) return null;
		const clampedRow = clampInt(bodyRow, 0, Math.max(0, height - 1));
		this.clampViewportTop(height);
		const start = this.viewportTop ?? this.bottomViewportTop(height);
		const line = clampInt(start + clampedRow, 0, Math.max(0, this.term.buffer.active.length - 1));
		const cellCol = clampInt(col - 1, 0, Math.max(0, this.cols - 1));
		return { line, col: cellCol };
	}

	private isDoubleClickCandidate(point: MousePoint): boolean {
		return !!this.lastClickPoint && Date.now() - this.lastClickAt <= DOUBLE_CLICK_MS && sameMousePoint(this.lastClickPoint, point);
	}

	private schedulePendingClick(point: MousePoint): void {
		this.clearPendingClick();
		this.pendingClickTimer = setTimeout(() => {
			this.pendingClickTimer = null;
			this.lastClickPoint = null;
			this.lastClickAt = 0;
			if (this.closed) return;
			this.openLinkAtPoint(point);
			this.selection = null;
			this.selectionDragging = false;
			this.tui.requestRender();
		}, DOUBLE_CLICK_MS);
		this.pendingClickTimer.unref?.();
	}

	private clearPendingClick(): void {
		if (!this.pendingClickTimer) return;
		clearTimeout(this.pendingClickTimer);
		this.pendingClickTimer = null;
	}

	private selectWordAtPoint(point: MousePoint): boolean {
		const buf = this.term.buffer.active;
		const line = buf.getLine(point.line);
		if (!line) return false;
		const reusable = buf.getNullCell();
		const range = findWordRangeAtCells(asciiCellsForBufferLine(line, reusable), point.col);
		if (!range) return false;
		this.selection = {
			anchor: { line: point.line, col: range.start },
			focus: { line: point.line, col: range.end },
		};
		this.selectionDragging = false;
		this.copySelectionToClipboard();
		return true;
	}

	private openLinkAtPoint(point: MousePoint): boolean {
		const target = this.linkAtPoint(point);
		return target ? openExternalTarget(target) : false;
	}

	private linkAtPoint(point: MousePoint): string | null {
		const buf = this.term.buffer.active;
		const line = buf.getLine(point.line);
		if (!line) return null;
		const reusable = buf.getNullCell();
		const cell = line.getCell(point.col, reusable);
		const osc8 = cell ? osc8UriForCell(this.term, cell) : "";
		if (osc8) return osc8;
		return findHttpUrlAtCells(asciiCellsForBufferLine(line, reusable), point.col);
	}

	private clearSelection(): void {
		this.clearSelectionAutoScroll();
		if (!this.selection && !this.selectionDragging) return;
		this.selection = null;
		this.selectionDragging = false;
		this.scheduleRender();
	}

	private copySelectionToClipboard(): void {
		const text = this.selectionText();
		if (!text) return;
		const seq = osc52CopySequence(text);
		if (seq) {
			try {
				this.tui.terminal.write(seq);
			} catch {}
		}
		// Also mirror the selection into the X11 PRIMARY selection so the rest of the
		// desktop can middle-click-paste it — closes the loop with pastePrimarySelection().
		this.writePrimarySelection(text);
	}

	/**
	 * Middle-click paste. Reads the X11 PRIMARY selection via xclip and forwards it to
	 * the hosted session as input, mimicking the terminal-native middle-click paste that
	 * mouse reporting (kept on for wheel scrolling) would otherwise swallow. Silent no-op
	 * when xclip is absent or AGENT_BOARD_ATTACH_NATIVE_PASTE=0.
	 */
	private pastePrimarySelection(): void {
		if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
		try {
			const child = spawn("xclip", ["-o", "-selection", "primary"], { stdio: ["ignore", "pipe", "ignore"] });
			let out = "";
			const timer = setTimeout(() => {
				try {
					child.kill("SIGKILL");
				} catch {}
			}, 800);
			child.stdout?.on("data", (chunk: Buffer) => {
				out += chunk.toString("utf8");
			});
			child.on("error", () => clearTimeout(timer));
			child.on("close", () => {
				clearTimeout(timer);
				if (!this.closed && out) this.send({ type: "input", data: out });
			});
		} catch {}
	}

	/** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
	private writePrimarySelection(text: string): void {
		if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
		try {
			const child = spawn("xclip", ["-selection", "primary"], { stdio: ["pipe", "ignore", "ignore"] });
			child.stdin?.on("error", () => {});
			child.on("error", () => {});
			child.stdin?.end(text);
		} catch {}
	}

	private selectionText(): string {
		const range = normalizeSelection(this.selection);
		if (!range) return "";
		const buf = this.term.buffer.active;
		const reusable = buf.getNullCell();
		const parts: string[] = [];
		for (let lineIndex = range.start.line; lineIndex <= range.end.line; lineIndex++) {
			const line = buf.getLine(lineIndex);
			if (!line) {
				parts.push("");
				continue;
			}
			const from = lineIndex === range.start.line ? range.start.col : 0;
			const to = lineIndex === range.end.line ? range.end.col : line.length - 1;
			let text = "";
			for (let x = Math.max(0, from); x <= Math.max(from, to); x++) {
				const cell = line.getCell(x, reusable);
				if (!cell || cell.getWidth() === 0) continue;
				text += cell.getChars() || " ";
			}
			parts.push(text.replace(/\s+$/u, ""));
		}
		return parts.join("\n").replace(/^\n+|\n+$/gu, "");
	}

	private currentSize(): { cols: number; rows: number } {
		const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined;
		return {
			cols: Math.max(20, term?.cols ?? term?.columns ?? 120),
			rows: Math.max(5, (term?.rows ?? 24) - 2),
		};
	}

	private resizeIfNeeded(width: number): void {
		const size = this.currentSize();
		// Render width is authoritative inside ctx.ui.custom; terminal.cols is not
		// consistently exposed by all Pi TUI versions.
		size.cols = Math.max(20, width);
		if (size.cols === this.cols && size.rows === this.rows) return;
		this.cols = size.cols;
		this.rows = size.rows;
		// A real terminal resize supersedes the hold protocol: cancel any armed
		// hold and adopt the new size as the baseline (guard G4, issue #25).
		this.jiggleRetry.notifyExternalResize(size.cols, size.rows);
		this.term.resize(this.cols, this.rows);
		this.sendResize();
		this.enableMouseScroll();
	}

	private sendResize(cols = this.cols, rows = this.rows): void {
		this.send({ type: "resize", cols, rows });
		this.clampViewportTop(this.bodyHeight());
	}

	/** Feed socket output into the jiggle retry controller (clear/frame detection). */
	private checkClearSequence(data: string): void {
		this.jiggleRetry.feed(data);
	}

	private tryScrollBy(linesUp: number): boolean {
		const result = scrollViewportTop(this.viewportTop, this.bottomViewportTop(this.bodyHeight()), linesUp);
		this.viewportTop = result.viewportTop;
		if (result.changed) this.requestScrollRender();
		return result.changed;
	}

	private tryScrollToTop(): boolean {
		if (this.bottomViewportTop(this.bodyHeight()) <= 0 || this.viewportTop === 0) return false;
		this.viewportTop = 0;
		this.requestScrollRender();
		return true;
	}

	private tryScrollToBottom(): boolean {
		if (this.viewportTop === null) return false;
		this.viewportTop = null;
		this.requestScrollRender();
		return true;
	}

	private scrollToBottom(): void {
		this.viewportTop = null;
		this.requestScrollRender();
	}

	private requestScrollRender(): void {
		this.scheduleRender();
	}

	private bodyHeight(): number {
		return Math.max(1, (this.tui.terminal?.rows ?? 24) - 2);
	}

	private pageSize(): number {
		return Math.max(1, this.bodyHeight() - 2);
	}

	private bottomViewportTop(height: number): number {
		const buf = this.term.buffer.active;
		const bottom = Math.min(buf.length, buf.baseY + this.rows);
		return Math.max(0, bottom - height);
	}

	private clampViewportTop(height: number): void {
		if (this.viewportTop === null) return;
		this.viewportTop = clampInt(this.viewportTop, 0, this.bottomViewportTop(height));
	}

	private send(msg: Record<string, unknown>): void {
		if (!this.socket || !this.connected) return;
		this.socket.write(JSON.stringify(msg) + "\n");
	}

	private onSocketData(text: string): void {
		this.parserBuffer += text;
		const lines = this.parserBuffer.split("\n");
		this.parserBuffer = lines.pop() ?? "";
		let needsRender = false;
		for (const line of lines) {
			if (!line.trim()) continue;
			try {
				const msg = JSON.parse(line);
				if (msg.type === "output" && typeof msg.data === "string") {
					this.pushOutput(msg.data, { forwardProtocols: true });
					this.checkClearSequence(msg.data);
					continue;
				}
				if (msg.type === "hello" || msg.type === "status") this.status = "attached";
				else if (msg.type === "exit") {
					this.status = "host exited";
					this.done({ action: "closed", exitCode: msg.exitCode ?? null });
				} else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
				if (shouldScheduleAttachRenderForMessage(msg.type)) needsRender = true;
			} catch {
				// Ignore malformed protocol lines; raw PTY data is only legal inside output.data.
			}
		}
		if (needsRender) this.scheduleRender();
	}

	private forwardTerminalProtocols(data: string): void {
		const toWrite: string[] = [];
		if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") {
			const { sequences, carry } = extractOsc52Sequences(this.osc52Carry + data);
			this.osc52Carry = carry;
			toWrite.push(...sequences);
		}
		if (process.env.AGENT_BOARD_FORWARD_IMAGES !== "0") {
			const { sequences, carry } = extractTerminalPassthroughSequences(this.passthroughCarry + data);
			this.passthroughCarry = carry;
			toWrite.push(...sequences);
		}
		for (const seq of toWrite) {
			try {
				this.tui.terminal.write(seq);
			} catch {}
		}
	}

	private replayScreenLog(): void {
		if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return;
		try {
			// Read only the tail of the log instead of the whole file: large logs (tens of MB)
			// would otherwise block the attach on a full readFileSync + UTF-8 decode before the
			// first frame. Skip a leading partial line so we don't inject a half escape sequence.
			const { size } = statSync(this.opts.screenLogPath);
			const start = Math.max(0, size - ATTACH_REPLAY_BYTES);
			const length = size - start;
			if (length <= 0) return;
			const fd = openSync(this.opts.screenLogPath, "r");
			try {
				const buf = Buffer.alloc(length);
				readSync(fd, buf, 0, length, start);
				let tail = buf.toString("utf8");
				if (start > 0) {
					const nl = tail.indexOf("\n");
					tail = nl >= 0 ? tail.slice(nl + 1) : tail;
				}
				this.pushOutput(tail);
			} finally {
				closeSync(fd);
			}
		} catch {}
	}

	private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
		if (data.length === 0) return;
		if (opts.forwardProtocols) this.forwardTerminalProtocols(data);
		// @xterm/headless parses asynchronously; the buffer is only populated once this
		// callback fires. Mark the buffer as ready (so the project path can paint it), but
		// stay on the loading banner while `attaching` — otherwise the screen-log replay and
		// the initial resize-jiggle redraws would flash through the viewport. Each parsed
		// chunk also defers the settle window, so the banner holds until output actually
		// stops arriving (i.e. the redraw finished), not just until the resize jiggle ends.
		this.term.write(data, () => {
			this.receivedOutput = true;
			this.deferAttachSettle();
			this.outputRenderScheduler.request();
		});
	}

	private project(height: number, width: number): { lines: string[]; cursor: { row: number; col: number } | null } {
		const out: string[] = [];
		const buf = this.term.buffer.active;
		const selection = normalizeSelection(this.selection);
		this.clampViewportTop(height);
		const start = this.viewportTop ?? this.bottomViewportTop(height);
		const end = Math.min(buf.length, start + height);
		const reusable = buf.getNullCell();
		// The PTY cursor: xterm buffer cursorY is relative to baseY (the viewport top of
		// the child terminal); cursorX may equal cols (one past the last cell). Only render
		// it when it lands inside the projected viewport.
		const cursor = projectPtyCursor(buf, start, height);
		for (let i = start; i < end; i++) {
			out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor));
		}
		if (out.length === 0) out.push("Waiting for PTY output…");
		return { lines: out.slice(-height), cursor };
	}

	private close(): void {
		this.closed = true;
		this.imeCoalesceUninstall?.();
		this.jiggleRetry.restoreAndStop();
		this.disableMouseScroll();
		this.clearMouseRefreshTimers();
		this.clearPendingClick();
		this.clearSelectionAutoScroll();
		this.clearRetry();
		this.stopLoadingTicker();
		this.outputRenderScheduler.dispose();
		if (this.attachSettleTimer) {
			clearTimeout(this.attachSettleTimer);
			this.attachSettleTimer = null;
		}
		if (this.attachHardTimeout) {
			clearTimeout(this.attachHardTimeout);
			this.attachHardTimeout = null;
		}
		try {
			this.socket?.destroy();
		} catch {}
		this.socket = null;
		this.connected = false;
	}
}

function sameMousePoint(a: MousePoint, b: MousePoint): boolean {
	return a.line === b.line && Math.abs(a.col - b.col) <= 1;
}

function asciiCellsForBufferLine(line: BufferLineLike, reusable: BufferCellLike): string[] {
	const cells: string[] = [];
	for (let x = 0; x < line.length; x++) {
		const cell = line.getCell(x, reusable);
		if (!cell || cell.getWidth() === 0) {
			cells.push(" ");
			continue;
		}
		const chars = cell.getChars() || " ";
		cells.push(chars.length === 1 && chars >= " " && chars <= "~" ? chars : " ");
	}
	return cells;
}

function openExternalTarget(target: string): boolean {
	const sanitized = sanitizeOscPayload(target).trim();
	if (!sanitized) return false;
	try {
		if (process.platform === "darwin") {
			spawn("open", [sanitized], { detached: true, stdio: "ignore" }).unref();
			return true;
		}
		if (process.platform === "win32") {
			spawn("cmd", ["/c", "start", "", sanitized], { detached: true, stdio: "ignore" }).unref();
			return true;
		}
		spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" }).unref();
		return true;
	} catch {
		return false;
	}
}

function compareMousePoints(a: MousePoint, b: MousePoint): number {
	return a.line === b.line ? a.col - b.col : a.line - b.line;
}

function normalizeSelection(selection: MouseSelection | null): NormalizedSelection | null {
	if (!selection) return null;
	return compareMousePoints(selection.anchor, selection.focus) <= 0
		? { start: selection.anchor, end: selection.focus }
		: { start: selection.focus, end: selection.anchor };
}

function pointWithinSelection(line: number, col: number, selection: NormalizedSelection | null): boolean {
	if (!selection) return false;
	if (line < selection.start.line || line > selection.end.line) return false;
	if (selection.start.line === selection.end.line) return col >= selection.start.col && col <= selection.end.col;
	if (line === selection.start.line) return col >= selection.start.col;
	if (line === selection.end.line) return col <= selection.end.col;
	return true;
}

function osc52CopySequence(text: string): string {
	if (!text) return "";
	const data = Buffer.from(text, "utf8").toString("base64");
	const seq = `\x1b]52;c;${data}\x07`;
	return Buffer.byteLength(seq, "utf8") <= OSC52_MAX_BYTES ? seq : "";
}

function lineToAnsi(
	line: BufferLineLike | undefined,
	reusable: BufferCellLike,
	term: XtermLike,
	lineIndex: number,
	selection: NormalizedSelection | null,
	cursor: { row: number; col: number } | null,
): string {
	const isCursorRow = cursor !== null && cursor.row === lineIndex;
	let last = -1;
	if (!line) {
		// No buffer line: show the cursor as an inverse block at the start of the line.
		if (isCursorRow && cursor!.col >= 0) return CURSOR_MARKER + "\x1b[7m \x1b[0m";
		return "";
	}
	for (let x = 0; x < line.length; x++) {
		const cell = line.getCell(x, reusable);
		if (!cell || cell.getWidth() === 0) continue;
		if (cell.getChars()) last = x;
	}
	if (last < 0) {
		// Empty line: show the cursor as a full inverse block at the start of the line
		// (or as an inverse space when it sits past the end of the content).
		if (isCursorRow && cursor!.col >= 0) return CURSOR_MARKER + "\x1b[7m \x1b[0m";
		return "";
	}

	let out = "";
	let prevAttr = "";
	let prevUri = "";
	for (let x = 0; x <= last; x++) {
		const cell = line.getCell(x, reusable);
		if (!cell || cell.getWidth() === 0) continue;
		const uri = osc8UriForCell(term, cell);
		if (uri !== prevUri) {
			if (prevUri) out += closeOsc8();
			if (uri) out += openOsc8(uri);
			prevUri = uri;
		}
		const selected = pointWithinSelection(lineIndex, x, selection);
		// The PTY cursor renders as a solid inverse block so it stays visible even though
		// the outer TUI hides the hardware cursor by default. The zero-width CURSOR_MARKER
		// (stripped by the TUI) additionally positions the hardware cursor for IME and
		// PI_HARDWARE_CURSOR=1 terminals; truncateToWidth keeps or drops it with the cell.
		const isCursor = isCursorRow && x === cursor!.col;
		if (isCursor) out += CURSOR_MARKER;
		const key = attrKey(cell, selected, isCursor);
		if (key !== prevAttr) {
			out += attrsToAnsi(cell, selected, isCursor);
			prevAttr = key;
		}
		out += cell.getChars() || " ";
	}
	if (prevUri) out += closeOsc8();
	// Cursor past the end of the line content (cursorX == cols or beyond last cell):
	// append an inverse space so the position is still visible.
	if (isCursorRow && cursor!.col > last) {
		out += CURSOR_MARKER + "\x1b[7m \x1b[0m";
	}
	return out + "\x1b[0m";
}

function attrKey(cell: BufferCellLike, selected = false, isCursor = false): string {
	return [
		selected ? 1 : 0,
		isCursor ? 1 : 0,
		cell.isBold(),
		cell.isDim(),
		cell.isItalic(),
		cell.isUnderline(),
		cell.isBlink(),
		cell.isInverse(),
		cell.isInvisible(),
		cell.isStrikethrough(),
		cell.isOverline(),
		cell.isFgRGB(),
		cell.isFgPalette(),
		cell.getFgColor(),
		cell.isBgRGB(),
		cell.isBgPalette(),
		cell.getBgColor(),
	].join(";");
}

function attrsToAnsi(cell: BufferCellLike, selected = false, isCursor = false): string {
	const codes: string[] = ["0"];
	if (cell.isBold()) codes.push("1");
	if (cell.isDim()) codes.push("2");
	if (cell.isItalic()) codes.push("3");
	if (cell.isUnderline()) codes.push("4");
	if (cell.isBlink()) codes.push("5");
	if (cell.isInverse() || selected || isCursor) codes.push("7");
	if (cell.isInvisible()) codes.push("8");
	if (cell.isStrikethrough()) codes.push("9");
	if (cell.isOverline()) codes.push("53");
	codes.push(...colorCodes(cell, "fg"));
	codes.push(...colorCodes(cell, "bg"));
	return `\x1b[${codes.join(";")}m`;
}

function colorCodes(cell: BufferCellLike, kind: "fg" | "bg"): string[] {
	const isFg = kind === "fg";
	const color = isFg ? cell.getFgColor() : cell.getBgColor();
	if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {
		return [isFg ? "38" : "48", "2", String((color >> 16) & 255), String((color >> 8) & 255), String(color & 255)];
	}
	if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
		if (color >= 0 && color <= 7) return [String((isFg ? 30 : 40) + color)];
		if (color >= 8 && color <= 15) return [String((isFg ? 90 : 100) + color - 8)];
		return [isFg ? "38" : "48", "5", String(color)];
	}
	return [isFg ? "39" : "49"];
}

function clip(line: string, width: number): string {
	return truncateToWidth(line, width, "");
}

function clipTerminalLine(line: string, width: number): string {
	// pi-tui's width helpers are ANSI-aware, but OSC sequences are terminal
	// protocols rather than SGR styling. Avoid truncating inside OSC 8 hyperlinks;
	// these lines are already projected from an xterm buffer sized to the viewport.
	return line.includes("\x1b]") ? line : clip(line, width);
}

function osc8UriForCell(term: XtermLike, cell: BufferCellLike): string {
	const id = cell.extended?.urlId ?? cell.extended?._urlId ?? 0;
	if (!id) return "";
	const service = term._core?._oscLinkService;
	const uri = service?.getLinkData?.(id)?.uri ?? service?._dataByLinkId?.get(id)?.data?.uri;
	return sanitizeOscPayload(uri ?? "");
}

function openOsc8(uri: string): string {
	return uri ? `\x1b]8;;${uri}\x07` : "";
}

function closeOsc8(): string {
	return "\x1b]8;;\x07";
}

function extractOsc52Sequences(input: string): { sequences: string[]; carry: string } {
	const sequences: string[] = [];
	let scanFrom = 0;
	let carryStart = -1;
	while (scanFrom < input.length) {
		const start = input.indexOf(OSC52_PREFIX, scanFrom);
		if (start < 0) break;
		const bel = input.indexOf("\x07", start + OSC52_PREFIX.length);
		const st = input.indexOf("\x1b\\", start + OSC52_PREFIX.length);
		const end = firstTerminator(bel, st);
		if (!end) {
			carryStart = start;
			break;
		}
		const [endIndex, terminatorLength] = end;
		const seq = input.slice(start, endIndex + terminatorLength);
		if (isForwardableOsc52(seq)) sequences.push(seq);
		scanFrom = endIndex + terminatorLength;
	}
	const carry = carryStart >= 0 ? input.slice(carryStart).slice(-OSC52_CARRY_MAX_BYTES) : osc52PrefixSuffix(input);
	return { sequences, carry };
}

function osc52PrefixSuffix(input: string): string {
	const max = Math.min(input.length, OSC52_PREFIX.length - 1);
	for (let len = max; len > 0; len--) {
		const suffix = input.slice(-len);
		if (OSC52_PREFIX.startsWith(suffix)) return suffix;
	}
	return "";
}

function firstTerminator(bel: number, st: number): [number, number] | null {
	if (bel < 0 && st < 0) return null;
	if (bel >= 0 && (st < 0 || bel < st)) return [bel, 1];
	return [st, 2];
}

function extractTerminalPassthroughSequences(input: string): { sequences: string[]; carry: string } {
	const sequences: string[] = [];
	let scanFrom = 0;
	let carryStart = -1;
	while (scanFrom < input.length) {
		const kitty = input.indexOf(KITTY_IMAGE_PREFIX, scanFrom);
		const iterm = input.indexOf(ITERM2_FILE_PREFIX, scanFrom);
		const start = firstIndex(kitty, iterm);
		if (start < 0) break;
		const prefix = start === kitty ? KITTY_IMAGE_PREFIX : ITERM2_FILE_PREFIX;
		const bel = prefix === ITERM2_FILE_PREFIX ? input.indexOf("\x07", start + prefix.length) : -1;
		const st = input.indexOf("\x1b\\", start + prefix.length);
		const end = firstTerminator(bel, st);
		if (!end) {
			carryStart = start;
			break;
		}
		const [endIndex, terminatorLength] = end;
		const seq = input.slice(start, endIndex + terminatorLength);
		if (seq.length <= TERMINAL_PASSTHROUGH_MAX_BYTES) sequences.push(seq);
		scanFrom = endIndex + terminatorLength;
	}
	const carry = carryStart >= 0 ? input.slice(carryStart).slice(-TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES) : terminalPassthroughPrefixSuffix(input);
	return { sequences, carry };
}

function firstIndex(a: number, b: number): number {
	if (a < 0) return b;
	if (b < 0) return a;
	return Math.min(a, b);
}

function terminalPassthroughPrefixSuffix(input: string): string {
	let best = "";
	for (const prefix of [KITTY_IMAGE_PREFIX, ITERM2_FILE_PREFIX]) {
		const max = Math.min(input.length, prefix.length - 1);
		for (let len = max; len > best.length; len--) {
			const suffix = input.slice(-len);
			if (prefix.startsWith(suffix)) best = suffix;
		}
	}
	return best;
}

function isForwardableOsc52(seq: string): boolean {
	if (seq.length > OSC52_MAX_BYTES) return false;
	const terminatorLength = seq.endsWith("\x1b\\") ? 2 : 1;
	const body = seq.slice(2, -terminatorLength); // strip ESC] and BEL/ST
	const firstSemi = body.indexOf(";");
	const secondSemi = body.indexOf(";", firstSemi + 1);
	if (!body.startsWith("52;") || secondSemi < 0) return false;
	const payload = body.slice(secondSemi + 1).replace(/[\r\n]/g, "");
	// Do not forward clipboard-read requests (OSC 52 ; ... ; ?) to the outer terminal.
	if (payload === "?") return false;
	return /^[A-Za-z0-9+/=]*$/.test(payload);
}

function sanitizeOscPayload(value: string): string {
	return value.replace(/[\x00-\x1f\x7f]/g, "");
}

function center(text: string, width: number): string {
	const w = visibleWidth(text);
	if (w >= width) return clip(text, width);
	return " ".repeat(Math.floor((width - w) / 2)) + text;
}
