/**
 * Minimal TUI implementation with differential rendering
 */

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { drainCellSizeQueryBuffer } from "./cell-size.js";
import {
	type AutonomousModeStatus,
	AutonomousStatusBar,
} from "./components/autonomous-status-bar.js";
import { syncHardwareCursorForIME } from "./hardware-cursor.js";
import { isKeyRelease, matchesKey } from "./keys.js";
import {
	applyLineResets,
	compositeOverlays,
	extractCursorPosition,
	isOverlayVisible as isOverlayEntryVisible,
} from "./overlay-layout.js";
import type { OverlayHandle, OverlayOptions } from "./overlay-types.js";
import { tryRender } from "./render-guard.js";
import type { Terminal } from "./terminal.js";
import {
	getCapabilities,
	isImageLine,
	setCellDimensions,
} from "./terminal-image.js";
import {
	applyInputListeners,
	type InputListener,
} from "./tui-input-dispatch.js";
import { truncateToWidth, visibleWidth } from "./utils.js";
import {
	buildStickyScrollVerticalEscapes,
	isStickyViewportAtBottom,
} from "./viewport-sticky.js";

/**
 * Component interface - all components must implement this
 */
export interface Component {
	/**
	 * Render the component to lines for the given viewport width
	 * @param width - Current viewport width
	 * @returns Array of strings, each representing a line
	 */
	render(width: number): string[];

	/**
	 * Optional handler for keyboard input when component has focus
	 */
	handleInput?(data: string): void;

	/**
	 * If true, component receives key release events (Kitty protocol).
	 * Default is false - release events are filtered out.
	 */
	wantsKeyRelease?: boolean;

	/**
	 * Invalidate any cached rendering state.
	 * Called when theme changes or when component needs to re-render from scratch.
	 */
	invalidate(): void;
}

/**
 * Interface for components that can receive focus and display a hardware cursor.
 * When focused, the component should emit CURSOR_MARKER at the cursor position
 * in its render output. TUI will find this marker and position the hardware
 * cursor there for proper IME candidate window positioning.
 */
export interface Focusable {
	/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
	focused: boolean;
}

/** Type guard to check if a component implements Focusable */
export function isFocusable(
	component: Component | null,
): component is Component & Focusable {
	return component !== null && "focused" in component;
}

/**
 * Cursor position marker - APC (Application Program Command) sequence.
 * This is a zero-width escape sequence that terminals ignore.
 * Components emit this at the cursor position when focused.
 * TUI finds and strips this marker, then positions the hardware cursor there.
 */
export const CURSOR_MARKER = "\x1b_sf:c\x07";

export type {
	OverlayAnchor,
	OverlayHandle,
	OverlayMargin,
	OverlayOptions,
	SizeValue,
} from "./overlay-types.js";
export { visibleWidth };

/**
 * Container - a component that contains other components
 */
export class Container implements Component {
	children: Component[] = [];
	private _prevRender: string[] | null = null;

	addChild(component: Component): void {
		this.children.push(component);
		this._prevRender = null;
	}

	removeChild(component: Component): void {
		const index = this.children.indexOf(component);
		if (index !== -1) {
			const child = this.children[index];
			this.children.splice(index, 1);
			if ("dispose" in child && typeof (child as any).dispose === "function") {
				(child as any).dispose();
			}
			this._prevRender = null;
		}
	}

	clear(): void {
		for (const child of this.children) {
			if ("dispose" in child && typeof (child as any).dispose === "function") {
				(child as any).dispose();
			}
		}
		this.children = [];
		this._prevRender = null;
	}

	/**
	 * Remove all children without calling dispose on them.
	 * Use when child lifecycle is owned elsewhere and the container is only a
	 * render mount (e.g. extension widget containers in InteractiveMode, where
	 * the extensionWidgets* maps own disposal).
	 */
	detachChildren(): void {
		this.children = [];
		this._prevRender = null;
	}

	invalidate(): void {
		for (const child of this.children) {
			child.invalidate?.();
		}
	}

	render(width: number): string[] {
		const lines: string[] = [];
		let index = 0;
		for (const child of this.children) {
			const rendered = tryRender(child, width, `child[${index++}]`);
			for (let i = 0; i < rendered.length; i++) lines.push(rendered[i]);
		}
		// Return stable reference if output unchanged — allows doRender()
		// to skip ALL post-processing (isImageLine, applyLineResets, diffs)
		const prev = this._prevRender;
		if (prev && prev.length === lines.length) {
			let same = true;
			for (let i = 0; i < lines.length; i++) {
				if (lines[i] !== prev[i]) {
					same = false;
					break;
				}
			}
			if (same) return prev;
		}
		this._prevRender = lines;
		return lines;
	}
}

/**
 * TUI - Main class for managing terminal UI with differential rendering
 */
export class TUI extends Container {
	public terminal: Terminal;
	private previousLines: string[] = [];
	private previousWidth = 0;
	private previousHeight = 0;
	private focusedComponent: Component | null = null;
	private inputListeners = new Set<InputListener>();

	/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
	public onDebug?: () => void;
	private renderRequested = false;
	private cursorRow = 0; // Logical cursor row (end of rendered content)
	private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
	private inputBuffer = ""; // Buffer for parsing terminal responses
	private cellSizeQueryPending = false;
	private showHardwareCursor =
		process.env.PI_HARDWARE_CURSOR === "1" ||
		process.env.TERM_PROGRAM === "WarpTerminal";
	private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
	private _shrinkDebounceActive = false;
	private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
	private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
	private fullRedrawCount = 0;
	private stopped = false;
	private _lastRenderedComponents: string[] | null = null;

	// === Sticky bottom scrolling ===
	private isScrolledToBottom = true; // Track if user is scrolled to bottom

	// === Autonomous mode info strip (component — keeps chrome out of core TUI logic) ===
	private readonly autonomousBar = new AutonomousStatusBar();

	// Overlay stack for modal components rendered on top of base content
	private focusOrderCounter = 0;
	private overlayStack: {
		component: Component;
		options?: OverlayOptions;
		preFocus: Component | null;
		hidden: boolean;
		focusOrder: number;
	}[] = [];

	constructor(terminal: Terminal, showHardwareCursor?: boolean) {
		super();
		this.terminal = terminal;
		if (showHardwareCursor !== undefined) {
			this.showHardwareCursor = showHardwareCursor;
		}
	}

	get fullRedraws(): number {
		return this.fullRedrawCount;
	}

	getShowHardwareCursor(): boolean {
		return this.showHardwareCursor;
	}

	setShowHardwareCursor(enabled: boolean): void {
		if (this.showHardwareCursor === enabled) return;
		this.showHardwareCursor = enabled;
		if (!enabled) {
			this.terminal.hideCursor();
		}
		this.requestRender();
	}

	getClearOnShrink(): boolean {
		return this.clearOnShrink;
	}

	/**
	 * Set whether to trigger full re-render when content shrinks.
	 * When true (default), empty rows are cleared when content shrinks.
	 * When false, empty rows remain (reduces redraws on slower terminals).
	 */
	setClearOnShrink(enabled: boolean): void {
		this.clearOnShrink = enabled;
	}

	setFocus(component: Component | null): void {
		// Clear focused flag on old component
		if (isFocusable(this.focusedComponent)) {
			this.focusedComponent.focused = false;
		}

		this.focusedComponent = component;

		// Set focused flag on new component
		if (isFocusable(component)) {
			component.focused = true;
		}
	}

	/**
	 * Show an overlay component with configurable positioning and sizing.
	 * Returns a handle to control the overlay's visibility.
	 */
	showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
		const entry = {
			component,
			options,
			preFocus: this.focusedComponent,
			hidden: false,
			focusOrder: ++this.focusOrderCounter,
		};
		this.overlayStack.push(entry);
		// Only focus if overlay is actually visible
		if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
			this.setFocus(component);
		}
		this.terminal.hideCursor();
		this.requestRender();

		// Return handle for controlling this overlay
		return {
			hide: () => {
				const index = this.overlayStack.indexOf(entry);
				if (index !== -1) {
					this.overlayStack.splice(index, 1);
					// Restore focus if this overlay had focus
					if (this.focusedComponent === component) {
						const topVisible = this.getTopmostVisibleOverlay();
						this.setFocus(topVisible?.component ?? entry.preFocus);
					}
					if (this.overlayStack.length === 0) this.terminal.hideCursor();
					this.requestRender();
				}
			},
			setHidden: (hidden: boolean) => {
				if (entry.hidden === hidden) return;
				entry.hidden = hidden;
				// Update focus when hiding/showing
				if (hidden) {
					// If this overlay had focus, move focus to next visible or preFocus
					if (this.focusedComponent === component) {
						const topVisible = this.getTopmostVisibleOverlay();
						this.setFocus(topVisible?.component ?? entry.preFocus);
					}
				} else {
					// Restore focus to this overlay when showing (if it's actually visible)
					if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
						entry.focusOrder = ++this.focusOrderCounter;
						this.setFocus(component);
					}
				}
				this.requestRender();
			},
			isHidden: () => entry.hidden,
			focus: () => {
				if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
					return;
				if (this.focusedComponent !== component) {
					this.setFocus(component);
				}
				entry.focusOrder = ++this.focusOrderCounter;
				this.requestRender();
			},
			unfocus: () => {
				if (this.focusedComponent !== component) return;
				const topVisible = this.getTopmostVisibleOverlay();
				this.setFocus(
					topVisible && topVisible !== entry
						? topVisible.component
						: entry.preFocus,
				);
				this.requestRender();
			},
			isFocused: () => this.focusedComponent === component,
		};
	}

	/** Hide the topmost overlay and restore previous focus. */
	hideOverlay(): void {
		const overlay = this.overlayStack.pop();
		if (!overlay) return;
		if (this.focusedComponent === overlay.component) {
			// Find topmost visible overlay, or fall back to preFocus
			const topVisible = this.getTopmostVisibleOverlay();
			this.setFocus(topVisible?.component ?? overlay.preFocus);
		}
		if (this.overlayStack.length === 0) this.terminal.hideCursor();
		this.requestRender();
	}

	/** Check if there are any visible overlays */
	hasOverlay(): boolean {
		return this.overlayStack.some((o) => this.isOverlayVisible(o));
	}

	/** Check if an overlay entry is currently visible */
	private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
		return isOverlayEntryVisible(
			entry,
			this.terminal.columns,
			this.terminal.rows,
		);
	}

	/** Find the topmost visible capturing overlay, if any */
	private getTopmostVisibleOverlay():
		| (typeof this.overlayStack)[number]
		| undefined {
		for (let i = this.overlayStack.length - 1; i >= 0; i--) {
			if (this.overlayStack[i].options?.nonCapturing) continue;
			if (this.isOverlayVisible(this.overlayStack[i])) {
				return this.overlayStack[i];
			}
		}
		return undefined;
	}

	override invalidate(): void {
		super.invalidate();
		for (const overlay of this.overlayStack) overlay.component.invalidate?.();
	}

	start(): void {
		this.stopped = false;
		// Non-TTY stdout (pipe) — skip TUI entirely to avoid burning CPU.
		// RPC bridge processes have piped stdio; rendering ANSI escape codes
		// to a pipe is pure waste and causes a runaway render loop. (issue #3095)
		if (!this.terminal.isTTY) {
			return;
		}
		this.terminal.start(
			(data) => this.handleInput(data),
			() => this.requestRender(),
		);
		this.terminal.hideCursor();
		this.queryCellSize();
		this.requestRender();
	}

	addInputListener(listener: InputListener): () => void {
		this.inputListeners.add(listener);
		return () => {
			this.inputListeners.delete(listener);
		};
	}

	removeInputListener(listener: InputListener): void {
		this.inputListeners.delete(listener);
	}

	private queryCellSize(): void {
		// Only query if terminal supports images (cell size is only used for image rendering)
		if (!getCapabilities().images) {
			return;
		}
		// Query terminal for cell size in pixels: CSI 16 t
		// Response format: CSI 6 ; height ; width t
		this.cellSizeQueryPending = true;
		this.terminal.write("\x1b[16t");
	}

	stop(): void {
		this.stopped = true;

		// Dispose all overlays to stop any running timers
		for (const entry of this.overlayStack) {
			if (
				"dispose" in entry.component &&
				typeof (entry.component as any).dispose === "function"
			) {
				(entry.component as any).dispose();
			}
		}
		this.overlayStack = [];

		// Move cursor to the end of the content to prevent overwriting/artifacts on exit
		if (this.previousLines.length > 0) {
			const targetRow = this.previousLines.length; // Line after the last content
			const lineDiff = targetRow - this.hardwareCursorRow;
			if (lineDiff > 0) {
				this.terminal.write(`\x1b[${lineDiff}B`);
			} else if (lineDiff < 0) {
				this.terminal.write(`\x1b[${-lineDiff}A`);
			}
			this.terminal.write("\r\n");
		}

		this.terminal.showCursor();
		this.terminal.stop();
	}

	requestRender(force = false): void {
		// Skip rendering on non-TTY stdout to prevent CPU burn (issue #3095)
		if (!this.terminal.isTTY) return;
		if (force) {
			this.previousLines = [];
			this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
			this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
			this.cursorRow = 0;
			this.hardwareCursorRow = 0;
			this.maxLinesRendered = 0;
			this.previousViewportTop = 0;
		}
		if (this.renderRequested) return;
		this.renderRequested = true;
		process.nextTick(() => {
			this.renderRequested = false;
			this.doRender();
		});
	}

	/**
	 * Check if user is scrolled to the bottom of the content
	 */
	private isAtBottom(): boolean {
		return isStickyViewportAtBottom({
			maxLinesRendered: this.maxLinesRendered,
			terminalRows: this.terminal.rows,
			previousContentLineCount: this.previousLines.length,
		});
	}

	/**
	 * Scroll to bottom of content (sticky bottom)
	 */
	private scrollToBottom(): void {
		const sticky = buildStickyScrollVerticalEscapes({
			contentLineCount: this.previousLines.length,
			terminalRows: this.terminal.rows,
			previousViewportTop: this.previousViewportTop,
			hardwareCursorRow: this.hardwareCursorRow,
		});
		if (!sticky) {
			return;
		}

		const { escapes, nextPreviousViewportTop } = sticky;
		if (escapes) {
			this.terminal.write(escapes);
		}
		this.previousViewportTop = nextPreviousViewportTop;
		this.isScrolledToBottom = true;
	}

	/**
	 * Update autonomous status information
	 */
	updateAutonomousStatus(status: AutonomousModeStatus | undefined): void {
		this.autonomousBar.setStatus(status);
		this.requestRender();
	}

	private flushCellSizeBufferedInput(chunk: string): string | undefined {
		this.inputBuffer += chunk;
		const drained = drainCellSizeQueryBuffer(this.inputBuffer);
		this.inputBuffer = drained.nextBuffer;

		if (drained.cellPixels) {
			setCellDimensions(drained.cellPixels);
			this.invalidate();
			this.requestRender();
		}

		if (drained.wait) {
			return undefined;
		}

		this.cellSizeQueryPending = false;
		if (drained.forwardInput.length === 0) {
			return undefined;
		}
		return drained.forwardInput;
	}

	private repairFocusedOverlayVisibility(): void {
		const focusedOverlay = this.overlayStack.find(
			(o) => o.component === this.focusedComponent,
		);
		if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
			const topVisible = this.getTopmostVisibleOverlay();
			if (topVisible) {
				this.setFocus(topVisible.component);
			} else {
				this.setFocus(focusedOverlay.preFocus);
			}
		}
	}

	private maybeHandleEnterAsScrollToBottom(data: string): boolean {
		if (data !== "\r" && data !== "\n") {
			return false;
		}
		if (!this.isAtBottom()) {
			this.scrollToBottom();
			return true;
		}
		return false;
	}

	private dispatchFocusedComponentInput(data: string): void {
		if (!this.focusedComponent?.handleInput) {
			return;
		}
		if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
			return;
		}
		this.focusedComponent.handleInput(data);
		this.requestRender();
	}

	private handleInput(data: string): void {
		const afterListeners = applyInputListeners(this.inputListeners, data);
		if (afterListeners === undefined) {
			return;
		}
		data = afterListeners;

		if (this.cellSizeQueryPending) {
			const released = this.flushCellSizeBufferedInput(data);
			if (released === undefined) {
				return;
			}
			data = released;
		}

		if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
			this.onDebug();
			return;
		}

		if (
			this.isScrolledToBottom &&
			(matchesKey(data, "pageUp") || matchesKey(data, "up"))
		) {
			this.isScrolledToBottom = false;
		}

		this.repairFocusedOverlayVisibility();

		if (this.maybeHandleEnterAsScrollToBottom(data)) {
			return;
		}

		this.dispatchFocusedComponentInput(data);
	}

	private doRender(): void {
		if (this.stopped) return;
		try {
			const width = this.terminal.columns;
			const height = this.terminal.rows;
			let viewportTop = Math.max(0, this.maxLinesRendered - height);
			let prevViewportTop = this.previousViewportTop;
			let hardwareCursorRow = this.hardwareCursorRow;
			const computeLineDiff = (targetRow: number): number => {
				const currentScreenRow = hardwareCursorRow - prevViewportTop;
				const targetScreenRow = targetRow - viewportTop;
				return targetScreenRow - currentScreenRow;
			};

			// Render all components to get new lines
			let newLines = this.render(width);

			const statusBarLines = this.autonomousBar.render(width);
			if (statusBarLines.length > 0) {
				newLines = [...statusBarLines, ...newLines];
			}

			// Check if content grew and we should scroll to bottom (sticky bottom behavior)
			const contentGrew = newLines.length > this.previousLines.length;
			const shouldScrollToBottom = contentGrew && this.isScrolledToBottom;

			// Skip ALL post-processing if component output is unchanged.
			// Container.render() returns the same array reference when stable.
			if (
				newLines === this._lastRenderedComponents &&
				this.overlayStack.length === 0 &&
				!shouldScrollToBottom
			) {
				return;
			}
			this._lastRenderedComponents = newLines;

			// Composite overlays into the rendered lines (before differential compare)
			if (this.overlayStack.length > 0) {
				newLines = compositeOverlays(
					newLines,
					this.overlayStack,
					width,
					height,
					this.maxLinesRendered,
				);
			}

			// Extract cursor position before applying line resets (marker must be found first)
			const cursorPos = extractCursorPosition(newLines, height);

			newLines = applyLineResets(newLines);

			// Width or height changed - need full re-render
			const widthChanged =
				this.previousWidth !== 0 && this.previousWidth !== width;
			const heightChanged =
				this.previousHeight !== 0 && this.previousHeight !== height;

			// Helper to clear scrollback and viewport and render all new lines
			const fullRender = (clear: boolean): void => {
				this.fullRedrawCount += 1;
				let buffer = "\x1b[?2026h"; // Begin synchronized output
				if (clear) buffer += "\x1b[2J\x1b[H"; // Clear screen and home (no scrollback clear — preserves view position)
				for (let i = 0; i < newLines.length; i++) {
					if (i > 0) buffer += "\r\n";
					let line = newLines[i];
					if (!isImageLine(line) && visibleWidth(line) > width) {
						line = truncateToWidth(line, width);
					}
					buffer += line;
				}
				buffer += "\x1b[?2026l"; // End synchronized output
				this.terminal.write(buffer);
				this.cursorRow = Math.max(0, newLines.length - 1);
				this.hardwareCursorRow = this.cursorRow;
				// Reset max lines when clearing, otherwise track growth
				if (clear) {
					this.maxLinesRendered = newLines.length;
				} else {
					this.maxLinesRendered = Math.max(
						this.maxLinesRendered,
						newLines.length,
					);
				}
				this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
				this.applyIMEHardwareCursor(cursorPos, newLines.length);
				this.previousLines = newLines;
				this.previousWidth = width;
				this.previousHeight = height;
			};

			const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
			const logRedraw = (reason: string): void => {
				if (!debugRedraw) return;
				const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log");
				const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
				fs.appendFileSync(logPath, msg);
			};

			// First render - just output everything without clearing (assumes clean screen)
			if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
				logRedraw("first render");
				fullRender(false);
				return;
			}

			// Width or height changed - full re-render
			if (widthChanged || heightChanged) {
				logRedraw(
					`terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`,
				);
				fullRender(true);
				return;
			}

			// Content shrunk below the working area and no overlays - re-render to clear empty rows
			// (overlays need the padding, so only do this when no overlays are active)
			// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
			if (
				this.clearOnShrink &&
				newLines.length < this.maxLinesRendered &&
				this.overlayStack.length === 0
			) {
				if (!this._shrinkDebounceActive) {
					// First shrink detection: defer the full redraw by one tick.
					// If content grows back immediately (pinned clear → new streaming),
					// the full redraw is avoided.
					this._shrinkDebounceActive = true;
					// Do NOT update maxLinesRendered here — keep the old value so the
					// condition `newLines.length < maxLinesRendered` still triggers on
					// the next render if content stays shrunk.
					logRedraw(
						`clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`,
					);
					// Fall through to differential render for this frame
				} else {
					// Still shrunk on second render — commit the full redraw
					this._shrinkDebounceActive = false;
					logRedraw(
						`clearOnShrink committed (maxLinesRendered=${this.maxLinesRendered})`,
					);
					fullRender(true);
					return;
				}
			} else {
				this._shrinkDebounceActive = false;
			}

			// Find first and last changed lines
			let firstChanged = -1;
			let lastChanged = -1;
			const maxLines = Math.max(newLines.length, this.previousLines.length);
			for (let i = 0; i < maxLines; i++) {
				const oldLine =
					i < this.previousLines.length ? this.previousLines[i] : "";
				const newLine = i < newLines.length ? newLines[i] : "";

				if (oldLine !== newLine) {
					if (firstChanged === -1) {
						firstChanged = i;
					}
					lastChanged = i;
				}
			}
			const appendedLines = newLines.length > this.previousLines.length;
			if (appendedLines) {
				if (firstChanged === -1) {
					firstChanged = this.previousLines.length;
				}
				lastChanged = newLines.length - 1;
			}
			const appendStart =
				appendedLines &&
				firstChanged === this.previousLines.length &&
				firstChanged > 0;

			// No changes - but still need to update hardware cursor position if it moved
			if (firstChanged === -1) {
				this.applyIMEHardwareCursor(cursorPos, newLines.length);
				this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
				this.previousHeight = height;
				return;
			}

			// All changes are in deleted lines (nothing to render, just clear)
			if (firstChanged >= newLines.length) {
				if (this.previousLines.length > newLines.length) {
					let buffer = "\x1b[?2026h";
					// Move to end of new content (clamp to 0 for empty content)
					const targetRow = Math.max(0, newLines.length - 1);
					const lineDiff = computeLineDiff(targetRow);
					if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
					else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
					buffer += "\r";
					// Clear extra lines without scrolling
					const extraLines = this.previousLines.length - newLines.length;
					if (extraLines > height) {
						logRedraw(`extraLines > height (${extraLines} > ${height})`);
						fullRender(true);
						return;
					}
					if (extraLines > 0) {
						buffer += "\x1b[1B";
					}
					for (let i = 0; i < extraLines; i++) {
						buffer += "\r\x1b[2K";
						if (i < extraLines - 1) buffer += "\x1b[1B";
					}
					if (extraLines > 0) {
						buffer += `\x1b[${extraLines}A`;
					}
					buffer += "\x1b[?2026l";
					this.terminal.write(buffer);
					this.cursorRow = targetRow;
					this.hardwareCursorRow = targetRow;
				}
				this.applyIMEHardwareCursor(cursorPos, newLines.length);
				this.previousLines = newLines;
				this.previousWidth = width;
				this.previousHeight = height;
				this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
				return;
			}

			// Check if firstChanged is above what was previously visible
			// Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
			const previousContentViewportTop = Math.max(
				0,
				this.previousLines.length - height,
			);
			if (firstChanged < previousContentViewportTop) {
				// First change is above previous viewport - need full re-render
				logRedraw(
					`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`,
				);
				fullRender(true);
				return;
			}

			// Render from first changed line to end
			// Build buffer with all updates wrapped in synchronized output
			let buffer = "\x1b[?2026h"; // Begin synchronized output
			const prevViewportBottom = prevViewportTop + height - 1;
			const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
			if (moveTargetRow > prevViewportBottom) {
				const currentScreenRow = Math.max(
					0,
					Math.min(height - 1, hardwareCursorRow - prevViewportTop),
				);
				const moveToBottom = height - 1 - currentScreenRow;
				if (moveToBottom > 0) {
					buffer += `\x1b[${moveToBottom}B`;
				}
				const scroll = moveTargetRow - prevViewportBottom;
				buffer += "\r\n".repeat(scroll);
				prevViewportTop += scroll;
				viewportTop += scroll;
				hardwareCursorRow = moveTargetRow;
			}

			// Move cursor to first changed line (use hardwareCursorRow for actual position)
			const lineDiff = computeLineDiff(moveTargetRow);
			if (lineDiff > 0) {
				buffer += `\x1b[${lineDiff}B`; // Move down
			} else if (lineDiff < 0) {
				buffer += `\x1b[${-lineDiff}A`; // Move up
			}

			buffer += appendStart ? "\r\n" : "\r"; // Move to column 0

			// Only render changed lines (firstChanged to lastChanged), not all lines to end
			// This reduces flicker when only a single line changes (e.g., spinner animation)
			const renderEnd = Math.min(lastChanged, newLines.length - 1);
			for (let i = firstChanged; i <= renderEnd; i++) {
				if (i > firstChanged) buffer += "\r\n";
				buffer += "\x1b[2K"; // Clear current line
				let line = newLines[i];
				const isImage = isImageLine(line);
				if (!isImage && visibleWidth(line) > width) {
					line = truncateToWidth(line, width);
				}
				buffer += line;
			}

			// Track where cursor ended up after rendering
			let finalCursorRow = renderEnd;

			// If we had more lines before, clear them and move cursor back
			if (this.previousLines.length > newLines.length) {
				// Move to end of new content first if we stopped before it
				if (renderEnd < newLines.length - 1) {
					const moveDown = newLines.length - 1 - renderEnd;
					buffer += `\x1b[${moveDown}B`;
					finalCursorRow = newLines.length - 1;
				}
				const extraLines = this.previousLines.length - newLines.length;
				for (let i = newLines.length; i < this.previousLines.length; i++) {
					buffer += "\r\n\x1b[2K";
				}
				// Move cursor back to end of new content
				buffer += `\x1b[${extraLines}A`;
			}

			buffer += "\x1b[?2026l"; // End synchronized output

			if (process.env.PI_TUI_DEBUG === "1") {
				const debugDir = path.join(os.tmpdir(), "tui");
				fs.mkdirSync(debugDir, { recursive: true });
				const debugPath = path.join(
					debugDir,
					`render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
				);
				const debugData = [
					`firstChanged: ${firstChanged}`,
					`viewportTop: ${viewportTop}`,
					`cursorRow: ${this.cursorRow}`,
					`height: ${height}`,
					`lineDiff: ${lineDiff}`,
					`hardwareCursorRow: ${hardwareCursorRow}`,
					`renderEnd: ${renderEnd}`,
					`finalCursorRow: ${finalCursorRow}`,
					`cursorPos: ${JSON.stringify(cursorPos)}`,
					`newLines.length: ${newLines.length}`,
					`previousLines.length: ${this.previousLines.length}`,
					"",
					"=== newLines ===",
					JSON.stringify(newLines, null, 2),
					"",
					"=== previousLines ===",
					JSON.stringify(this.previousLines, null, 2),
					"",
					"=== buffer ===",
					JSON.stringify(buffer),
				].join("\n");
				fs.writeFileSync(debugPath, debugData);
			}

			// Write entire buffer at once
			this.terminal.write(buffer);

			// Track cursor position for next render
			// cursorRow tracks end of content (for viewport calculation)
			// hardwareCursorRow tracks actual terminal cursor position (for movement)
			this.cursorRow = Math.max(0, newLines.length - 1);
			this.hardwareCursorRow = finalCursorRow;
			// Track terminal's working area (grows but doesn't shrink unless cleared)
			this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
			this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);

			// Apply sticky bottom behavior if content grew and user was at bottom
			if (shouldScrollToBottom) {
				this.scrollToBottom();
			}

			// Position hardware cursor for IME
			this.applyIMEHardwareCursor(cursorPos, newLines.length);

			this.previousLines = newLines;
			this.previousWidth = width;
			this.previousHeight = height;
		} catch (err) {
			console.error("[TUI] render error:", err);
			process.stdout.write(
				"\x1b[2K\x1b[31m[TUI render error — see stderr]\x1b[0m\n",
			);
		}
	}

	/**
	 * Position the hardware cursor for IME (`CURSOR_MARKER` anchor).
	 */
	private applyIMEHardwareCursor(
		cursorPos: { row: number; col: number } | null,
		totalLines: number,
	): void {
		this.hardwareCursorRow = syncHardwareCursorForIME(this.terminal, {
			cursorPos,
			totalLines,
			hardwareCursorRow: this.hardwareCursorRow,
			showHardwareCursor: this.showHardwareCursor,
		});
	}
}
