import {
	type Component,
	extractPrintableText,
	matchesKey,
	padding,
	replaceTabs,
	truncateToWidth,
	visibleWidth,
} from './stubs/tui/index.ts';
import { sanitizeText } from "@oh-my-pi/pi-utils";
import { theme } from "../modes/theme/theme";
import { copyToClipboard } from "../utils/clipboard";
import {
	formatDebugLogExpandedLines,
	formatDebugLogLine,
	parseDebugLogPid,
	parseDebugLogTimestampMs,
} from "./log-formatting";
import type { DebugLogSource } from "./report-bundle";

export const SESSION_BOUNDARY_WARNING = "### WARNING - Logs above are older than current session!";
export const LOAD_OLDER_LABEL = "### MOVE UP TO LOAD MORE...";

const INITIAL_LOG_CHUNK = 50;
const LOAD_OLDER_CHUNK = 50;

type LogEntry = {
	rawLine: string;
	timestampMs: number | undefined;
	pid: number | undefined;
};

type CursorToken = { kind: "log"; logIndex: number } | { kind: "load-older" };

type DebugLogViewerModelOptions = {
	processStartMs?: number;
	processPid?: number;
	hasOlderLogs?: () => boolean;
	loadOlderLogs?: (limitDays?: number) => Promise<string>;
};

type ViewerRow =
	| {
			kind: "warning";
	  }
	| {
			kind: "load-older";
	  }
	| {
			kind: "log";
			logIndex: number;
	  };

function getProcessStartMs(): number {
	return Date.now() - process.uptime() * 1000;
}

export function splitLogText(logText: string): string[] {
	return logText.split("\n").filter(line => line.length > 0);
}

export function buildLogCopyPayload(lines: string[]): string {
	return lines
		.map(line => sanitizeText(line))
		.filter(line => line.length > 0)
		.join("\n");
}

export class DebugLogViewerModel {
	#entries: LogEntry[];
	#rows: ViewerRow[];
	#visibleLogIndices: number[];
	#selectableRowIndices: number[];
	#cursorSelectableIndex = 0;
	#selectionAnchorSelectableIndex: number | undefined;
	#expandedLogIndices = new Set<number>();
	#filterQuery = "";
	#processStartMs: number;
	#loadedStartIndex: number;
	#processFilterEnabled = false;
	#processPid: number;
	#hasOlderLogs?: () => boolean;
	#loadOlderLogs?: (limitDays?: number) => Promise<string>;

	constructor(logText: string, options: DebugLogViewerModelOptions = {}) {
		const { processStartMs = getProcessStartMs(), processPid = process.pid, hasOlderLogs, loadOlderLogs } = options;
		this.#entries = splitLogText(logText).map(rawLine => ({
			rawLine,
			timestampMs: parseDebugLogTimestampMs(rawLine),
			pid: parseDebugLogPid(rawLine),
		}));
		this.#processStartMs = processStartMs;
		this.#processPid = processPid;
		this.#hasOlderLogs = hasOlderLogs;
		this.#loadOlderLogs = loadOlderLogs;
		this.#loadedStartIndex = Math.max(0, this.#entries.length - INITIAL_LOG_CHUNK);
		this.#rows = [];
		this.#visibleLogIndices = [];
		this.#selectableRowIndices = [];
		this.#rebuildRows();
	}

	get logCount(): number {
		return this.#entries.length;
	}

	get visibleLogCount(): number {
		return this.#visibleLogIndices.length;
	}

	get rows(): readonly ViewerRow[] {
		return this.#rows;
	}

	get cursorRowIndex(): number | undefined {
		return this.#selectableRowIndices[this.#cursorSelectableIndex];
	}

	get cursorLogIndex(): number | undefined {
		const row = this.#getCursorRow();
		return row?.kind === "log" ? row.logIndex : undefined;
	}

	get filterQuery(): string {
		return this.#filterQuery;
	}

	get cursorRowKind(): ViewerRow["kind"] | undefined {
		return this.#getCursorRow()?.kind;
	}

	get expandedCount(): number {
		return this.#expandedLogIndices.size;
	}

	isProcessFilterEnabled(): boolean {
		return this.#processFilterEnabled;
	}

	isCursorAtFirstSelectableRow(): boolean {
		return this.#cursorSelectableIndex === 0;
	}

	getRawLine(logIndex: number): string {
		return this.#entries[logIndex]?.rawLine ?? "";
	}

	setFilterQuery(query: string): void {
		if (query === this.#filterQuery) {
			return;
		}
		this.#filterQuery = query;
		this.#rebuildRows();
	}

	toggleProcessFilter(): void {
		this.#processFilterEnabled = !this.#processFilterEnabled;
		this.#rebuildRows();
	}

	moveCursor(delta: number, extendSelection: boolean): void {
		if (this.#selectableRowIndices.length === 0) {
			return;
		}

		if (extendSelection && this.#selectionAnchorSelectableIndex === undefined) {
			const row = this.#getCursorRow();
			if (row?.kind === "log") {
				this.#selectionAnchorSelectableIndex = this.#cursorSelectableIndex;
			}
		}

		this.#cursorSelectableIndex = Math.max(
			0,
			Math.min(this.#selectableRowIndices.length - 1, this.#cursorSelectableIndex + delta),
		);

		if (!extendSelection) {
			this.#selectionAnchorSelectableIndex = undefined;
		}

		if (this.#getCursorRow()?.kind !== "log" && !extendSelection) {
			this.#selectionAnchorSelectableIndex = undefined;
		}
	}

	getSelectedLogIndices(): number[] {
		if (this.#selectableRowIndices.length === 0) {
			return [];
		}

		const cursorRow = this.#getCursorRow();
		if (this.#selectionAnchorSelectableIndex === undefined) {
			if (cursorRow?.kind !== "log") {
				return [];
			}
			return [cursorRow.logIndex];
		}

		const min = Math.min(this.#selectionAnchorSelectableIndex, this.#cursorSelectableIndex);
		const max = Math.max(this.#selectionAnchorSelectableIndex, this.#cursorSelectableIndex);
		const selected: number[] = [];
		for (let i = min; i <= max; i++) {
			const rowIndex = this.#selectableRowIndices[i];
			const row = rowIndex === undefined ? undefined : this.#rows[rowIndex];
			if (row?.kind === "log") {
				selected.push(row.logIndex);
			}
		}
		return selected;
	}

	getSelectedCount(): number {
		return this.getSelectedLogIndices().length;
	}

	isSelected(logIndex: number): boolean {
		const selected = this.getSelectedLogIndices();
		return selected.includes(logIndex);
	}

	isExpanded(logIndex: number): boolean {
		return this.#expandedLogIndices.has(logIndex);
	}

	expandSelected(): void {
		for (const index of this.getSelectedLogIndices()) {
			this.#expandedLogIndices.add(index);
		}
	}

	collapseSelected(): void {
		for (const index of this.getSelectedLogIndices()) {
			this.#expandedLogIndices.delete(index);
		}
	}

	getSelectedRawLines(): string[] {
		const selectedIndices = this.getSelectedLogIndices();
		return selectedIndices.map(index => this.getRawLine(index));
	}

	selectAllVisible(): void {
		if (this.#selectableRowIndices.length === 0) {
			return;
		}

		let firstLogIndex: number | undefined;
		let lastLogIndex: number | undefined;
		for (let i = 0; i < this.#selectableRowIndices.length; i++) {
			const rowIndex = this.#selectableRowIndices[i];
			const row = rowIndex === undefined ? undefined : this.#rows[rowIndex];
			if (row?.kind === "log") {
				if (firstLogIndex === undefined) {
					firstLogIndex = i;
				}
				lastLogIndex = i;
			}
		}

		if (firstLogIndex === undefined || lastLogIndex === undefined) {
			return;
		}

		this.#selectionAnchorSelectableIndex = firstLogIndex;
		this.#cursorSelectableIndex = lastLogIndex;
	}

	canLoadOlder(): boolean {
		return this.#loadedStartIndex > 0 || this.#hasExternalOlderLogs();
	}

	async loadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise<boolean> {
		if (this.#loadedStartIndex > 0) {
			return this.#loadOlderInMemory(additionalCount);
		}
		if (!this.#loadOlderLogs || !this.#hasExternalOlderLogs()) {
			return false;
		}
		const olderText = await this.#loadOlderLogs();
		if (olderText.length === 0) {
			if (!this.#hasExternalOlderLogs()) {
				this.#rebuildRows();
			}
			return false;
		}
		const added = this.prependLogs(olderText);
		if (added === 0) {
			if (!this.#hasExternalOlderLogs()) {
				this.#rebuildRows();
			}
			return false;
		}
		return this.#loadOlderInMemory(additionalCount);
	}

	prependLogs(logText: string): number {
		const previousCursor = this.#getCursorToken();
		const previousAnchorLogIndex = this.#getAnchorLogIndex();
		const newEntries = splitLogText(logText).map(rawLine => ({
			rawLine,
			timestampMs: parseDebugLogTimestampMs(rawLine),
			pid: parseDebugLogPid(rawLine),
		}));
		if (newEntries.length === 0) {
			return 0;
		}
		const offset = newEntries.length;
		this.#entries = [...newEntries, ...this.#entries];
		this.#loadedStartIndex += offset;
		this.#expandedLogIndices = new Set([...this.#expandedLogIndices].map(logIndex => logIndex + offset));
		const adjustedCursor: CursorToken | undefined =
			previousCursor?.kind === "log" ? { kind: "log", logIndex: previousCursor.logIndex + offset } : previousCursor;
		const adjustedAnchor = previousAnchorLogIndex === undefined ? undefined : previousAnchorLogIndex + offset;
		this.#rebuildRows(adjustedCursor, adjustedAnchor);
		return offset;
	}

	#loadOlderInMemory(additionalCount: number = LOAD_OLDER_CHUNK): boolean {
		if (this.#loadedStartIndex === 0) {
			return false;
		}
		const requested = Math.max(1, additionalCount);
		const nextStart = Math.max(0, this.#loadedStartIndex - requested);
		if (nextStart === this.#loadedStartIndex) {
			return false;
		}
		this.#loadedStartIndex = nextStart;
		this.#rebuildRows();
		return true;
	}

	#rebuildRows(
		previousCursor: CursorToken | undefined = this.#getCursorToken(),
		previousAnchorLogIndex = this.#getAnchorLogIndex(),
	): void {
		const query = this.#filterQuery.toLowerCase();
		const visible: number[] = [];
		for (let i = this.#loadedStartIndex; i < this.#entries.length; i++) {
			const entry = this.#entries[i];
			if (!entry) {
				continue;
			}
			if (this.#matchesFilters(entry, query)) {
				visible.push(i);
			}
		}
		this.#visibleLogIndices = visible;

		const rows: ViewerRow[] = [];
		if (this.#hasOlderEntries(query)) {
			rows.push({ kind: "load-older" });
		}
		let olderSeen = false;
		let warningInserted = false;
		for (const logIndex of visible) {
			const timestampMs = this.#entries[logIndex]?.timestampMs;
			if (timestampMs !== undefined) {
				if (timestampMs < this.#processStartMs) {
					olderSeen = true;
				} else if (olderSeen && !warningInserted) {
					rows.push({ kind: "warning" });
					warningInserted = true;
				}
			}
			rows.push({ kind: "log", logIndex });
		}
		this.#rows = rows;
		this.#selectableRowIndices = rows
			.map((row, index) => (row.kind === "warning" ? undefined : index))
			.filter((index): index is number => index !== undefined);

		if (this.#selectableRowIndices.length === 0) {
			this.#cursorSelectableIndex = 0;
			this.#selectionAnchorSelectableIndex = undefined;
			return;
		}

		if (previousCursor?.kind === "log") {
			const rowIndex = this.#rows.findIndex(row => row.kind === "log" && row.logIndex === previousCursor.logIndex);
			const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex);
			if (selectableIndex >= 0) {
				this.#cursorSelectableIndex = selectableIndex;
			} else {
				this.#cursorSelectableIndex = this.#selectableRowIndices.length - 1;
			}
		} else if (previousCursor?.kind === "load-older") {
			const rowIndex = this.#rows.findIndex(row => row.kind === "load-older");
			const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex);
			this.#cursorSelectableIndex = selectableIndex >= 0 ? selectableIndex : this.#selectableRowIndices.length - 1;
		} else {
			this.#cursorSelectableIndex = this.#selectableRowIndices.length - 1;
		}

		if (previousAnchorLogIndex !== undefined) {
			const rowIndex = this.#rows.findIndex(row => row.kind === "log" && row.logIndex === previousAnchorLogIndex);
			const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex);
			this.#selectionAnchorSelectableIndex = selectableIndex >= 0 ? selectableIndex : undefined;
		} else {
			this.#selectionAnchorSelectableIndex = undefined;
		}
	}

	#matchesFilters(entry: LogEntry, query: string): boolean {
		if (query.length > 0 && !entry.rawLine.toLowerCase().includes(query)) {
			return false;
		}
		if (!this.#processFilterEnabled) {
			return true;
		}
		return entry.pid === this.#processPid;
	}

	#hasOlderEntries(query: string): boolean {
		if (this.#hasExternalOlderLogs()) {
			return true;
		}
		if (this.#loadedStartIndex === 0) {
			return false;
		}
		for (let i = 0; i < this.#loadedStartIndex; i++) {
			const entry = this.#entries[i];
			if (entry && this.#matchesFilters(entry, query)) {
				return true;
			}
		}
		return false;
	}

	#hasExternalOlderLogs(): boolean {
		return this.#hasOlderLogs?.() ?? false;
	}

	#getCursorRow(): ViewerRow | undefined {
		const rowIndex = this.cursorRowIndex;
		return rowIndex === undefined ? undefined : this.#rows[rowIndex];
	}

	#getCursorToken(): CursorToken | undefined {
		const row = this.#getCursorRow();
		if (!row) {
			return undefined;
		}
		if (row.kind === "log") {
			return { kind: "log", logIndex: row.logIndex };
		}
		if (row.kind === "load-older") {
			return { kind: "load-older" };
		}
		return undefined;
	}

	#getAnchorLogIndex(): number | undefined {
		if (this.#selectionAnchorSelectableIndex === undefined) {
			return undefined;
		}
		const rowIndex = this.#selectableRowIndices[this.#selectionAnchorSelectableIndex];
		const row = rowIndex === undefined ? undefined : this.#rows[rowIndex];
		return row?.kind === "log" ? row.logIndex : undefined;
	}
}

interface DebugLogViewerComponentOptions {
	logs: string;
	terminalRows: number;
	onExit: () => void;
	onStatus?: (message: string) => void;
	onError?: (message: string) => void;
	processStartMs?: number;
	processPid?: number;
	logSource?: DebugLogSource;
	onUpdate?: () => void;
}

export class DebugLogViewerComponent implements Component {
	#model: DebugLogViewerModel;
	#terminalRows: number;
	#onExit: () => void;
	#onStatus?: (message: string) => void;
	#onError?: (message: string) => void;
	#onUpdate?: () => void;
	#logSource?: DebugLogSource;
	#lastRenderWidth = 80;
	#scrollRowOffset = 0;
	#statusMessage: string | undefined;
	#loadingOlder = false;

	constructor(options: DebugLogViewerComponentOptions) {
		this.#logSource = options.logSource;
		this.#model = new DebugLogViewerModel(options.logs, {
			processStartMs: options.processStartMs,
			processPid: options.processPid,
			hasOlderLogs: this.#logSource?.hasOlderLogs.bind(this.#logSource),
			loadOlderLogs: this.#logSource?.loadOlderLogs.bind(this.#logSource),
		});
		this.#terminalRows = options.terminalRows;
		this.#onExit = options.onExit;
		this.#onStatus = options.onStatus;
		this.#onError = options.onError;
		this.#onUpdate = options.onUpdate;
	}

	handleInput(keyData: string): void {
		if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
			this.#onExit();
			return;
		}

		if (matchesKey(keyData, "ctrl+c")) {
			this.#copySelected();
			return;
		}

		if (matchesKey(keyData, "ctrl+p")) {
			this.#statusMessage = undefined;
			this.#model.toggleProcessFilter();
			this.#ensureCursorVisible();
			return;
		}

		if (matchesKey(keyData, "ctrl+a")) {
			this.#statusMessage = undefined;
			this.#model.selectAllVisible();
			this.#ensureCursorVisible();
			return;
		}

		if (matchesKey(keyData, "ctrl+o")) {
			this.#statusMessage = undefined;
			void this.#handleLoadOlder(this.#bodyHeight() + 1);
			return;
		}

		if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
			if (this.#model.cursorRowKind === "load-older") {
				this.#statusMessage = undefined;
				void this.#handleLoadOlder();
			}
			return;
		}

		if (matchesKey(keyData, "shift+up")) {
			this.#statusMessage = undefined;
			void this.#handleMoveUp(true);
			return;
		}

		if (matchesKey(keyData, "shift+down")) {
			this.#statusMessage = undefined;
			this.#model.moveCursor(1, true);
			this.#ensureCursorVisible();
			return;
		}

		if (matchesKey(keyData, "up")) {
			this.#statusMessage = undefined;
			void this.#handleMoveUp(false);
			return;
		}

		if (matchesKey(keyData, "down")) {
			this.#statusMessage = undefined;
			this.#model.moveCursor(1, false);
			this.#ensureCursorVisible();
			return;
		}

		if (matchesKey(keyData, "right")) {
			this.#statusMessage = undefined;
			if (this.#model.cursorRowKind === "load-older") {
				void this.#handleLoadOlder();
				return;
			}
			this.#model.expandSelected();
			return;
		}

		if (matchesKey(keyData, "left")) {
			this.#statusMessage = undefined;
			this.#model.collapseSelected();
			return;
		}

		if (matchesKey(keyData, "backspace")) {
			if (this.#model.filterQuery.length > 0) {
				this.#statusMessage = undefined;
				this.#model.setFilterQuery(this.#model.filterQuery.slice(0, -1));
				this.#ensureCursorVisible();
			}
			return;
		}

		const printableText = extractPrintableText(keyData);
		if (printableText) {
			this.#statusMessage = undefined;
			this.#model.setFilterQuery(this.#model.filterQuery + printableText);
			this.#ensureCursorVisible();
		}
	}

	invalidate(): void {
		// no cached child state
	}

	render(width: number): readonly string[] {
		this.#lastRenderWidth = Math.max(20, width);
		this.#ensureCursorVisible();

		const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
		const bodyHeight = this.#bodyHeight();

		const rows = this.#renderRows(innerWidth);
		const visibleBodyLines = this.#renderVisibleBodyLines(rows, innerWidth, bodyHeight);

		return [
			this.#frameTop(innerWidth),
			this.#frameLine(this.#summaryText(), innerWidth),
			this.#frameSeparator(innerWidth),
			this.#frameLine(this.#filterText(), innerWidth),
			this.#frameSeparator(innerWidth),
			...visibleBodyLines,
			this.#frameLine(this.#statusText(), innerWidth),
			this.#frameBottom(innerWidth),
		];
	}

	#summaryText(): string {
		return ` # ${this.#model.visibleLogCount}/${this.#model.logCount} logs | ${this.#controlsText()}`;
	}

	#controlsText(): string {
		return "Esc: back  Ctrl+C: copy  Up/Down: move  Shift+Up/Down: select range  Left/Right: collapse/expand  Ctrl+A: select all  Ctrl+O: load older  Ctrl+P: pid filter";
	}

	#filterText(): string {
		const sanitized = replaceTabs(sanitizeText(this.#model.filterQuery));
		const query = sanitized.length === 0 ? "" : theme.fg("accent", sanitized);
		const pidStatus = this.#model.isProcessFilterEnabled()
			? theme.fg("success", "pid:on")
			: theme.fg("muted", "pid:off");
		return ` filter: ${query}  ${pidStatus}`;
	}

	#statusText(): string {
		const base = ` Selected: ${this.#model.getSelectedCount()}  Expanded: ${this.#model.expandedCount}`;
		if (this.#statusMessage) {
			return `${base}  ${this.#statusMessage}`;
		}
		return base;
	}

	#bodyHeight(): number {
		return Math.max(3, this.#terminalRows - 8);
	}

	async #handleLoadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise<void> {
		const loaded = await this.#loadOlder(additionalCount);
		if (loaded) {
			this.#ensureCursorVisible();
			this.#onUpdate?.();
		}
	}

	async #handleMoveUp(extendSelection: boolean): Promise<void> {
		if (this.#model.cursorRowKind === "load-older") {
			const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK);
			if (loaded) {
				this.#ensureCursorVisible();
				this.#onUpdate?.();
				return;
			}
		}

		if (this.#model.canLoadOlder() && this.#model.isCursorAtFirstSelectableRow()) {
			const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK);
			if (loaded) {
				this.#model.moveCursor(-1, extendSelection);
				this.#ensureCursorVisible();
				this.#onUpdate?.();
				return;
			}
		}

		this.#model.moveCursor(-1, extendSelection);
		this.#ensureCursorVisible();
		this.#onUpdate?.();
	}

	async #loadOlder(additionalCount: number): Promise<boolean> {
		if (this.#loadingOlder || !this.#model.canLoadOlder()) {
			return false;
		}
		this.#loadingOlder = true;
		const previousCursorRowIndex = this.#model.cursorRowIndex;
		const previousScrollOffset = this.#scrollRowOffset;
		try {
			const didLoad = await this.#model.loadOlder(additionalCount);
			if (didLoad) {
				this.#preserveScrollPosition(previousCursorRowIndex, previousScrollOffset);
			}
			return didLoad;
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			this.#statusMessage = `Load older failed: ${message}`;
			this.#onError?.(`Failed to load older logs: ${message}`);
			this.#onUpdate?.();
			return false;
		} finally {
			this.#loadingOlder = false;
		}
	}

	#preserveScrollPosition(previousCursorRowIndex: number | undefined, previousScrollOffset: number): void {
		const cursorRowIndex = this.#model.cursorRowIndex;
		if (previousCursorRowIndex === undefined || cursorRowIndex === undefined) {
			return;
		}
		const delta = cursorRowIndex - previousCursorRowIndex;
		const nextOffset = previousScrollOffset + delta;
		const maxOffset = Math.max(0, this.#model.rows.length - this.#bodyHeight());
		this.#scrollRowOffset = Math.max(0, Math.min(maxOffset, nextOffset));
	}

	#renderRows(innerWidth: number): Array<{ lines: string[]; rowIndex: number }> {
		const rendered: Array<{ lines: string[]; rowIndex: number }> = [];

		for (let rowIndex = 0; rowIndex < this.#model.rows.length; rowIndex++) {
			const row = this.#model.rows[rowIndex];
			if (!row) {
				continue;
			}

			if (row.kind === "warning") {
				rendered.push({
					rowIndex,
					lines: [theme.fg("muted", truncateToWidth(SESSION_BOUNDARY_WARNING, innerWidth))],
				});
				continue;
			}

			if (row.kind === "load-older") {
				const active = this.#model.cursorRowIndex === rowIndex;
				const marker = active ? theme.fg("accent", "❯") : " ";
				const prefix = `${marker}  `;
				const contentWidth = Math.max(1, innerWidth - visibleWidth(prefix));
				const label = truncateToWidth(LOAD_OLDER_LABEL, contentWidth);
				rendered.push({
					rowIndex,
					lines: [truncateToWidth(`${prefix}${theme.fg("muted", label)}`, innerWidth)],
				});
				continue;
			}

			const logIndex = row.logIndex;
			const selected = this.#model.isSelected(logIndex);
			const cursorLogIndex = this.#model.cursorLogIndex;
			const active = cursorLogIndex !== undefined && cursorLogIndex === logIndex;
			const expanded = this.#model.isExpanded(logIndex);
			const marker = active ? theme.fg("accent", "❯") : selected ? theme.fg("accent", "•") : " ";
			const fold = expanded ? theme.fg("accent", "▾") : theme.fg("muted", "▸");
			const prefix = `${marker}${fold} `;
			const contentWidth = Math.max(1, innerWidth - visibleWidth(prefix));

			if (expanded) {
				const wrapped = formatDebugLogExpandedLines(this.#model.getRawLine(logIndex), contentWidth);
				const indent = padding(visibleWidth(prefix));
				const lines = wrapped.map((segment, index) => {
					const content = selected ? theme.bold(segment) : segment;
					return truncateToWidth(`${index === 0 ? prefix : indent}${content}`, innerWidth);
				});
				rendered.push({ rowIndex, lines });
				continue;
			}

			const preview = formatDebugLogLine(this.#model.getRawLine(logIndex), contentWidth);
			const content = selected ? theme.bold(preview) : preview;
			rendered.push({ rowIndex, lines: [truncateToWidth(`${prefix}${content}`, innerWidth)] });
		}

		return rendered;
	}

	#renderVisibleBodyLines(
		rows: Array<{ lines: string[]; rowIndex: number }>,
		innerWidth: number,
		bodyHeight: number,
	): string[] {
		const lines: string[] = [];
		if (rows.length === 0) {
			lines.push(this.#frameLine(theme.fg("muted", "no matches"), innerWidth));
		}
		for (let i = this.#scrollRowOffset; i < rows.length; i++) {
			const row = rows[i];
			if (!row) {
				continue;
			}

			for (const line of row.lines) {
				if (lines.length >= bodyHeight) {
					break;
				}
				lines.push(this.#frameLine(line, innerWidth));
			}

			if (lines.length >= bodyHeight) {
				break;
			}
		}

		while (lines.length < bodyHeight) {
			lines.push(this.#frameLine("", innerWidth));
		}

		return lines;
	}

	/** Returns the number of rendered screen lines for a given row index. */
	#getRowRenderedLineCount(rowIndex: number, innerWidth: number): number {
		const row = this.#model.rows[rowIndex];
		if (!row || row.kind === "warning" || row.kind === "load-older") {
			return 1;
		}

		if (!this.#model.isExpanded(row.logIndex)) {
			return 1;
		}

		// Expanded row: compute prefix visible width and get wrapped line count
		// Prefix is marker(1) + fold(1) + space(1) = 3 visible chars
		const prefixVisibleWidth = 3;
		const contentWidth = Math.max(1, innerWidth - prefixVisibleWidth);
		const wrapped = formatDebugLogExpandedLines(this.#model.getRawLine(row.logIndex), contentWidth);
		return Math.max(1, wrapped.length);
	}

	#ensureCursorVisible(): void {
		const cursorRowIndex = this.#model.cursorRowIndex;
		if (cursorRowIndex === undefined) {
			this.#scrollRowOffset = 0;
			return;
		}
		const bodyHeight = Math.max(1, this.#bodyHeight());
		const innerWidth = Math.max(1, this.#lastRenderWidth - 2);

		// Scroll up: cursor is above viewport
		if (cursorRowIndex < this.#scrollRowOffset) {
			this.#scrollRowOffset = cursorRowIndex;
			return;
		}
		// Scroll down: sum rendered line heights from scrollRowOffset to cursorRowIndex (inclusive)
		// to check if the cursor row actually fits in the viewport
		let usedLines = 0;
		for (let i = this.#scrollRowOffset; i <= cursorRowIndex; i++) {
			usedLines += this.#getRowRenderedLineCount(i, innerWidth);
		}
		if (usedLines > bodyHeight) {
			// Advance scrollRowOffset until the cursor row fits
			while (this.#scrollRowOffset < cursorRowIndex) {
				usedLines -= this.#getRowRenderedLineCount(this.#scrollRowOffset, innerWidth);
				this.#scrollRowOffset++;
				if (usedLines <= bodyHeight) {
					break;
				}
			}
		}
	}

	#frameTop(innerWidth: number): string {
		return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`;
	}

	#frameSeparator(innerWidth: number): string {
		return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`;
	}

	#frameBottom(innerWidth: number): string {
		return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`;
	}

	#frameLine(content: string, innerWidth: number): string {
		const truncated = truncateToWidth(content, innerWidth);
		const remaining = Math.max(0, innerWidth - visibleWidth(truncated));
		return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`;
	}

	#copySelected() {
		const selectedPayload = buildLogCopyPayload(this.#model.getSelectedRawLines());
		const selected = selectedPayload.length === 0 ? [] : selectedPayload.split("\n");

		if (selected.length === 0) {
			const message = "No log entry selected";
			this.#statusMessage = message;
			this.#onStatus?.(message);
			return;
		}

		try {
			copyToClipboard(selectedPayload);
			const message = `Copied ${selected.length} log ${selected.length === 1 ? "entry" : "entries"}`;
			this.#statusMessage = message;
			this.#onStatus?.(message);
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			this.#statusMessage = `Copy failed: ${message}`;
			this.#onError?.(`Failed to copy logs: ${message}`);
		}
	}
}
