/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import stripAnsi from 'strip-ansi';
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo } from 'react';
import stringWidth from 'string-width';
import { unescapePath } from '@gemini-cli/core';

export type Direction =
  | 'left'
  | 'right'
  | 'up'
  | 'down'
  | 'wordLeft'
  | 'wordRight'
  | 'home'
  | 'end';

// TODO(jacob314): refactor so all edit operations to be part of this list.
// This makes it robust for clients to apply multiple edit operations without
// having to carefully reason about how React manages state.
type UpdateOperation =
  | { type: 'insert'; payload: string }
  | { type: 'backspace' };

// Simple helper for word‑wise ops.
function isWordChar(ch: string | undefined): boolean {
  if (ch === undefined) {
    return false;
  }
  return !/[\s,.;!?]/.test(ch);
}

/**
 * Strip characters that can break terminal rendering.
 *
 * Strip ANSI escape codes and control characters except for line breaks.
 * Control characters such as delete break terminal UI rendering.
 */
function stripUnsafeCharacters(str: string): string {
  const stripped = stripAnsi(str);
  return toCodePoints(stripAnsi(stripped))
    .filter((char) => {
      if (char.length > 1) return false;
      const code = char.codePointAt(0);
      if (code === undefined) {
        return false;
      }
      const isUnsafe =
        code === 127 || (code <= 31 && code !== 13 && code !== 10);
      return !isUnsafe;
    })
    .join('');
}

export interface Viewport {
  height: number;
  width: number;
}

function clamp(v: number, min: number, max: number): number {
  return v < min ? min : v > max ? max : v;
}

/*
 * -------------------------------------------------------------------------
 *  Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
 *  code units so that surrogate‑pair emoji count as one "column".)
 * ---------------------------------------------------------------------- */

export function toCodePoints(str: string): string[] {
  // [...str] or Array.from both iterate by UTF‑32 code point, handling
  // surrogate pairs correctly.
  return Array.from(str);
}

export function cpLen(str: string): number {
  return toCodePoints(str).length;
}

export function cpSlice(str: string, start: number, end?: number): string {
  // Slice by code‑point indices and re‑join.
  const arr = toCodePoints(str).slice(start, end);
  return arr.join('');
}

/* -------------------------------------------------------------------------
 *  Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
 * ---------------------------------------------------------------------- */

// Enable verbose logging only when requested via env var.
const DEBUG =
  process.env['TEXTBUFFER_DEBUG'] === '1' ||
  process.env['TEXTBUFFER_DEBUG'] === 'true';

function dbg(...args: unknown[]): void {
  if (DEBUG) {
    console.log('[TextBuffer]', ...args);
  }
}

/* ────────────────────────────────────────────────────────────────────────── */

interface UseTextBufferProps {
  initialText?: string;
  initialCursorOffset?: number;
  viewport: Viewport; // Viewport dimensions needed for scrolling
  stdin?: NodeJS.ReadStream | null; // For external editor
  setRawMode?: (mode: boolean) => void; // For external editor
  onChange?: (text: string) => void; // Callback for when text changes
  isValidPath: (path: string) => boolean;
}

interface UndoHistoryEntry {
  lines: string[];
  cursorRow: number;
  cursorCol: number;
}

function calculateInitialCursorPosition(
  initialLines: string[],
  offset: number,
): [number, number] {
  let remainingChars = offset;
  let row = 0;
  while (row < initialLines.length) {
    const lineLength = cpLen(initialLines[row]);
    // Add 1 for the newline character (except for the last line)
    const totalCharsInLineAndNewline =
      lineLength + (row < initialLines.length - 1 ? 1 : 0);

    if (remainingChars <= lineLength) {
      // Cursor is on this line
      return [row, remainingChars];
    }
    remainingChars -= totalCharsInLineAndNewline;
    row++;
  }
  // Offset is beyond the text, place cursor at the end of the last line
  if (initialLines.length > 0) {
    const lastRow = initialLines.length - 1;
    return [lastRow, cpLen(initialLines[lastRow])];
  }
  return [0, 0]; // Default for empty text
}

export function offsetToLogicalPos(
  text: string,
  offset: number,
): [number, number] {
  let row = 0;
  let col = 0;
  let currentOffset = 0;

  if (offset === 0) return [0, 0];

  const lines = text.split('\n');
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const lineLength = cpLen(line);
    const lineLengthWithNewline = lineLength + (i < lines.length - 1 ? 1 : 0);

    if (offset <= currentOffset + lineLength) {
      // Check against lineLength first
      row = i;
      col = offset - currentOffset;
      return [row, col];
    } else if (offset <= currentOffset + lineLengthWithNewline) {
      // Check if offset is the newline itself
      row = i;
      col = lineLength; // Position cursor at the end of the current line content
      // If the offset IS the newline, and it's not the last line, advance to next line, col 0
      if (
        offset === currentOffset + lineLengthWithNewline &&
        i < lines.length - 1
      ) {
        return [i + 1, 0];
      }
      return [row, col]; // Otherwise, it's at the end of the current line content
    }
    currentOffset += lineLengthWithNewline;
  }

  // If offset is beyond the text length, place cursor at the end of the last line
  // or [0,0] if text is empty
  if (lines.length > 0) {
    row = lines.length - 1;
    col = cpLen(lines[row]);
  } else {
    row = 0;
    col = 0;
  }
  return [row, col];
}

// Helper to calculate visual lines and map cursor positions
function calculateVisualLayout(
  logicalLines: string[],
  logicalCursor: [number, number],
  viewportWidth: number,
): {
  visualLines: string[];
  visualCursor: [number, number];
  logicalToVisualMap: Array<Array<[number, number]>>; // For each logical line, an array of [visualLineIndex, startColInLogical]
  visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical]
} {
  const visualLines: string[] = [];
  const logicalToVisualMap: Array<Array<[number, number]>> = [];
  const visualToLogicalMap: Array<[number, number]> = [];
  let currentVisualCursor: [number, number] = [0, 0];

  logicalLines.forEach((logLine, logIndex) => {
    logicalToVisualMap[logIndex] = [];
    if (logLine.length === 0) {
      // Handle empty logical line
      logicalToVisualMap[logIndex].push([visualLines.length, 0]);
      visualToLogicalMap.push([logIndex, 0]);
      visualLines.push('');
      if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
        currentVisualCursor = [visualLines.length - 1, 0];
      }
    } else {
      // Non-empty logical line
      let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
      const codePointsInLogLine = toCodePoints(logLine);

      while (currentPosInLogLine < codePointsInLogLine.length) {
        let currentChunk = '';
        let currentChunkVisualWidth = 0;
        let numCodePointsInChunk = 0;
        let lastWordBreakPoint = -1; // Index in codePointsInLogLine for word break
        let numCodePointsAtLastWordBreak = 0;

        // Iterate through code points to build the current visual line (chunk)
        for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
          const char = codePointsInLogLine[i];
          const charVisualWidth = stringWidth(char);

          if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
            // Character would exceed viewport width
            if (
              lastWordBreakPoint !== -1 &&
              numCodePointsAtLastWordBreak > 0 &&
              currentPosInLogLine + numCodePointsAtLastWordBreak < i
            ) {
              // We have a valid word break point to use, and it's not the start of the current segment
              currentChunk = codePointsInLogLine
                .slice(
                  currentPosInLogLine,
                  currentPosInLogLine + numCodePointsAtLastWordBreak,
                )
                .join('');
              numCodePointsInChunk = numCodePointsAtLastWordBreak;
            } else {
              // No word break, or word break is at the start of this potential chunk, or word break leads to empty chunk.
              // Hard break: take characters up to viewportWidth, or just the current char if it alone is too wide.
              if (
                numCodePointsInChunk === 0 &&
                charVisualWidth > viewportWidth
              ) {
                // Single character is wider than viewport, take it anyway
                currentChunk = char;
                numCodePointsInChunk = 1;
              } else if (
                numCodePointsInChunk === 0 &&
                charVisualWidth <= viewportWidth
              ) {
                // This case should ideally be caught by the next iteration if the char fits.
                // If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
                // then numCodePointsInChunk would not be 0.
                // This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
                // If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
              }
            }
            break; // Break from inner loop to finalize this chunk
          }

          currentChunk += char;
          currentChunkVisualWidth += charVisualWidth;
          numCodePointsInChunk++;

          // Check for word break opportunity (space)
          if (char === ' ') {
            lastWordBreakPoint = i; // Store code point index of the space
            // Store the state *before* adding the space, if we decide to break here.
            numCodePointsAtLastWordBreak = numCodePointsInChunk - 1; // Chars *before* the space
          }
        }

        // If the inner loop completed without breaking (i.e., remaining text fits)
        // or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
        if (
          numCodePointsInChunk === 0 &&
          currentPosInLogLine < codePointsInLogLine.length
        ) {
          // This can happen if the very first character considered for a new visual line is wider than the viewport.
          // In this case, we take that single character.
          const firstChar = codePointsInLogLine[currentPosInLogLine];
          currentChunk = firstChar;
          numCodePointsInChunk = 1; // Ensure we advance
        }

        // If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
        // it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
        if (
          numCodePointsInChunk === 0 &&
          currentPosInLogLine < codePointsInLogLine.length
        ) {
          // Force advance by one character to prevent infinite loop if something went wrong
          currentChunk = codePointsInLogLine[currentPosInLogLine];
          numCodePointsInChunk = 1;
        }

        logicalToVisualMap[logIndex].push([
          visualLines.length,
          currentPosInLogLine,
        ]);
        visualToLogicalMap.push([logIndex, currentPosInLogLine]);
        visualLines.push(currentChunk);

        // Cursor mapping logic
        // Note: currentPosInLogLine here is the start of the currentChunk within the logical line.
        if (logIndex === logicalCursor[0]) {
          const cursorLogCol = logicalCursor[1]; // This is a code point index
          if (
            cursorLogCol >= currentPosInLogLine &&
            cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk
          ) {
            currentVisualCursor = [
              visualLines.length - 1,
              cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line
            ];
          } else if (
            cursorLogCol === currentPosInLogLine + numCodePointsInChunk &&
            numCodePointsInChunk > 0
          ) {
            // Cursor is exactly at the end of this non-empty chunk
            currentVisualCursor = [
              visualLines.length - 1,
              numCodePointsInChunk,
            ];
          }
        }

        const logicalStartOfThisChunk = currentPosInLogLine;
        currentPosInLogLine += numCodePointsInChunk;

        // If the chunk processed did not consume the entire logical line,
        // and the character immediately following the chunk is a space,
        // advance past this space as it acted as a delimiter for word wrapping.
        if (
          logicalStartOfThisChunk + numCodePointsInChunk <
            codePointsInLogLine.length &&
          currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
          codePointsInLogLine[currentPosInLogLine] === ' '
        ) {
          currentPosInLogLine++;
        }
      }
      // After all chunks of a non-empty logical line are processed,
      // if the cursor is at the very end of this logical line, update visual cursor.
      if (
        logIndex === logicalCursor[0] &&
        logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line
      ) {
        const lastVisualLineIdx = visualLines.length - 1;
        if (
          lastVisualLineIdx >= 0 &&
          visualLines[lastVisualLineIdx] !== undefined
        ) {
          currentVisualCursor = [
            lastVisualLineIdx,
            cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line
          ];
        }
      }
    }
  });

  // If the entire logical text was empty, ensure there's one empty visual line.
  if (
    logicalLines.length === 0 ||
    (logicalLines.length === 1 && logicalLines[0] === '')
  ) {
    if (visualLines.length === 0) {
      visualLines.push('');
      if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
      logicalToVisualMap[0].push([0, 0]);
      visualToLogicalMap.push([0, 0]);
    }
    currentVisualCursor = [0, 0];
  }
  // Handle cursor at the very end of the text (after all processing)
  // This case might be covered by the loop end condition now, but kept for safety.
  else if (
    logicalCursor[0] === logicalLines.length - 1 &&
    logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) &&
    visualLines.length > 0
  ) {
    const lastVisLineIdx = visualLines.length - 1;
    currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])];
  }

  return {
    visualLines,
    visualCursor: currentVisualCursor,
    logicalToVisualMap,
    visualToLogicalMap,
  };
}

export function useTextBuffer({
  initialText = '',
  initialCursorOffset = 0,
  viewport,
  stdin,
  setRawMode,
  onChange,
  isValidPath,
}: UseTextBufferProps): TextBuffer {
  const [lines, setLines] = useState<string[]>(() => {
    const l = initialText.split('\n');
    return l.length === 0 ? [''] : l;
  });

  const [[initialCursorRow, initialCursorCol]] = useState(() =>
    calculateInitialCursorPosition(lines, initialCursorOffset),
  );

  const [cursorRow, setCursorRow] = useState<number>(initialCursorRow);
  const [cursorCol, setCursorCol] = useState<number>(initialCursorCol);
  const [preferredCol, setPreferredCol] = useState<number | null>(null); // Visual preferred col

  const [undoStack, setUndoStack] = useState<UndoHistoryEntry[]>([]);
  const [redoStack, setRedoStack] = useState<UndoHistoryEntry[]>([]);
  const historyLimit = 100;

  const [clipboard, setClipboard] = useState<string | null>(null);
  const [selectionAnchor, setSelectionAnchor] = useState<
    [number, number] | null
  >(null); // Logical selection

  // Visual state
  const [visualLines, setVisualLines] = useState<string[]>(['']);
  const [visualCursor, setVisualCursor] = useState<[number, number]>([0, 0]);
  const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
  const [logicalToVisualMap, setLogicalToVisualMap] = useState<
    Array<Array<[number, number]>>
  >([]);
  const [visualToLogicalMap, setVisualToLogicalMap] = useState<
    Array<[number, number]>
  >([]);

  const currentLine = useCallback(
    (r: number): string => lines[r] ?? '',
    [lines],
  );
  const currentLineLen = useCallback(
    (r: number): number => cpLen(currentLine(r)),
    [currentLine],
  );

  // Recalculate visual layout whenever logical lines or viewport width changes
  useEffect(() => {
    const layout = calculateVisualLayout(
      lines,
      [cursorRow, cursorCol],
      viewport.width,
    );
    setVisualLines(layout.visualLines);
    setVisualCursor(layout.visualCursor);
    setLogicalToVisualMap(layout.logicalToVisualMap);
    setVisualToLogicalMap(layout.visualToLogicalMap);
  }, [lines, cursorRow, cursorCol, viewport.width]);

  // Update visual scroll (vertical)
  useEffect(() => {
    const { height } = viewport;
    let newVisualScrollRow = visualScrollRow;

    if (visualCursor[0] < visualScrollRow) {
      newVisualScrollRow = visualCursor[0];
    } else if (visualCursor[0] >= visualScrollRow + height) {
      newVisualScrollRow = visualCursor[0] - height + 1;
    }
    if (newVisualScrollRow !== visualScrollRow) {
      setVisualScrollRow(newVisualScrollRow);
    }
  }, [visualCursor, visualScrollRow, viewport]);

  const pushUndo = useCallback(() => {
    dbg('pushUndo', { cursor: [cursorRow, cursorCol], text: lines.join('\n') });
    const snapshot = { lines: [...lines], cursorRow, cursorCol };
    setUndoStack((prev) => {
      const newStack = [...prev, snapshot];
      if (newStack.length > historyLimit) {
        newStack.shift();
      }
      return newStack;
    });
    setRedoStack([]);
  }, [lines, cursorRow, cursorCol, historyLimit]);

  const _restoreState = useCallback(
    (state: UndoHistoryEntry | undefined): boolean => {
      if (!state) return false;
      setLines(state.lines);
      setCursorRow(state.cursorRow);
      setCursorCol(state.cursorCol);
      return true;
    },
    [],
  );

  const text = lines.join('\n');

  useEffect(() => {
    if (onChange) {
      onChange(text);
    }
  }, [text, onChange]);

  const undo = useCallback((): boolean => {
    const state = undoStack[undoStack.length - 1];
    if (!state) return false;

    setUndoStack((prev) => prev.slice(0, -1));
    const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
    setRedoStack((prev) => [...prev, currentSnapshot]);
    return _restoreState(state);
  }, [undoStack, lines, cursorRow, cursorCol, _restoreState]);

  const redo = useCallback((): boolean => {
    const state = redoStack[redoStack.length - 1];
    if (!state) return false;

    setRedoStack((prev) => prev.slice(0, -1));
    const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
    setUndoStack((prev) => [...prev, currentSnapshot]);
    return _restoreState(state);
  }, [redoStack, lines, cursorRow, cursorCol, _restoreState]);

  const insertStr = useCallback(
    (str: string): boolean => {
      dbg('insertStr', { str, beforeCursor: [cursorRow, cursorCol] });
      if (str === '') return false;

      pushUndo();
      let normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
      normalised = stripUnsafeCharacters(normalised);

      const parts = normalised.split('\n');

      const newLines = [...lines];
      const lineContent = currentLine(cursorRow);
      const before = cpSlice(lineContent, 0, cursorCol);
      const after = cpSlice(lineContent, cursorCol);
      newLines[cursorRow] = before + parts[0];

      if (parts.length > 1) {
        // Adjusted condition for inserting multiple lines
        const remainingParts = parts.slice(1);
        const lastPartOriginal = remainingParts.pop() ?? '';
        newLines.splice(cursorRow + 1, 0, ...remainingParts);
        newLines.splice(
          cursorRow + parts.length - 1,
          0,
          lastPartOriginal + after,
        );
        setCursorRow(cursorRow + parts.length - 1);
        setCursorCol(cpLen(lastPartOriginal));
      } else {
        setCursorCol(cpLen(before) + cpLen(parts[0]));
      }
      setLines(newLines);
      setPreferredCol(null);
      return true;
    },
    [pushUndo, cursorRow, cursorCol, lines, currentLine, setPreferredCol],
  );

  const applyOperations = useCallback(
    (ops: UpdateOperation[]) => {
      if (ops.length === 0) return;

      const expandedOps: UpdateOperation[] = [];
      for (const op of ops) {
        if (op.type === 'insert') {
          let currentText = '';
          for (const char of toCodePoints(op.payload)) {
            if (char.codePointAt(0) === 127) {
              // \x7f
              if (currentText.length > 0) {
                expandedOps.push({ type: 'insert', payload: currentText });
                currentText = '';
              }
              expandedOps.push({ type: 'backspace' });
            } else {
              currentText += char;
            }
          }
          if (currentText.length > 0) {
            expandedOps.push({ type: 'insert', payload: currentText });
          }
        } else {
          expandedOps.push(op);
        }
      }

      if (expandedOps.length === 0) {
        return;
      }

      pushUndo(); // Snapshot before applying batch of updates

      const newLines = [...lines];
      let newCursorRow = cursorRow;
      let newCursorCol = cursorCol;

      const currentLine = (r: number) => newLines[r] ?? '';

      for (const op of expandedOps) {
        if (op.type === 'insert') {
          const str = stripUnsafeCharacters(
            op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'),
          );
          const parts = str.split('\n');
          const lineContent = currentLine(newCursorRow);
          const before = cpSlice(lineContent, 0, newCursorCol);
          const after = cpSlice(lineContent, newCursorCol);
          newLines[newCursorRow] = before + parts[0];

          if (parts.length > 1) {
            const remainingParts = parts.slice(1);
            const lastPartOriginal = remainingParts.pop() ?? '';
            newLines.splice(newCursorRow + 1, 0, ...remainingParts);
            newLines.splice(
              newCursorRow + parts.length - 1,
              0,
              lastPartOriginal + after,
            );
            newCursorRow = newCursorRow + parts.length - 1;
            newCursorCol = cpLen(lastPartOriginal);
          } else {
            newCursorCol = cpLen(before) + cpLen(parts[0]);
          }
        } else if (op.type === 'backspace') {
          if (newCursorCol === 0 && newCursorRow === 0) continue;

          if (newCursorCol > 0) {
            const lineContent = currentLine(newCursorRow);
            newLines[newCursorRow] =
              cpSlice(lineContent, 0, newCursorCol - 1) +
              cpSlice(lineContent, newCursorCol);
            newCursorCol--;
          } else if (newCursorRow > 0) {
            const prevLineContent = currentLine(newCursorRow - 1);
            const currentLineContentVal = currentLine(newCursorRow);
            const newCol = cpLen(prevLineContent);
            newLines[newCursorRow - 1] =
              prevLineContent + currentLineContentVal;
            newLines.splice(newCursorRow, 1);
            newCursorRow--;
            newCursorCol = newCol;
          }
        }
      }

      setLines(newLines);
      setCursorRow(newCursorRow);
      setCursorCol(newCursorCol);
      setPreferredCol(null);
    },
    [lines, cursorRow, cursorCol, pushUndo, setPreferredCol],
  );

  const insert = useCallback(
    (ch: string): void => {
      if (/[\n\r]/.test(ch)) {
        insertStr(ch);
        return;
      }
      dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });

      ch = stripUnsafeCharacters(ch);

      // Arbitrary threshold to avoid false positives on normal key presses
      // while still detecting virtually all reasonable length file paths.
      const minLengthToInferAsDragDrop = 3;
      if (ch.length >= minLengthToInferAsDragDrop) {
        // Possible drag and drop of a file path.
        let potentialPath = ch;
        if (
          potentialPath.length > 2 &&
          potentialPath.startsWith("'") &&
          potentialPath.endsWith("'")
        ) {
          potentialPath = ch.slice(1, -1);
        }

        potentialPath = potentialPath.trim();
        // Be conservative and only add an @ if the path is valid.
        if (isValidPath(unescapePath(potentialPath))) {
          ch = `@${potentialPath}`;
        }
      }
      applyOperations([{ type: 'insert', payload: ch }]);
    },
    [applyOperations, cursorRow, cursorCol, isValidPath, insertStr],
  );

  const newline = useCallback((): void => {
    dbg('newline', { beforeCursor: [cursorRow, cursorCol] });
    applyOperations([{ type: 'insert', payload: '\n' }]);
  }, [applyOperations, cursorRow, cursorCol]);

  const backspace = useCallback((): void => {
    dbg('backspace', { beforeCursor: [cursorRow, cursorCol] });
    if (cursorCol === 0 && cursorRow === 0) return;
    applyOperations([{ type: 'backspace' }]);
  }, [applyOperations, cursorRow, cursorCol]);

  const del = useCallback((): void => {
    dbg('delete', { beforeCursor: [cursorRow, cursorCol] });
    const lineContent = currentLine(cursorRow);
    if (cursorCol < currentLineLen(cursorRow)) {
      pushUndo();
      setLines((prevLines) => {
        const newLines = [...prevLines];
        newLines[cursorRow] =
          cpSlice(lineContent, 0, cursorCol) +
          cpSlice(lineContent, cursorCol + 1);
        return newLines;
      });
    } else if (cursorRow < lines.length - 1) {
      pushUndo();
      const nextLineContent = currentLine(cursorRow + 1);
      setLines((prevLines) => {
        const newLines = [...prevLines];
        newLines[cursorRow] = lineContent + nextLineContent;
        newLines.splice(cursorRow + 1, 1);
        return newLines;
      });
    }
    // cursor position does not change for del
    setPreferredCol(null);
  }, [
    pushUndo,
    cursorRow,
    cursorCol,
    currentLine,
    currentLineLen,
    lines.length,
    setPreferredCol,
  ]);

  const setText = useCallback(
    (newText: string): void => {
      dbg('setText', { text: newText });
      pushUndo();
      const newContentLines = newText.replace(/\r\n?/g, '\n').split('\n');
      setLines(newContentLines.length === 0 ? [''] : newContentLines);
      // Set logical cursor to the end of the new text
      const lastNewLineIndex = newContentLines.length - 1;
      setCursorRow(lastNewLineIndex);
      setCursorCol(cpLen(newContentLines[lastNewLineIndex] ?? ''));
      setPreferredCol(null);
    },
    [pushUndo, setPreferredCol],
  );

  const replaceRange = useCallback(
    (
      startRow: number,
      startCol: number,
      endRow: number,
      endCol: number,
      replacementText: string,
    ): boolean => {
      if (
        startRow > endRow ||
        (startRow === endRow && startCol > endCol) ||
        startRow < 0 ||
        startCol < 0 ||
        endRow >= lines.length ||
        (endRow < lines.length && endCol > currentLineLen(endRow))
      ) {
        console.error('Invalid range provided to replaceRange', {
          startRow,
          startCol,
          endRow,
          endCol,
          linesLength: lines.length,
          endRowLineLength: currentLineLen(endRow),
        });
        return false;
      }
      dbg('replaceRange', {
        start: [startRow, startCol],
        end: [endRow, endCol],
        text: replacementText,
      });
      pushUndo();

      const sCol = clamp(startCol, 0, currentLineLen(startRow));
      const eCol = clamp(endCol, 0, currentLineLen(endRow));

      const prefix = cpSlice(currentLine(startRow), 0, sCol);
      const suffix = cpSlice(currentLine(endRow), eCol);
      const normalisedReplacement = replacementText
        .replace(/\r\n/g, '\n')
        .replace(/\r/g, '\n');
      const replacementParts = normalisedReplacement.split('\n');

      setLines((prevLines) => {
        const newLines = [...prevLines];
        // Remove lines between startRow and endRow (exclusive of startRow, inclusive of endRow if different)
        if (startRow < endRow) {
          newLines.splice(startRow + 1, endRow - startRow);
        }

        // Construct the new content for the startRow
        newLines[startRow] = prefix + replacementParts[0];

        // If replacementText has multiple lines, insert them
        if (replacementParts.length > 1) {
          const lastReplacementPart = replacementParts.pop() ?? ''; // parts are already split by \n
          // Insert middle parts (if any)
          if (replacementParts.length > 1) {
            // parts[0] is already used
            newLines.splice(startRow + 1, 0, ...replacementParts.slice(1));
          }

          // The line where the last part of the replacement will go
          const targetRowForLastPart = startRow + (replacementParts.length - 1); // -1 because parts[0] is on startRow
          // If the last part is not the first part (multi-line replacement)
          if (
            targetRowForLastPart > startRow ||
            (replacementParts.length === 1 && lastReplacementPart !== '')
          ) {
            // If the target row for the last part doesn't exist (because it's a new line created by replacement)
            // ensure it's created before trying to append suffix.
            // This case should be handled by splice if replacementParts.length > 1
            // For single line replacement that becomes multi-line due to parts.length > 1 logic, this is tricky.
            // Let's assume newLines[targetRowForLastPart] exists due to previous splice or it's newLines[startRow]
            if (
              newLines[targetRowForLastPart] === undefined &&
              targetRowForLastPart === startRow + 1 &&
              replacementParts.length === 1
            ) {
              // This implies a single line replacement that became two lines.
              // e.g. "abc" replace "b" with "B\nC" -> "aB", "C", "c"
              // Here, lastReplacementPart is "C", targetRowForLastPart is startRow + 1
              newLines.splice(
                targetRowForLastPart,
                0,
                lastReplacementPart + suffix,
              );
            } else {
              newLines[targetRowForLastPart] =
                (newLines[targetRowForLastPart] || '') +
                lastReplacementPart +
                suffix;
            }
          } else {
            // Single line in replacementParts, but it was the only part
            newLines[startRow] += suffix;
          }

          setCursorRow(targetRowForLastPart);
          setCursorCol(cpLen(newLines[targetRowForLastPart]) - cpLen(suffix));
        } else {
          // Single line replacement (replacementParts has only one item)
          newLines[startRow] += suffix;
          setCursorRow(startRow);
          setCursorCol(cpLen(prefix) + cpLen(replacementParts[0]));
        }
        return newLines;
      });

      setPreferredCol(null);
      return true;
    },
    [pushUndo, lines, currentLine, currentLineLen, setPreferredCol],
  );

  const deleteWordLeft = useCallback((): void => {
    dbg('deleteWordLeft', { beforeCursor: [cursorRow, cursorCol] });
    if (cursorCol === 0 && cursorRow === 0) return;
    if (cursorCol === 0) {
      backspace();
      return;
    }
    pushUndo();
    const lineContent = currentLine(cursorRow);
    const arr = toCodePoints(lineContent);
    let start = cursorCol;
    let onlySpaces = true;
    for (let i = 0; i < start; i++) {
      if (isWordChar(arr[i])) {
        onlySpaces = false;
        break;
      }
    }
    if (onlySpaces && start > 0) {
      start--;
    } else {
      while (start > 0 && !isWordChar(arr[start - 1])) start--;
      while (start > 0 && isWordChar(arr[start - 1])) start--;
    }
    setLines((prevLines) => {
      const newLines = [...prevLines];
      newLines[cursorRow] =
        cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
      return newLines;
    });
    setCursorCol(start);
    setPreferredCol(null);
  }, [pushUndo, cursorRow, cursorCol, currentLine, backspace, setPreferredCol]);

  const deleteWordRight = useCallback((): void => {
    dbg('deleteWordRight', { beforeCursor: [cursorRow, cursorCol] });
    const lineContent = currentLine(cursorRow);
    const arr = toCodePoints(lineContent);
    if (cursorCol >= arr.length && cursorRow === lines.length - 1) return;
    if (cursorCol >= arr.length) {
      del();
      return;
    }
    pushUndo();
    let end = cursorCol;
    while (end < arr.length && !isWordChar(arr[end])) end++;
    while (end < arr.length && isWordChar(arr[end])) end++;
    setLines((prevLines) => {
      const newLines = [...prevLines];
      newLines[cursorRow] =
        cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
      return newLines;
    });
    setPreferredCol(null);
  }, [
    pushUndo,
    cursorRow,
    cursorCol,
    currentLine,
    del,
    lines.length,
    setPreferredCol,
  ]);

  const killLineRight = useCallback((): void => {
    const lineContent = currentLine(cursorRow);
    if (cursorCol < currentLineLen(cursorRow)) {
      // Cursor is before the end of the line's content, delete text to the right
      pushUndo();
      setLines((prevLines) => {
        const newLines = [...prevLines];
        newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
        return newLines;
      });
      // Cursor position and preferredCol do not change in this case
    } else if (
      cursorCol === currentLineLen(cursorRow) &&
      cursorRow < lines.length - 1
    ) {
      // Cursor is at the end of the line's content (or line is empty),
      // and it's not the last line. Delete the newline.
      // `del()` handles pushUndo and setPreferredCol.
      del();
    }
    // If cursor is at the end of the line and it's the last line, do nothing.
  }, [
    pushUndo,
    cursorRow,
    cursorCol,
    currentLine,
    currentLineLen,
    lines.length,
    del,
  ]);

  const killLineLeft = useCallback((): void => {
    const lineContent = currentLine(cursorRow);
    // Only act if the cursor is not at the beginning of the line
    if (cursorCol > 0) {
      pushUndo();
      setLines((prevLines) => {
        const newLines = [...prevLines];
        newLines[cursorRow] = cpSlice(lineContent, cursorCol);
        return newLines;
      });
      setCursorCol(0);
      setPreferredCol(null);
    }
  }, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);

  const move = useCallback(
    (dir: Direction): void => {
      let newVisualRow = visualCursor[0];
      let newVisualCol = visualCursor[1];
      let newPreferredCol = preferredCol;

      const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');

      switch (dir) {
        case 'left':
          newPreferredCol = null;
          if (newVisualCol > 0) {
            newVisualCol--;
          } else if (newVisualRow > 0) {
            newVisualRow--;
            newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
          }
          break;
        case 'right':
          newPreferredCol = null;
          if (newVisualCol < currentVisLineLen) {
            newVisualCol++;
          } else if (newVisualRow < visualLines.length - 1) {
            newVisualRow++;
            newVisualCol = 0;
          }
          break;
        case 'up':
          if (newVisualRow > 0) {
            if (newPreferredCol === null) newPreferredCol = newVisualCol;
            newVisualRow--;
            newVisualCol = clamp(
              newPreferredCol,
              0,
              cpLen(visualLines[newVisualRow] ?? ''),
            );
          }
          break;
        case 'down':
          if (newVisualRow < visualLines.length - 1) {
            if (newPreferredCol === null) newPreferredCol = newVisualCol;
            newVisualRow++;
            newVisualCol = clamp(
              newPreferredCol,
              0,
              cpLen(visualLines[newVisualRow] ?? ''),
            );
          }
          break;
        case 'home':
          newPreferredCol = null;
          newVisualCol = 0;
          break;
        case 'end':
          newPreferredCol = null;
          newVisualCol = currentVisLineLen;
          break;
        // wordLeft and wordRight might need more sophisticated visual handling
        // For now, they operate on the logical line derived from the visual cursor
        case 'wordLeft': {
          newPreferredCol = null;
          if (
            visualToLogicalMap.length === 0 ||
            logicalToVisualMap.length === 0
          )
            break;
          const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
            0, 0,
          ];
          const currentLogCol = logColInitial + newVisualCol;
          const lineText = lines[logRow];
          const sliceToCursor = cpSlice(lineText, 0, currentLogCol).replace(
            /[\s,.;!?]+$/,
            '',
          );
          let lastIdx = 0;
          const regex = /[\s,.;!?]+/g;
          let m;
          while ((m = regex.exec(sliceToCursor)) != null) lastIdx = m.index;
          const newLogicalCol =
            lastIdx === 0 ? 0 : cpLen(sliceToCursor.slice(0, lastIdx)) + 1;

          // Map newLogicalCol back to visual
          const targetLogicalMapEntries = logicalToVisualMap[logRow];
          if (!targetLogicalMapEntries) break;
          for (let i = targetLogicalMapEntries.length - 1; i >= 0; i--) {
            const [visRow, logStartCol] = targetLogicalMapEntries[i];
            if (newLogicalCol >= logStartCol) {
              newVisualRow = visRow;
              newVisualCol = newLogicalCol - logStartCol;
              break;
            }
          }
          break;
        }
        case 'wordRight': {
          newPreferredCol = null;
          if (
            visualToLogicalMap.length === 0 ||
            logicalToVisualMap.length === 0
          )
            break;
          const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
            0, 0,
          ];
          const currentLogCol = logColInitial + newVisualCol;
          const lineText = lines[logRow];
          const regex = /[\s,.;!?]+/g;
          let moved = false;
          let m;
          let newLogicalCol = currentLineLen(logRow); // Default to end of logical line

          while ((m = regex.exec(lineText)) != null) {
            const cpIdx = cpLen(lineText.slice(0, m.index));
            if (cpIdx > currentLogCol) {
              newLogicalCol = cpIdx;
              moved = true;
              break;
            }
          }
          if (!moved && currentLogCol < currentLineLen(logRow)) {
            // If no word break found after cursor, move to end
            newLogicalCol = currentLineLen(logRow);
          }

          // Map newLogicalCol back to visual
          const targetLogicalMapEntries = logicalToVisualMap[logRow];
          if (!targetLogicalMapEntries) break;
          for (let i = 0; i < targetLogicalMapEntries.length; i++) {
            const [visRow, logStartCol] = targetLogicalMapEntries[i];
            const nextLogStartCol =
              i + 1 < targetLogicalMapEntries.length
                ? targetLogicalMapEntries[i + 1][1]
                : Infinity;
            if (
              newLogicalCol >= logStartCol &&
              newLogicalCol < nextLogStartCol
            ) {
              newVisualRow = visRow;
              newVisualCol = newLogicalCol - logStartCol;
              break;
            }
            if (
              newLogicalCol === logStartCol &&
              i === targetLogicalMapEntries.length - 1 &&
              cpLen(visualLines[visRow] ?? '') === 0
            ) {
              // Special case: moving to an empty visual line at the end of a logical line
              newVisualRow = visRow;
              newVisualCol = 0;
              break;
            }
          }
          break;
        }
        default:
          break;
      }

      setVisualCursor([newVisualRow, newVisualCol]);
      setPreferredCol(newPreferredCol);

      // Update logical cursor based on new visual cursor
      if (visualToLogicalMap[newVisualRow]) {
        const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
        setCursorRow(logRow);
        setCursorCol(
          clamp(logStartCol + newVisualCol, 0, currentLineLen(logRow)),
        );
      }

      dbg('move', {
        dir,
        visualBefore: visualCursor,
        visualAfter: [newVisualRow, newVisualCol],
        logicalAfter: [cursorRow, cursorCol],
      });
    },
    [
      visualCursor,
      visualLines,
      preferredCol,
      lines,
      currentLineLen,
      visualToLogicalMap,
      logicalToVisualMap,
      cursorCol,
      cursorRow,
    ],
  );

  const openInExternalEditor = useCallback(
    async (opts: { editor?: string } = {}): Promise<void> => {
      const editor =
        opts.editor ??
        process.env['VISUAL'] ??
        process.env['EDITOR'] ??
        (process.platform === 'win32' ? 'notepad' : 'vi');
      const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
      const filePath = pathMod.join(tmpDir, 'buffer.txt');
      fs.writeFileSync(filePath, text, 'utf8');

      pushUndo(); // Snapshot before external edit

      const wasRaw = stdin?.isRaw ?? false;
      try {
        setRawMode?.(false);
        const { status, error } = spawnSync(editor, [filePath], {
          stdio: 'inherit',
        });
        if (error) throw error;
        if (typeof status === 'number' && status !== 0)
          throw new Error(`External editor exited with status ${status}`);

        let newText = fs.readFileSync(filePath, 'utf8');
        newText = newText.replace(/\r\n?/g, '\n');
        setText(newText);
      } catch (err) {
        console.error('[useTextBuffer] external editor error', err);
        // TODO(jacobr): potentially revert or handle error state.
      } finally {
        if (wasRaw) setRawMode?.(true);
        try {
          fs.unlinkSync(filePath);
        } catch {
          /* ignore */
        }
        try {
          fs.rmdirSync(tmpDir);
        } catch {
          /* ignore */
        }
      }
    },
    [text, pushUndo, stdin, setRawMode, setText],
  );

  const handleInput = useCallback(
    (input: string | undefined, key: Record<string, boolean>): boolean => {
      dbg('handleInput', {
        input,
        key,
        cursor: [cursorRow, cursorCol],
        visualCursor,
      });
      const beforeText = text;
      const beforeLogicalCursor = [cursorRow, cursorCol];
      const beforeVisualCursor = [...visualCursor];

      if (key['escape']) return false;

      if (
        key['return'] ||
        input === '\r' ||
        input === '\n' ||
        input === '\\\r' // VSCode terminal represents shift + enter this way
      )
        newline();
      else if (key['leftArrow'] && !key['meta'] && !key['ctrl'] && !key['alt'])
        move('left');
      else if (key['ctrl'] && input === 'b') move('left');
      else if (key['rightArrow'] && !key['meta'] && !key['ctrl'] && !key['alt'])
        move('right');
      else if (key['ctrl'] && input === 'f') move('right');
      else if (key['upArrow']) move('up');
      else if (key['downArrow']) move('down');
      else if ((key['ctrl'] || key['alt']) && key['leftArrow'])
        move('wordLeft');
      else if (key['meta'] && input === 'b') move('wordLeft');
      else if ((key['ctrl'] || key['alt']) && key['rightArrow'])
        move('wordRight');
      else if (key['meta'] && input === 'f') move('wordRight');
      else if (key['home']) move('home');
      else if (key['ctrl'] && input === 'a') move('home');
      else if (key['end']) move('end');
      else if (key['ctrl'] && input === 'e') move('end');
      else if (key['ctrl'] && input === 'w') deleteWordLeft();
      else if (
        (key['meta'] || key['ctrl'] || key['alt']) &&
        (key['backspace'] || input === '\x7f')
      )
        deleteWordLeft();
      else if ((key['meta'] || key['ctrl'] || key['alt']) && key['delete'])
        deleteWordRight();
      else if (
        key['backspace'] ||
        input === '\x7f' ||
        (key['ctrl'] && input === 'h') ||
        (key['delete'] && !key['shift'])
      )
        backspace();
      else if (key['delete'] || (key['ctrl'] && input === 'd')) del();
      else if (input && !key['ctrl'] && !key['meta']) {
        insert(input);
      }

      const textChanged = text !== beforeText;
      // After operations, visualCursor might not be immediately updated if the change
      // was to `lines`, `cursorRow`, or `cursorCol` which then triggers the useEffect.
      // So, for return value, we check logical cursor change.
      const cursorChanged =
        cursorRow !== beforeLogicalCursor[0] ||
        cursorCol !== beforeLogicalCursor[1] ||
        visualCursor[0] !== beforeVisualCursor[0] ||
        visualCursor[1] !== beforeVisualCursor[1];

      dbg('handleInput:after', {
        cursor: [cursorRow, cursorCol],
        visualCursor,
        text,
      });
      return textChanged || cursorChanged;
    },
    [
      text,
      cursorRow,
      cursorCol,
      visualCursor,
      newline,
      move,
      deleteWordLeft,
      deleteWordRight,
      backspace,
      del,
      insert,
    ],
  );

  const renderedVisualLines = useMemo(
    () => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height),
    [visualLines, visualScrollRow, viewport.height],
  );

  const replaceRangeByOffset = useCallback(
    (
      startOffset: number,
      endOffset: number,
      replacementText: string,
    ): boolean => {
      dbg('replaceRangeByOffset', { startOffset, endOffset, replacementText });
      const [startRow, startCol] = offsetToLogicalPos(text, startOffset);
      const [endRow, endCol] = offsetToLogicalPos(text, endOffset);
      return replaceRange(startRow, startCol, endRow, endCol, replacementText);
    },
    [text, replaceRange],
  );

  const moveToOffset = useCallback(
    (offset: number): void => {
      const [newRow, newCol] = offsetToLogicalPos(text, offset);
      setCursorRow(newRow);
      setCursorCol(newCol);
      setPreferredCol(null);
      dbg('moveToOffset', { offset, newCursor: [newRow, newCol] });
    },
    [text, setPreferredCol],
  );

  const returnValue: TextBuffer = {
    lines,
    text,
    cursor: [cursorRow, cursorCol],
    preferredCol,
    selectionAnchor,

    allVisualLines: visualLines,
    viewportVisualLines: renderedVisualLines,
    visualCursor,
    visualScrollRow,

    setText,
    insert,
    newline,
    backspace,
    del,
    move,
    undo,
    redo,
    replaceRange,
    replaceRangeByOffset,
    moveToOffset, // Added here
    deleteWordLeft,
    deleteWordRight,
    killLineRight,
    killLineLeft,
    handleInput,
    openInExternalEditor,

    applyOperations,

    copy: useCallback(() => {
      if (!selectionAnchor) return null;
      const [ar, ac] = selectionAnchor;
      const [br, bc] = [cursorRow, cursorCol];
      if (ar === br && ac === bc) return null;
      const topBefore = ar < br || (ar === br && ac < bc);
      const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];

      let selectedTextVal;
      if (sr === er) {
        selectedTextVal = cpSlice(currentLine(sr), sc, ec);
      } else {
        const parts: string[] = [cpSlice(currentLine(sr), sc)];
        for (let r = sr + 1; r < er; r++) parts.push(currentLine(r));
        parts.push(cpSlice(currentLine(er), 0, ec));
        selectedTextVal = parts.join('\n');
      }
      setClipboard(selectedTextVal);
      return selectedTextVal;
    }, [selectionAnchor, cursorRow, cursorCol, currentLine, setClipboard]),
    paste: useCallback(() => {
      if (clipboard === null) return false;
      return insertStr(clipboard);
    }, [clipboard, insertStr]),
    startSelection: useCallback(
      () => setSelectionAnchor([cursorRow, cursorCol]),
      [cursorRow, cursorCol, setSelectionAnchor],
    ),
  };
  return returnValue;
}

export interface TextBuffer {
  // State
  lines: string[]; // Logical lines
  text: string;
  cursor: [number, number]; // Logical cursor [row, col]
  /**
   * When the user moves the caret vertically we try to keep their original
   * horizontal column even when passing through shorter lines.  We remember
   * that *preferred* column in this field while the user is still travelling
   * vertically.  Any explicit horizontal movement resets the preference.
   */
  preferredCol: number | null; // Preferred visual column
  selectionAnchor: [number, number] | null; // Logical selection anchor

  // Visual state (handles wrapping)
  allVisualLines: string[]; // All visual lines for the current text and viewport width.
  viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height
  visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines
  visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line)

  // Actions

  /**
   * Replaces the entire buffer content with the provided text.
   * The operation is undoable.
   */
  setText: (text: string) => void;
  /**
   * Insert a single character or string without newlines.
   */
  insert: (ch: string) => void;
  newline: () => void;
  backspace: () => void;
  del: () => void;
  move: (dir: Direction) => void;
  undo: () => boolean;
  redo: () => boolean;
  /**
   * Replaces the text within the specified range with new text.
   * Handles both single-line and multi-line ranges.
   *
   * @param startRow The starting row index (inclusive).
   * @param startCol The starting column index (inclusive, code-point based).
   * @param endRow The ending row index (inclusive).
   * @param endCol The ending column index (exclusive, code-point based).
   * @param text The new text to insert.
   * @returns True if the buffer was modified, false otherwise.
   */
  replaceRange: (
    startRow: number,
    startCol: number,
    endRow: number,
    endCol: number,
    text: string,
  ) => boolean;
  /**
   * Delete the word to the *left* of the caret, mirroring common
   * Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent
   * whitespace *and* the word characters immediately preceding the caret are
   * removed.  If the caret is already at column‑0 this becomes a no-op.
   */
  deleteWordLeft: () => void;
  /**
   * Delete the word to the *right* of the caret, akin to many editors'
   * Ctrl/Alt+Delete shortcut.  Removes any whitespace/punctuation that
   * follows the caret and the next contiguous run of word characters.
   */
  deleteWordRight: () => void;
  /**
   * Deletes text from the cursor to the end of the current line.
   */
  killLineRight: () => void;
  /**
   * Deletes text from the start of the current line to the cursor.
   */
  killLineLeft: () => void;
  /**
   * High level "handleInput" – receives what Ink gives us.
   */
  handleInput: (
    input: string | undefined,
    key: Record<string, boolean>,
  ) => boolean;
  /**
   * Opens the current buffer contents in the user's preferred terminal text
   * editor ($VISUAL or $EDITOR, falling back to "vi").  The method blocks
   * until the editor exits, then reloads the file and replaces the in‑memory
   * buffer with whatever the user saved.
   *
   * The operation is treated as a single undoable edit – we snapshot the
   * previous state *once* before launching the editor so one `undo()` will
   * revert the entire change set.
   *
   * Note: We purposefully rely on the *synchronous* spawn API so that the
   * calling process genuinely waits for the editor to close before
   * continuing.  This mirrors Git's behaviour and simplifies downstream
   * control‑flow (callers can simply `await` the Promise).
   */
  openInExternalEditor: (opts?: { editor?: string }) => Promise<void>;

  // Selection & Clipboard
  copy: () => string | null;
  paste: () => boolean;
  startSelection: () => void;
  replaceRangeByOffset: (
    startOffset: number,
    endOffset: number,
    replacementText: string,
  ) => boolean;
  moveToOffset(offset: number): void;

  // Batch updates
  applyOperations: (ops: UpdateOperation[]) => void;
}
