/**
 * 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 { isKeyRelease, matchesKey } from './keys.js';
import { getCapabilities, isImageLine, setCellDimensions } from './terminal-image.js';
import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from './utils.js';
/** Type guard to check if a component implements Focusable */
export function isFocusable(component) {
  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_pi:c\x07';
export { visibleWidth };
function componentName(component) {
  if (!component) return null;
  return component.constructor?.name || typeof component;
}
function sanitizeOverlayOptions(options) {
  if (!options) return undefined;
  const sanitized = {};
  for (const [key, value] of Object.entries(options)) {
    sanitized[key] =
      typeof value === 'function' ? `[function ${value.name || 'anonymous'}]` : value;
  }
  return sanitized;
}
/** Parse a SizeValue into absolute value given a reference size */
function parseSizeValue(value, referenceSize) {
  if (value === undefined) return undefined;
  if (typeof value === 'number') return value;
  // Parse percentage string like "50%"
  const match = value.match(/^(\d+(?:\.\d+)?)%$/);
  if (match) {
    return Math.floor((referenceSize * parseFloat(match[1])) / 100);
  }
  return undefined;
}
/**
 * Container - a component that contains other components
 */
export class Container {
  children = [];
  addChild(component) {
    this.children.push(component);
  }
  removeChild(component) {
    const index = this.children.indexOf(component);
    if (index !== -1) {
      this.children.splice(index, 1);
    }
  }
  clear() {
    this.children = [];
  }
  invalidate() {
    for (const child of this.children) {
      child.invalidate?.();
    }
  }
  render(width) {
    const lines = [];
    for (const child of this.children) {
      lines.push(...child.render(width));
    }
    return lines;
  }
}
/**
 * ScrollContainer - clips a child component to a fixed-height viewport.
 *
 * The container is sticky by default: when content grows while the user is at
 * the bottom, it remains pinned to the newest lines. Manual scrolling breaks
 * stickiness until the viewport reaches the bottom again.
 */
export class ScrollContainer {
  child;
  viewportHeight = undefined;
  scrollTop = 0;
  sticky = true;
  contentHeight = 0;
  constructor(child) {
    this.child = child;
  }
  setViewportHeight(height) {
    const nextHeight = Math.max(0, Math.floor(height));
    if (this.viewportHeight !== nextHeight) {
      this.viewportHeight = nextHeight;
      this.clamp();
    }
  }
  getViewportHeight() {
    return this.viewportHeight ?? this.contentHeight;
  }
  getScrollTop() {
    return this.scrollTop;
  }
  getContentHeight() {
    return this.contentHeight;
  }
  isAtBottom() {
    return this.scrollTop >= this.getMaxScrollTop();
  }
  scrollTo(scrollTop) {
    this.scrollTop = Math.max(0, Math.floor(scrollTop));
    this.sticky = false;
    this.clamp();
  }
  scrollBy(delta) {
    if (delta === 0) return;
    this.scrollTo(this.scrollTop + Math.floor(delta));
  }
  scrollPage(direction) {
    const page = Math.max(1, this.getViewportHeight() - 1);
    this.scrollBy(direction * page);
  }
  scrollToBottom() {
    this.sticky = true;
    this.scrollTop = this.getMaxScrollTop();
  }
  invalidate() {
    this.child.invalidate?.();
  }
  render(width) {
    const lines = this.child.render(width);
    this.contentHeight = lines.length;
    this.clamp();
    const height = this.getViewportHeight();
    if (height <= 0) return [];
    const visible = lines.slice(this.scrollTop, this.scrollTop + height);
    while (visible.length < height) {
      visible.push('');
    }
    return visible;
  }
  getMaxScrollTop() {
    return Math.max(0, this.contentHeight - this.getViewportHeight());
  }
  clamp() {
    const maxScrollTop = this.getMaxScrollTop();
    if (this.sticky) {
      this.scrollTop = maxScrollTop;
    } else {
      this.scrollTop = Math.max(0, Math.min(this.scrollTop, maxScrollTop));
      if (this.scrollTop === maxScrollTop) {
        this.sticky = true;
      }
    }
  }
}
/**
 * TUI - Main class for managing terminal UI with differential rendering
 */
export class TUI extends Container {
  terminal;
  previousLines = [];
  previousWidth = 0;
  focusedComponent = null;
  inputListeners = new Set();
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
  onDebug;
  renderRequested = false;
  renderSuspended = false; // When true, requestRender is a no-op (used by fullscreen takeovers like games)
  cursorRow = 0; // Logical cursor row (end of rendered content)
  hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
  inputBuffer = ''; // Buffer for parsing terminal responses
  cellSizeQueryPending = false;
  showHardwareCursor = process.env.PI_HARDWARE_CURSOR === '1';
  clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === '1'; // Clear empty rows when content shrinks (default: off)
  maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
  previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
  previousHeight = 0;
  fullRedrawCount = 0;
  stopped = false;
  renderTrace = undefined;
  renderSequence = 0;
  // Overlay stack for modal components rendered on top of base content
  overlayStack = [];
  constructor(terminal, showHardwareCursor) {
    super();
    this.terminal = terminal;
    if (showHardwareCursor !== undefined) {
      this.showHardwareCursor = showHardwareCursor;
    }
  }
  get fullRedraws() {
    return this.fullRedrawCount;
  }
  setRenderTrace(renderTrace) {
    this.renderTrace = renderTrace ?? undefined;
  }
  traceEvent(type, payload = {}) {
    this.renderTrace?.recordEvent(type, payload);
  }
  traceFrame(payload) {
    this.renderTrace?.recordFrame(payload);
  }
  describeOverlaysForTrace() {
    return this.overlayStack.map((entry, index) => ({
      index,
      component: componentName(entry.component),
      hidden: entry.hidden,
      visible: this.isOverlayVisible(entry),
      options: sanitizeOverlayOptions(entry.options)
    }));
  }
  getShowHardwareCursor() {
    return this.showHardwareCursor;
  }
  setShowHardwareCursor(enabled) {
    if (this.showHardwareCursor === enabled) return;
    this.showHardwareCursor = enabled;
    if (!enabled) {
      this.terminal.hideCursor();
    }
    this.requestRender();
  }
  getClearOnShrink() {
    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) {
    this.clearOnShrink = enabled;
  }
  setFocus(component) {
    const previous = this.focusedComponent;
    // 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;
    }
    if (previous !== component) {
      this.traceEvent('focus_changed', {
        previous: componentName(previous),
        next: componentName(component),
        overlays: this.describeOverlaysForTrace()
      });
    }
  }
  getFocusedComponent() {
    return this.focusedComponent;
  }
  /**
   * Show an overlay component with configurable positioning and sizing.
   * Returns a handle to control the overlay's visibility.
   */
  showOverlay(component, options) {
    const entry = { component, options, preFocus: this.focusedComponent, hidden: false };
    this.overlayStack.push(entry);
    this.traceEvent('overlay_show', {
      component: componentName(component),
      options: sanitizeOverlayOptions(options),
      stackSize: this.overlayStack.length,
      overlays: this.describeOverlaysForTrace()
    });
    // Only focus if overlay is actually visible
    if (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);
          this.traceEvent('overlay_hide', {
            component: componentName(component),
            stackSize: this.overlayStack.length,
            overlays: this.describeOverlaysForTrace()
          });
          // 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) => {
        if (entry.hidden === hidden) return;
        entry.hidden = hidden;
        this.traceEvent('overlay_set_hidden', {
          component: componentName(component),
          hidden,
          overlays: this.describeOverlaysForTrace()
        });
        // 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 (this.isOverlayVisible(entry)) {
            this.setFocus(component);
          }
        }
        this.requestRender();
      },
      isHidden: () => entry.hidden
    };
  }
  /** Hide the topmost overlay and restore previous focus. */
  hideOverlay() {
    const overlay = this.overlayStack.pop();
    if (!overlay) return;
    this.traceEvent('overlay_hide_top', {
      component: componentName(overlay.component),
      stackSize: this.overlayStack.length,
      overlays: this.describeOverlaysForTrace()
    });
    // 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() {
    return this.overlayStack.some((o) => this.isOverlayVisible(o));
  }
  /** Check if an overlay entry is currently visible */
  isOverlayVisible(entry) {
    if (entry.hidden) return false;
    if (entry.options?.visible) {
      return entry.options.visible(this.terminal.columns, this.terminal.rows);
    }
    return true;
  }
  /** Find the topmost visible overlay, if any */
  getTopmostVisibleOverlay() {
    for (let i = this.overlayStack.length - 1; i >= 0; i--) {
      if (this.isOverlayVisible(this.overlayStack[i])) {
        return this.overlayStack[i];
      }
    }
    return undefined;
  }
  invalidate() {
    super.invalidate();
    for (const overlay of this.overlayStack) overlay.component.invalidate?.();
  }
  start() {
    this.stopped = false;
    this.traceEvent('tui_start', {
      width: this.terminal.columns,
      height: this.terminal.rows,
      showHardwareCursor: this.showHardwareCursor,
      clearOnShrink: this.clearOnShrink
    });
    this.terminal.start(
      (data) => this.handleInput(data),
      () => this.requestRender()
    );
    this.terminal.hideCursor();
    this.queryCellSize();
    this.requestRender();
  }
  addInputListener(listener) {
    this.inputListeners.add(listener);
    return () => {
      this.inputListeners.delete(listener);
    };
  }
  removeInputListener(listener) {
    this.inputListeners.delete(listener);
  }
  queryCellSize() {
    // 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() {
    this.stopped = true;
    this.traceEvent('tui_stop', {
      previousLineCount: this.previousLines.length,
      cursorRow: this.cursorRow,
      hardwareCursorRow: this.hardwareCursorRow,
      maxLinesRendered: this.maxLinesRendered,
      previousViewportTop: this.previousViewportTop
    });
    // Move the cursor below the rendered UI before handing control back to the
    // terminal. Alternate-buffer terminals will discard this on stop; generic
    // terminals still need it to avoid overwriting the last rendered row.
    if (this.previousLines.length > 0) {
      const targetRow = this.previousLines.length;
      this.terminal.moveBy(targetRow - this.hardwareCursorRow);
      this.terminal.write('\r\n');
    }
    this.terminal.showCursor();
    this.terminal.stop();
  }
  requestRender(force = false) {
    this.traceEvent('render_requested', {
      force,
      suspended: this.renderSuspended,
      alreadyRequested: this.renderRequested,
      previousLineCount: this.previousLines.length,
      maxLinesRendered: this.maxLinesRendered,
      overlayCount: this.overlayStack.length,
      focusedComponent: componentName(this.focusedComponent)
    });
    if (this.renderSuspended && !force) return;
    if (force) {
      this.renderSuspended = false;
      this.previousLines = [];
      this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
      this.previousHeight = 0;
      this.cursorRow = 0;
      this.hardwareCursorRow = 0;
      this.maxLinesRendered = 0;
      this.previousViewportTop = 0;
    }
    if (this.renderRequested) return;
    this.renderRequested = true;
    process.nextTick(() => {
      this.renderRequested = false;
      if (this.renderSuspended) return;
      this.doRender();
    });
  }
  handleInput(data) {
    const originalInput = data;
    this.traceEvent('input_received', {
      inputRaw: originalInput,
      focusedComponent: componentName(this.focusedComponent),
      inputListenerCount: this.inputListeners.size,
      overlays: this.describeOverlaysForTrace()
    });
    if (this.inputListeners.size > 0) {
      let current = data;
      for (const listener of this.inputListeners) {
        const result = listener(current);
        if (result?.consume) {
          this.traceEvent('input_consumed', {
            inputRaw: originalInput,
            focusedComponent: componentName(this.focusedComponent)
          });
          return;
        }
        if (result?.data !== undefined) {
          current = result.data;
        }
      }
      if (current.length === 0) {
        this.traceEvent('input_filtered_empty', {
          inputRaw: originalInput,
          focusedComponent: componentName(this.focusedComponent)
        });
        return;
      }
      data = current;
    }
    // If we're waiting for cell size response, buffer input and parse
    if (this.cellSizeQueryPending) {
      this.inputBuffer += data;
      const filtered = this.parseCellSizeResponse();
      if (filtered.length === 0) return;
      data = filtered;
    }
    // Global debug key handler (Shift+Ctrl+D)
    if (matchesKey(data, 'shift+ctrl+d') && this.onDebug) {
      this.traceEvent('input_debug_command', {
        inputRaw: data
      });
      this.onDebug();
      return;
    }
    // If focused component is an overlay, verify it's still visible
    // (visibility can change due to terminal resize or visible() callback)
    const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);
    if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
      // Focused overlay is no longer visible, redirect to topmost visible overlay
      const topVisible = this.getTopmostVisibleOverlay();
      if (topVisible) {
        this.setFocus(topVisible.component);
      } else {
        // No visible overlays, restore to preFocus
        this.setFocus(focusedOverlay.preFocus);
      }
    }
    // Pass input to focused component (including Ctrl+C)
    // The focused component can decide how to handle Ctrl+C
    if (this.focusedComponent?.handleInput) {
      // Filter out key release events unless component opts in
      if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
        this.traceEvent('input_key_release_ignored', {
          inputRaw: data,
          focusedComponent: componentName(this.focusedComponent)
        });
        return;
      }
      this.focusedComponent.handleInput(data);
      this.traceEvent('input_forwarded', {
        inputRaw: data,
        focusedComponent: componentName(this.focusedComponent)
      });
      this.requestRender();
    }
  }
  parseCellSizeResponse() {
    // Response format: ESC [ 6 ; height ; width t
    // Match the response pattern
    const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
    const match = this.inputBuffer.match(responsePattern);
    if (match) {
      const heightPx = parseInt(match[1], 10);
      const widthPx = parseInt(match[2], 10);
      if (heightPx > 0 && widthPx > 0) {
        setCellDimensions({ widthPx, heightPx });
        // Invalidate all components so images re-render with correct dimensions
        this.invalidate();
        this.requestRender();
      }
      // Remove the response from buffer
      this.inputBuffer = this.inputBuffer.replace(responsePattern, '');
      this.cellSizeQueryPending = false;
    }
    // Check if we have a partial cell size response starting (wait for more data)
    // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
    const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
    if (partialCellSizePattern.test(this.inputBuffer)) {
      // Check if it's actually a complete different escape sequence (ends with a letter)
      // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
      const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
      if (!/[a-zA-Z~]/.test(lastChar)) {
        // Doesn't end with a terminator, might be incomplete - wait for more
        return '';
      }
    }
    // No cell size response found, return buffered data as user input
    const result = this.inputBuffer;
    this.inputBuffer = '';
    this.cellSizeQueryPending = false; // Give up waiting
    return result;
  }
  /**
   * Resolve overlay layout from options.
   * Returns { width, row, col, maxHeight } for rendering.
   */
  resolveOverlayLayout(options, overlayHeight, termWidth, termHeight) {
    const opt = options ?? {};
    // Parse margin (clamp to non-negative)
    const margin =
      typeof opt.margin === 'number'
        ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
        : (opt.margin ?? {});
    const marginTop = Math.max(0, margin.top ?? 0);
    const marginRight = Math.max(0, margin.right ?? 0);
    const marginBottom = Math.max(0, margin.bottom ?? 0);
    const marginLeft = Math.max(0, margin.left ?? 0);
    // Available space after margins
    const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
    const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
    // === Resolve width ===
    let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
    // Apply minWidth
    if (opt.minWidth !== undefined) {
      width = Math.max(width, opt.minWidth);
    }
    // Clamp to available space
    width = Math.max(1, Math.min(width, availWidth));
    // === Resolve maxHeight ===
    let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
    // Clamp to available space
    if (maxHeight !== undefined) {
      maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
    }
    // Effective overlay height (may be clamped by maxHeight)
    const effectiveHeight =
      maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
    // === Resolve position ===
    let row;
    let col;
    if (opt.row !== undefined) {
      if (typeof opt.row === 'string') {
        // Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
        const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
        if (match) {
          const maxRow = Math.max(0, availHeight - effectiveHeight);
          const percent = parseFloat(match[1]) / 100;
          row = marginTop + Math.floor(maxRow * percent);
        } else {
          // Invalid format, fall back to center
          row = this.resolveAnchorRow('center', effectiveHeight, availHeight, marginTop);
        }
      } else {
        // Absolute row position
        row = opt.row;
      }
    } else {
      // Anchor-based (default: center)
      const anchor = opt.anchor ?? 'center';
      row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
    }
    if (opt.col !== undefined) {
      if (typeof opt.col === 'string') {
        // Percentage: 0% = left, 100% = right (overlay stays within bounds)
        const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
        if (match) {
          const maxCol = Math.max(0, availWidth - width);
          const percent = parseFloat(match[1]) / 100;
          col = marginLeft + Math.floor(maxCol * percent);
        } else {
          // Invalid format, fall back to center
          col = this.resolveAnchorCol('center', width, availWidth, marginLeft);
        }
      } else {
        // Absolute column position
        col = opt.col;
      }
    } else {
      // Anchor-based (default: center)
      const anchor = opt.anchor ?? 'center';
      col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);
    }
    // Apply offsets
    if (opt.offsetY !== undefined) row += opt.offsetY;
    if (opt.offsetX !== undefined) col += opt.offsetX;
    // Clamp to terminal bounds (respecting margins)
    row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
    col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
    return { width, row, col, maxHeight };
  }
  resolveAnchorRow(anchor, height, availHeight, marginTop) {
    switch (anchor) {
      case 'top-left':
      case 'top-center':
      case 'top-right':
        return marginTop;
      case 'bottom-left':
      case 'bottom-center':
      case 'bottom-right':
        return marginTop + availHeight - height;
      case 'left-center':
      case 'center':
      case 'right-center':
        return marginTop + Math.floor((availHeight - height) / 2);
    }
  }
  resolveAnchorCol(anchor, width, availWidth, marginLeft) {
    switch (anchor) {
      case 'top-left':
      case 'left-center':
      case 'bottom-left':
        return marginLeft;
      case 'top-right':
      case 'right-center':
      case 'bottom-right':
        return marginLeft + availWidth - width;
      case 'top-center':
      case 'center':
      case 'bottom-center':
        return marginLeft + Math.floor((availWidth - width) / 2);
    }
  }
  /** Composite all overlays into content lines (in stack order, later = on top). */
  compositeOverlays(lines, termWidth, termHeight) {
    if (this.overlayStack.length === 0) return lines;
    const result = [...lines];
    // Pre-render all visible overlays and calculate positions
    const rendered = [];
    let minLinesNeeded = result.length;
    for (const entry of this.overlayStack) {
      // Skip invisible overlays (hidden or visible() returns false)
      if (!this.isOverlayVisible(entry)) continue;
      const { component, options } = entry;
      // Get layout with height=0 first to determine width and maxHeight
      // (width and maxHeight don't depend on overlay height)
      const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
      // Render component at calculated width
      let overlayLines = component.render(width);
      // Apply maxHeight if specified
      if (maxHeight !== undefined && overlayLines.length > maxHeight) {
        overlayLines = overlayLines.slice(0, maxHeight);
      }
      if (options?.opaque && maxHeight !== undefined && overlayLines.length < maxHeight) {
        overlayLines = overlayLines.concat(
          Array.from({ length: maxHeight - overlayLines.length }, () => '')
        );
      }
      // Get final row/col with actual overlay height
      const { row, col } = this.resolveOverlayLayout(
        options,
        overlayLines.length,
        termWidth,
        termHeight
      );
      rendered.push({ overlayLines, row, col, w: width });
      minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
    }
    // Ensure result covers the terminal working area to keep overlay positioning stable across resizes.
    // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent.
    const workingHeight = Math.max(this.maxLinesRendered, minLinesNeeded);
    // Extend result with empty lines if content is too short for overlay placement or working area
    while (result.length < workingHeight) {
      result.push('');
    }
    const viewportStart = Math.max(0, workingHeight - termHeight);
    // Track which lines were modified for final verification
    const modifiedLines = new Set();
    // Composite each overlay
    for (const { overlayLines, row, col, w } of rendered) {
      for (let i = 0; i < overlayLines.length; i++) {
        const idx = viewportStart + row + i;
        if (idx >= 0 && idx < result.length) {
          // Defensive: truncate overlay line to declared width before compositing
          // (components should already respect width, but this ensures it)
          const truncatedOverlayLine =
            visibleWidth(overlayLines[i]) > w
              ? sliceByColumn(overlayLines[i], 0, w, true)
              : overlayLines[i];
          result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
          modifiedLines.add(idx);
        }
      }
    }
    // Final verification: ensure no composited line exceeds terminal width
    // This is a belt-and-suspenders safeguard - compositeLineAt should already
    // guarantee this, but we verify here to prevent crashes from any edge cases
    // Only check lines that were actually modified (optimization)
    for (const idx of modifiedLines) {
      const lineWidth = visibleWidth(result[idx]);
      if (lineWidth > termWidth) {
        result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
      }
    }
    return result;
  }
  static SEGMENT_RESET = '\x1b[0m\x1b]8;;\x07';
  applyLineResets(lines) {
    const reset = TUI.SEGMENT_RESET;
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      if (!isImageLine(line)) {
        lines[i] = line + reset;
      }
    }
    return lines;
  }
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
  compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
    if (isImageLine(baseLine)) return baseLine;
    // Single pass through baseLine extracts both before and after segments
    const afterStart = startCol + overlayWidth;
    const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
    // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
    const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
    // Pad segments to target widths
    const beforePad = Math.max(0, startCol - base.beforeWidth);
    const overlayPad = Math.max(0, overlayWidth - overlay.width);
    const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
    const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
    const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
    const afterPad = Math.max(0, afterTarget - base.afterWidth);
    // Compose result
    const r = TUI.SEGMENT_RESET;
    const result =
      base.before +
      ' '.repeat(beforePad) +
      r +
      overlay.text +
      ' '.repeat(overlayPad) +
      r +
      base.after +
      ' '.repeat(afterPad);
    // CRITICAL: Always verify and truncate to terminal width.
    // This is the final safeguard against width overflow which would crash the TUI.
    // Width tracking can drift from actual visible width due to:
    // - Complex ANSI/OSC sequences (hyperlinks, colors)
    // - Wide characters at segment boundaries
    // - Edge cases in segment extraction
    const resultWidth = visibleWidth(result);
    if (resultWidth <= totalWidth) {
      return result;
    }
    // Truncate with strict=true to ensure we don't exceed totalWidth
    return sliceByColumn(result, 0, totalWidth, true);
  }
  /**
   * Find and extract cursor position from rendered lines.
   * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
   * Only scans the bottom terminal height lines (visible viewport).
   * @param lines - Rendered lines to search
   * @param height - Terminal height (visible viewport size)
   * @returns Cursor position { row, col } or null if no marker found
   */
  extractCursorPosition(lines, height) {
    // Only scan the bottom `height` lines (visible viewport)
    const viewportTop = Math.max(0, lines.length - height);
    for (let row = lines.length - 1; row >= viewportTop; row--) {
      const line = lines[row];
      const markerIndex = line.indexOf(CURSOR_MARKER);
      if (markerIndex !== -1) {
        // Calculate visual column (width of text before marker)
        const beforeMarker = line.slice(0, markerIndex);
        const col = visibleWidth(beforeMarker);
        // Strip marker from the line
        lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
        return { row, col };
      }
    }
    return null;
  }
  constrainLineWidths(lines, width) {
    return lines.map((line) => {
      if (isImageLine(line) || visibleWidth(line) <= width) {
        return line;
      }
      return sliceByColumn(line, 0, width, true);
    });
  }
  doRender() {
    if (this.stopped) return;
    const frameId = ++this.renderSequence;
    const width = this.terminal.columns;
    const height = this.terminal.rows;
    const previousLinesAtStart = this.previousLines;
    const previousWidthAtStart = this.previousWidth;
    const previousHeightAtStart = this.previousHeight;
    const maxLinesRenderedAtStart = this.maxLinesRendered;
    const previousViewportTopAtStart = this.previousViewportTop;
    const cursorRowAtStart = this.cursorRow;
    const hardwareCursorRowAtStart = this.hardwareCursorRow;
    let viewportTop = Math.max(0, this.maxLinesRendered - height);
    let prevViewportTop = this.previousViewportTop;
    let hardwareCursorRow = this.hardwareCursorRow;
    const computeLineDiff = (targetRow) => {
      const currentScreenRow = hardwareCursorRow - prevViewportTop;
      const targetScreenRow = targetRow - viewportTop;
      return targetScreenRow - currentScreenRow;
    };
    // Render all components to get new lines
    let newLines = this.render(width);
    // Composite overlays into the rendered lines (before differential compare)
    if (this.overlayStack.length > 0) {
      newLines = this.compositeOverlays(newLines, width, height);
    }
    newLines = this.constrainLineWidths(newLines, width);
    // Extract cursor position before applying line resets (marker must be found first)
    const cursorPos = this.extractCursorPosition(newLines, height);
    newLines = this.applyLineResets(newLines);
    // Width changed - need full re-render (line wrapping changes)
    const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
    const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
    // Find first and last changed lines early so every branch can emit the same trace shape.
    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;
    const traceFrame = (decision, extra = {}) => {
      if (!this.renderTrace) return;
      this.traceFrame({
        frameId,
        decision,
        width,
        height,
        previousWidth: previousWidthAtStart,
        previousHeight: previousHeightAtStart,
        previousLineCount: previousLinesAtStart.length,
        newLineCount: newLines.length,
        maxLinesRenderedStart: maxLinesRenderedAtStart,
        maxLinesRenderedEnd: this.maxLinesRendered,
        previousViewportTopStart: previousViewportTopAtStart,
        previousViewportTopEnd: this.previousViewportTop,
        viewportTopStart: Math.max(0, maxLinesRenderedAtStart - (previousHeightAtStart || height)),
        cursorRowStart: cursorRowAtStart,
        cursorRowEnd: this.cursorRow,
        hardwareCursorRowStart: hardwareCursorRowAtStart,
        hardwareCursorRowEnd: this.hardwareCursorRow,
        focusedComponent: componentName(this.focusedComponent),
        overlays: this.describeOverlaysForTrace(),
        cursorPos,
        widthChanged,
        heightChanged,
        firstChanged,
        lastChanged,
        appendedLines,
        appendStart,
        previousLinesRaw: previousLinesAtStart,
        newLinesRaw: newLines,
        rendererPreviousViewportTop: previousViewportTopAtStart,
        rendererNextViewportTop: this.previousViewportTop,
        ...extra
      });
    };
    const writeLines = (lines) => {
      let output = '';
      for (let i = 0; i < lines.length; i++) {
        if (i > 0) output += '\r\n';
        output += lines[i];
      }
      return output;
    };
    const fullRender = (clear, decision, extra = {}) => {
      this.fullRedrawCount += 1;
      let buffer = '\x1b[?2026h'; // Begin synchronized output
      if (clear) buffer += '\x1b[2J\x1b[H'; // Clear screen and home within the alternate buffer.
      buffer += writeLines(newLines);
      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.previousLines = newLines;
      this.previousWidth = width;
      this.previousHeight = height;
      traceFrame(decision, {
        clear,
        fullRedrawCount: this.fullRedrawCount,
        bufferRaw: buffer,
        ...extra
      });
      this.positionHardwareCursor(cursorPos, newLines.length);
    };
    // First render - just output everything without clearing (assumes clean screen)
    if (this.previousLines.length === 0 && !widthChanged) {
      fullRender(false, 'full_render_first');
      return;
    }
    // Width changed - full re-render (line wrapping changes)
    if (widthChanged) {
      fullRender(true, 'full_render_width_changed', { previousWidth: previousWidthAtStart });
      return;
    }
    // Height changed - viewport math changed, so redraw against the new geometry.
    if (heightChanged) {
      fullRender(true, 'full_render_height_changed', { previousHeight: previousHeightAtStart });
      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
    ) {
      fullRender(true, 'full_render_clear_on_shrink');
      return;
    }
    // No changes - but still need to update hardware cursor position if it moved
    if (firstChanged === -1) {
      this.positionHardwareCursor(cursorPos, newLines.length);
      this.previousLines = newLines;
      this.previousWidth = width;
      this.previousHeight = height;
      this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
      traceFrame('no_changes');
      return;
    }
    // All changes are in deleted lines (nothing to render, just clear)
    if (firstChanged >= newLines.length) {
      let deletedBuffer = '';
      let extraLines = 0;
      let targetRow = Math.max(0, newLines.length - 1);
      let lineDiff = 0;
      if (this.previousLines.length > newLines.length) {
        let buffer = '\x1b[?2026h';
        // Move to end of new content (clamp to 0 for empty content)
        targetRow = Math.max(0, newLines.length - 1);
        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
        extraLines = this.previousLines.length - newLines.length;
        if (extraLines > height) {
          fullRender(true, 'full_render_deleted_extra_lines_over_height', { extraLines });
          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);
        deletedBuffer = buffer;
        this.cursorRow = targetRow;
        this.hardwareCursorRow = targetRow;
      }
      this.previousLines = newLines;
      this.previousWidth = width;
      this.previousHeight = height;
      this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
      traceFrame('deleted_lines_clear', {
        targetRow,
        lineDiff,
        extraLines,
        bufferRaw: deletedBuffer
      });
      this.positionHardwareCursor(cursorPos, newLines.length);
      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
      fullRender(true, 'full_render_change_above_previous_viewport', {
        previousContentViewportTop
      });
      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;
    let scroll = 0;
    let moveToBottom = 0;
    let currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
    if (moveTargetRow > prevViewportBottom) {
      currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
      moveToBottom = height - 1 - currentScreenRow;
      if (moveToBottom > 0) {
        buffer += `\x1b[${moveToBottom}B`;
      }
      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
      const line = newLines[i];
      const isImage = isImageLine(line);
      if (!isImage && visibleWidth(line) > width) {
        // Log all lines to crash file for debugging
        const crashLogPath = path.join(os.homedir(), '.pi', 'agent', 'pi-crash.log');
        const crashData = [
          `Crash at ${new Date().toISOString()}`,
          `Terminal width: ${width}`,
          `Line ${i} visible width: ${visibleWidth(line)}`,
          '',
          '=== All rendered lines ===',
          ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
          ''
        ].join('\n');
        fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
        fs.writeFileSync(crashLogPath, crashData);
        // Clean up terminal state before throwing
        this.stop();
        const errorMsg = [
          `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
          '',
          'This is likely caused by a custom TUI component not truncating its output.',
          'Use visibleWidth() to measure and truncateToWidth() to truncate lines.',
          '',
          `Debug log written to: ${crashLogPath}`
        ].join('\n');
        throw new Error(errorMsg);
      }
      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
    // 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);
    this.previousLines = newLines;
    this.previousWidth = width;
    this.previousHeight = height;
    if (this.renderTrace) {
      traceFrame('diff_render', {
        prevViewportBottom,
        moveTargetRow,
        currentScreenRow,
        moveToBottom,
        scroll,
        lineDiff,
        renderEnd,
        finalCursorRow,
        bufferRaw: buffer
      });
    }
    // Position hardware cursor for IME
    this.positionHardwareCursor(cursorPos, newLines.length);
  }
  /**
   * Position the hardware cursor for IME candidate window.
   * @param cursorPos The cursor position extracted from rendered output, or null
   * @param totalLines Total number of rendered lines
   */
  positionHardwareCursor(cursorPos, totalLines) {
    if (!cursorPos || totalLines <= 0) {
      this.terminal.hideCursor();
      this.traceEvent('cursor_position', {
        action: 'hide',
        reason: !cursorPos ? 'missing_cursor_marker' : 'empty_render',
        totalLines,
        hardwareCursorRow: this.hardwareCursorRow
      });
      return;
    }
    // Clamp cursor position to valid range
    const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
    const targetCol = Math.max(0, cursorPos.col);
    // Move cursor from current position to target
    const rowDelta = targetRow - this.hardwareCursorRow;
    let buffer = '';
    if (rowDelta > 0) {
      buffer += `\x1b[${rowDelta}B`; // Move down
    } else if (rowDelta < 0) {
      buffer += `\x1b[${-rowDelta}A`; // Move up
    }
    // Move to absolute column (1-indexed)
    buffer += `\x1b[${targetCol + 1}G`;
    if (buffer) {
      this.terminal.write(buffer);
    }
    this.traceEvent('cursor_position', {
      action: this.showHardwareCursor ? 'show' : 'hide',
      targetRow,
      targetCol,
      rowDelta,
      totalLines,
      width: this.terminal.columns,
      height: this.terminal.rows,
      bufferRaw: buffer
    });
    this.hardwareCursorRow = targetRow;
    if (this.showHardwareCursor) {
      this.terminal.showCursor();
    } else {
      this.terminal.hideCursor();
    }
  }
}
//# sourceMappingURL=tui.js.map
